Merge pull request #1651 from Human-Connection/1650-change_slug

Change your own slug
This commit is contained in:
mattwr18 2019-09-23 10:06:32 +02:00 committed by GitHub
commit a55fab16d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 325 additions and 53 deletions

View File

@ -4,7 +4,7 @@ module.exports = {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
actorId: { type: 'string', allow: [null] }, actorId: { type: 'string', allow: [null] },
name: { type: 'string', disallow: [null], min: 3 }, name: { type: 'string', disallow: [null], min: 3 },
slug: 'string', slug: { type: 'string', regex: /^[a-z0-9_-]+$/, lowercase: true },
encryptedPassword: 'string', encryptedPassword: 'string',
avatar: { type: 'string', allow: [null] }, avatar: { type: 'string', allow: [null] },
coverImg: { type: 'string', allow: [null] }, coverImg: { type: 'string', allow: [null] },

View File

@ -18,3 +18,67 @@ describe('role', () => {
) )
}) })
}) })
describe('slug', () => {
it('normalizes to lowercase letters', async () => {
const user = await instance.create('User', { slug: 'Matt' })
await expect(user.toJson()).resolves.toEqual(
expect.objectContaining({
slug: 'matt',
}),
)
})
it('must be unique', async done => {
await instance.create('User', { slug: 'Matt' })
try {
await expect(instance.create('User', { slug: 'Matt' })).rejects.toThrow('already exists')
done()
} catch (error) {
throw new Error(`
${error}
Probably your database has no unique constraints!
To see all constraints go to http://localhost:7474/browser/ and
paste the following:
\`\`\`
CALL db.constraints();
\`\`\`
Learn how to setup the database here:
https://docs.human-connection.org/human-connection/neo4j
`)
}
})
describe('characters', () => {
const createUser = attrs => {
return instance.create('User', attrs).then(user => user.toJson())
}
it('-', async () => {
await expect(createUser({ slug: 'matt-rider' })).resolves.toMatchObject({
slug: 'matt-rider',
})
})
it('_', async () => {
await expect(createUser({ slug: 'matt_rider' })).resolves.toMatchObject({
slug: 'matt_rider',
})
})
it(' ', async () => {
await expect(createUser({ slug: 'matt rider' })).rejects.toThrow(
/fails to match the required pattern/,
)
})
it('ä', async () => {
await expect(createUser({ slug: 'mätt' })).rejects.toThrow(
/fails to match the required pattern/,
)
})
})
})

View File

@ -18,6 +18,8 @@ When('I save {string} as my new name', name => {
cy.get('[type=submit]') cy.get('[type=submit]')
.click() .click()
.not('[disabled]') .not('[disabled]')
cy.get('.iziToast-message')
.should('contain', 'Your data was successfully updated')
}) })
When('I save {string} as my location', location => { When('I save {string} as my location', location => {
@ -28,6 +30,8 @@ When('I save {string} as my location', location => {
cy.get('[type=submit]') cy.get('[type=submit]')
.click() .click()
.not('[disabled]') .not('[disabled]')
cy.get('.iziToast-message')
.should('contain', 'Your data was successfully updated')
myLocation = location myLocation = location
}) })
@ -38,6 +42,8 @@ When('I have the following self-description:', text => {
cy.get('[type=submit]') cy.get('[type=submit]')
.click() .click()
.not('[disabled]') .not('[disabled]')
cy.get('.iziToast-message')
.should('contain', 'Your data was successfully updated')
aboutMeText = text aboutMeText = text
}) })

View File

@ -0,0 +1,36 @@
import { debounce } from 'lodash'
import { checkSlugAvailableQuery } from '~/graphql/User.js'
export default function UniqueSlugForm({ translate, apollo, currentUser }) {
return {
formSchema: {
slug: [
{
type: 'string',
required: true,
pattern: /^[a-z0-9_-]+$/,
message: translate('settings.validation.slug.regex'),
},
{
asyncValidator(rule, value, callback) {
debounce(() => {
const variables = { slug: value }
apollo.query({ query: checkSlugAvailableQuery, variables }).then(response => {
const {
data: { User },
} = response
const existingSlug = User && User[0] && User[0].slug
const available = !existingSlug || existingSlug === currentUser.slug
if (!available) {
callback(new Error(translate('settings.validation.slug.alreadyTaken')))
} else {
callback()
}
})
}, 500)()
},
},
],
},
}
}

View File

@ -0,0 +1,80 @@
import UniqueSlugForm from './UniqueSlugForm'
import Schema from 'async-validator'
let translate
let apollo
let currentUser
beforeEach(() => {
translate = jest.fn(() => 'Validation error')
apollo = {
query: jest.fn().mockResolvedValue({ data: { User: [] } }),
}
currentUser = null
})
describe('UniqueSlugForm', () => {
let validate = object => {
const { formSchema } = UniqueSlugForm({ translate, apollo, currentUser })
const validator = new Schema(formSchema)
return validator.validate(object, { suppressWarning: true }).catch(({ errors }) => {
throw new Error(errors[0].message)
})
}
describe('regex', () => {
describe('non URL-safe characters, e.g. whitespaces', () => {
it('rejects', async () => {
await expect(validate({ slug: 'uh oh' })).rejects.toThrow('Validation error')
})
})
describe('alphanumeric, hyphens or underscores', () => {
it('validates', async () => {
await expect(validate({ slug: '_all-right_' })).resolves.toBeUndefined()
})
})
})
describe('given a currentUser with a slug', () => {
beforeEach(() => {
currentUser = { slug: 'current-user' }
})
describe('backend returns no user for given slug', () => {
beforeEach(() => {
apollo.query.mockResolvedValue({
data: { User: [] },
})
})
it('validates', async () => {
await expect(validate({ slug: 'slug' })).resolves.toBeUndefined()
})
})
describe('backend returns user', () => {
let slug
beforeEach(() => {
slug = 'already-taken'
apollo.query.mockResolvedValue({
data: { User: [{ slug: 'already-taken' }] },
})
})
it('rejects', async () => {
await expect(validate({ slug: 'uh oh' })).rejects.toThrow('Validation error')
})
describe('but it is the current user', () => {
beforeEach(() => {
currentUser = { slug: 'already-taken' }
})
it('validates', async () => {
await expect(validate({ slug })).resolves.toBeUndefined()
})
})
})
})
})

View File

@ -134,3 +134,11 @@ export const unfollowUserMutation = i18n => {
} }
` `
} }
export const checkSlugAvailableQuery = gql`
query($slug: String!) {
User(slug: $slug) {
slug
}
}
`

View File

@ -151,11 +151,18 @@
"data": { "data": {
"name": "Deine Daten", "name": "Deine Daten",
"labelName": "Dein Name", "labelName": "Dein Name",
"labelSlug": "Dein eindeutiger Benutzername",
"namePlaceholder": "Petra Lustig", "namePlaceholder": "Petra Lustig",
"labelCity": "Deine Stadt oder Region", "labelCity": "Deine Stadt oder Region",
"labelBio": "Über dich", "labelBio": "Über dich",
"success": "Deine Daten wurden erfolgreich aktualisiert!" "success": "Deine Daten wurden erfolgreich aktualisiert!"
}, },
"validation": {
"slug": {
"regex": "Es sind nur Kleinbuchstaben, Zahlen, Unterstriche oder Bindestriche erlaubt.",
"alreadyTaken": "Dieser Benutzername ist schon vergeben."
}
},
"security": { "security": {
"name": "Sicherheit", "name": "Sicherheit",
"change-password": { "change-password": {

View File

@ -152,11 +152,18 @@
"data": { "data": {
"name": "Your data", "name": "Your data",
"labelName": "Your Name", "labelName": "Your Name",
"labelSlug": "Your unique user name",
"namePlaceholder": "Femanon Funny", "namePlaceholder": "Femanon Funny",
"labelCity": "Your City or Region", "labelCity": "Your City or Region",
"labelBio": "About You", "labelBio": "About You",
"success": "Your data was successfully updated!" "success": "Your data was successfully updated!"
}, },
"validation": {
"slug": {
"regex": "Allowed characters are only lowercase letters, numbers, underscores and hyphens.",
"alreadyTaken": "This user name is already taken."
}
},
"security": { "security": {
"name": "Security", "name": "Security",
"change-password": { "change-password": {

View File

@ -98,6 +98,7 @@
"@vue/eslint-config-prettier": "~5.0.0", "@vue/eslint-config-prettier": "~5.0.0",
"@vue/server-test-utils": "~1.0.0-beta.29", "@vue/server-test-utils": "~1.0.0-beta.29",
"@vue/test-utils": "~1.0.0-beta.29", "@vue/test-utils": "~1.0.0-beta.29",
"async-validator": "^3.1.0",
"babel-core": "~7.0.0-bridge.0", "babel-core": "~7.0.0-bridge.0",
"babel-eslint": "~10.0.3", "babel-eslint": "~10.0.3",
"babel-jest": "~24.9.0", "babel-jest": "~24.9.0",

View File

@ -24,6 +24,7 @@ describe('index.vue', () => {
data: { data: {
UpdateUser: { UpdateUser: {
id: 'u1', id: 'u1',
slug: 'peter',
name: 'Peter', name: 'Peter',
locationName: 'Berlin', locationName: 'Berlin',
about: 'Smth', about: 'Smth',
@ -37,34 +38,67 @@ describe('index.vue', () => {
}, },
} }
getters = { getters = {
'auth/user': () => { 'auth/user': () => ({}),
return {}
},
} }
}) })
describe('mount', () => { describe('mount', () => {
let options
const Wrapper = () => { const Wrapper = () => {
store = new Vuex.Store({ store = new Vuex.Store({
getters, getters,
}) })
return mount(index, { store, mocks, localVue }) return mount(index, { store, mocks, localVue, ...options })
} }
beforeEach(() => {
options = {}
})
it('renders', () => { it('renders', () => {
expect(Wrapper().contains('div')).toBe(true) expect(Wrapper().contains('div')).toBe(true)
}) })
describe('given a new username and hitting submit', () => { describe('given form validation errors', () => {
it('calls updateUser mutation', () => { beforeEach(() => {
options = {
...options,
computed: {
formSchema: () => ({
slug: [
(_rule, _value, callback) => {
callback(new Error('Ouch!'))
},
],
}),
},
}
})
it('cannot call updateUser mutation', () => {
const wrapper = Wrapper() const wrapper = Wrapper()
const input = wrapper.find('#name')
const submitForm = wrapper.find('.ds-form')
input.setValue('Peter') wrapper.find('#name').setValue('Peter')
submitForm.trigger('submit') wrapper.find('.ds-form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalled() expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
})
describe('no form validation errors', () => {
beforeEach(() => {
options = { ...options, computed: { formSchema: () => ({}) } }
})
describe('given a new username and hitting submit', () => {
it('calls updateUser mutation', () => {
const wrapper = Wrapper()
wrapper.find('#name').setValue('Peter')
wrapper.find('.ds-form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
}) })
}) })
}) })

View File

@ -1,39 +1,49 @@
<template> <template>
<ds-form v-model="form" @submit="submit"> <ds-form v-model="form" :schema="formSchema" @submit="submit">
<ds-card :header="$t('settings.data.name')"> <template slot-scope="{ errors }">
<ds-input <ds-card :header="$t('settings.data.name')">
id="name" <ds-input
model="name" id="name"
icon="user" model="name"
:label="$t('settings.data.labelName')" icon="user"
:placeholder="$t('settings.data.namePlaceholder')" :label="$t('settings.data.labelName')"
/> :placeholder="$t('settings.data.namePlaceholder')"
<!-- eslint-disable vue/use-v-on-exact --> />
<ds-select <ds-input id="slug" model="slug" icon="at" :label="$t('settings.data.labelSlug')" />
id="city" <!-- eslint-disable vue/use-v-on-exact -->
model="locationName" <ds-select
icon="map-marker" id="city"
:options="cities" model="locationName"
:label="$t('settings.data.labelCity')" icon="map-marker"
:placeholder="$t('settings.data.labelCity')" :options="cities"
:loading="loadingGeo" :label="$t('settings.data.labelCity')"
@input.native="handleCityInput" :placeholder="$t('settings.data.labelCity')"
/> :loading="loadingGeo"
<!-- eslint-enable vue/use-v-on-exact --> @input.native="handleCityInput"
<ds-input />
id="bio" <!-- eslint-enable vue/use-v-on-exact -->
model="about" <ds-input
type="textarea" id="bio"
rows="3" model="about"
:label="$t('settings.data.labelBio')" type="textarea"
:placeholder="$t('settings.data.labelBio')" rows="3"
/> :label="$t('settings.data.labelBio')"
<template slot="footer"> :placeholder="$t('settings.data.labelBio')"
<ds-button style="float: right;" icon="check" type="submit" :loading="loadingData" primary> />
{{ $t('actions.save') }} <template slot="footer">
</ds-button> <ds-button
</template> style="float: right;"
</ds-card> icon="check"
:disabled="errors"
type="submit"
:loading="loadingData"
primary
>
{{ $t('actions.save') }}
</ds-button>
</template>
</ds-card>
</template>
</ds-form> </ds-form>
</template> </template>
@ -42,6 +52,7 @@ import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { CancelToken } from 'axios' import { CancelToken } from 'axios'
import UniqueSlugForm from '~/components/utils/UniqueSlugForm'
let timeout let timeout
const mapboxToken = process.env.MAPBOX_TOKEN const mapboxToken = process.env.MAPBOX_TOKEN
@ -60,9 +71,10 @@ const query = gql`
*/ */
const mutation = gql` const mutation = gql`
mutation($id: ID!, $name: String, $locationName: String, $about: String) { mutation($id: ID!, $slug: String, $name: String, $locationName: String, $about: String) {
UpdateUser(id: $id, name: $name, locationName: $locationName, about: $about) { UpdateUser(id: $id, slug: $slug, name: $name, locationName: $locationName, about: $about) {
id id
slug
name name
locationName locationName
about about
@ -84,10 +96,20 @@ export default {
...mapGetters({ ...mapGetters({
currentUser: 'auth/user', currentUser: 'auth/user',
}), }),
formSchema() {
const uniqueSlugForm = UniqueSlugForm({
apollo: this.$apollo,
currentUser: this.currentUser,
translate: this.$t,
})
return {
...uniqueSlugForm.formSchema,
}
},
form: { form: {
get: function() { get: function() {
const { name, locationName, about } = this.currentUser const { name, slug, locationName, about } = this.currentUser
return { name, locationName, about } return { name, slug, locationName, about }
}, },
set: function(formData) { set: function(formData) {
this.formData = formData this.formData = formData
@ -100,7 +122,7 @@ export default {
}), }),
async submit() { async submit() {
this.loadingData = true this.loadingData = true
const { name, about } = this.formData const { name, slug, about } = this.formData
let { locationName } = this.formData || this.currentUser let { locationName } = this.formData || this.currentUser
locationName = locationName && (locationName['label'] || locationName) locationName = locationName && (locationName['label'] || locationName)
try { try {
@ -109,14 +131,16 @@ export default {
variables: { variables: {
id: this.currentUser.id, id: this.currentUser.id,
name, name,
slug,
locationName, locationName,
about, about,
}, },
update: (store, { data: { UpdateUser } }) => { update: (store, { data: { UpdateUser } }) => {
const { name, locationName, about } = UpdateUser const { name, slug, locationName, about } = UpdateUser
this.setCurrentUser({ this.setCurrentUser({
...this.currentUser, ...this.currentUser,
name, name,
slug,
locationName, locationName,
about, about,
}) })

View File

@ -3661,6 +3661,11 @@ async-retry@^1.2.1:
dependencies: dependencies:
retry "0.12.0" retry "0.12.0"
async-validator@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/async-validator/-/async-validator-3.1.0.tgz#447db5eb003cbb47e650f040037a29fc3881ce92"
integrity sha512-XyAHGwtpx3Y3aHIOaGXXFo4tiulnrh+mXBU9INxig6Q8rtmtmBxDuCxb60j7EIGbAsQg9cxfJ2jrUZ+fIqEnBQ==
async@^2.1.4: async@^2.1.4:
version "2.6.2" version "2.6.2"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381" resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381"