Merge branch 'master' of github.com:Human-Connection/Human-Connection into 384-emotions-on-posts

This commit is contained in:
Matt Rider 2019-08-01 09:22:57 +02:00
commit 08899a4af9
29 changed files with 1242 additions and 501 deletions

View File

@ -48,7 +48,7 @@
"apollo-client": "~2.6.3", "apollo-client": "~2.6.3",
"apollo-link-context": "~1.0.18", "apollo-link-context": "~1.0.18",
"apollo-link-http": "~1.5.15", "apollo-link-http": "~1.5.15",
"apollo-server": "~2.7.2", "apollo-server": "~2.8.0",
"apollo-server-express": "^2.7.2", "apollo-server-express": "^2.7.2",
"bcryptjs": "~2.4.3", "bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3", "cheerio": "~1.0.0-rc.3",
@ -62,7 +62,7 @@
"graphql": "~14.4.2", "graphql": "~14.4.2",
"graphql-custom-directives": "~0.2.14", "graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1", "graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.2", "graphql-middleware": "~3.0.3",
"graphql-shield": "~6.0.4", "graphql-shield": "~6.0.4",
"graphql-tag": "~2.10.1", "graphql-tag": "~2.10.1",
"helmet": "~3.20.0", "helmet": "~3.20.0",
@ -106,7 +106,7 @@
"@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.5.5", "@babel/preset-env": "~7.5.5",
"@babel/register": "~7.5.5", "@babel/register": "~7.5.5",
"apollo-server-testing": "~2.7.2", "apollo-server-testing": "~2.8.0",
"babel-core": "~7.0.0-0", "babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.2", "babel-eslint": "~10.0.2",
"babel-jest": "~24.8.0", "babel-jest": "~24.8.0",

View File

@ -1,4 +1,7 @@
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield' import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
import { neode } from '../bootstrap/neo4j'
const instance = neode()
/* /*
* TODO: implement * TODO: implement
@ -7,7 +10,7 @@ import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
const isAuthenticated = rule({ const isAuthenticated = rule({
cache: 'contextual', cache: 'contextual',
})(async (_parent, _args, ctx, _info) => { })(async (_parent, _args, ctx, _info) => {
return ctx.user !== null return ctx.user != null
}) })
const isModerator = rule()(async (parent, args, { user }, info) => { const isModerator = rule()(async (parent, args, { user }, info) => {
@ -36,6 +39,14 @@ const isMyOwn = rule({
return context.user.id === parent.id return context.user.id === parent.id
}) })
const isMySocialMedia = rule({
cache: 'no_cache',
})(async (_, args, { user }) => {
let socialMedia = await instance.find('SocialMedia', args.id)
socialMedia = await socialMedia.toJson()
return socialMedia.ownedBy.node.id === user.id
})
const belongsToMe = rule({ const belongsToMe = rule({
cache: 'no_cache', cache: 'no_cache',
})(async (_, args, context) => { })(async (_, args, context) => {
@ -170,7 +181,8 @@ const permissions = shield(
DeletePost: isAuthor, DeletePost: isAuthor,
report: isAuthenticated, report: isAuthenticated,
CreateSocialMedia: isAuthenticated, CreateSocialMedia: isAuthenticated,
DeleteSocialMedia: isAuthenticated, UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia,
// AddBadgeRewarded: isAdmin, // AddBadgeRewarded: isAdmin,
// RemoveBadgeRewarded: isAdmin, // RemoveBadgeRewarded: isAdmin,
reward: isAdmin, reward: isAdmin,

View File

@ -1,23 +1,8 @@
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import Joi from '@hapi/joi'
const COMMENT_MIN_LENGTH = 1 const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const validate = schema => {
return async (resolve, root, args, context, info) => {
const validation = schema.validate(args)
if (validation.error) throw new UserInputError(validation.error)
return resolve(root, args, context, info)
}
}
const socialMediaSchema = Joi.object().keys({
url: Joi.string()
.uri()
.required(),
})
const validateCommentCreation = async (resolve, root, args, context, info) => { const validateCommentCreation = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args const { postId } = args
@ -57,7 +42,6 @@ const validateUpdateComment = async (resolve, root, args, context, info) => {
export default { export default {
Mutation: { Mutation: {
CreateSocialMedia: validate(socialMediaSchema),
CreateComment: validateCommentCreation, CreateComment: validateCommentCreation,
UpdateComment: validateUpdateComment, UpdateComment: validateUpdateComment,
}, },

View File

@ -0,0 +1,15 @@
import uuid from 'uuid/v4'
module.exports = {
id: { type: 'string', primary: true, default: uuid },
url: { type: 'string', uri: true, required: true },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
ownedBy: {
type: 'relationship',
relationship: 'OWNED_BY',
target: 'User',
direction: 'in',
eager: true,
cascade: 'detach',
},
}

View File

@ -5,4 +5,5 @@ export default {
User: require('./User.js'), User: require('./User.js'),
InvitationCode: require('./InvitationCode.js'), InvitationCode: require('./InvitationCode.js'),
EmailAddress: require('./EmailAddress.js'), EmailAddress: require('./EmailAddress.js'),
SocialMedia: require('./SocialMedia.js'),
} }

View File

@ -19,6 +19,7 @@ export default applyScalars(
'Notfication', 'Notfication',
'Statistics', 'Statistics',
'LoggedInUser', 'LoggedInUser',
'SocialMedia',
], ],
// add 'User' here as soon as possible // add 'User' here as soon as possible
}, },
@ -30,6 +31,7 @@ export default applyScalars(
'Notfication', 'Notfication',
'Statistics', 'Statistics',
'LoggedInUser', 'LoggedInUser',
'SocialMedia',
], ],
// add 'User' here as soon as possible // add 'User' here as soon as possible
}, },

View File

@ -1,5 +1,5 @@
import scrape from './embeds/scraper.js' import scrape from './embeds/scraper.js'
import { undefinedToNull } from '../helpers' import { undefinedToNullResolver } from './helpers/Resolver'
export default { export default {
Query: { Query: {
@ -8,7 +8,7 @@ export default {
}, },
}, },
Embed: { Embed: {
...undefinedToNull([ ...undefinedToNullResolver([
'type', 'type',
'title', 'title',
'author', 'author',

View File

@ -0,0 +1,75 @@
import { neode } from '../../../bootstrap/neo4j'
export const undefinedToNullResolver = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}
export default function Resolver(type, options = {}) {
const instance = neode()
const {
idAttribute = 'id',
undefinedToNull = [],
count = {},
hasOne = {},
hasMany = {},
} = options
const _hasResolver = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `MATCH(:${type} {${idAttribute}: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const countResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `
MATCH(u:${type} {${idAttribute}: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
const hasManyResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
const hasOneResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
const result = {
...undefinedToNullResolver(undefinedToNull),
...countResolver(count),
...hasOneResolver(hasOne),
...hasManyResolver(hasMany),
}
return result
}

View File

@ -1,30 +1,38 @@
import { neo4jgraphql } from 'neo4j-graphql-js' import { neode } from '../../bootstrap/neo4j'
import Resolver from './helpers/Resolver'
const instance = neode()
export default { export default {
Mutation: { Mutation: {
CreateSocialMedia: async (object, params, context, resolveInfo) => { CreateSocialMedia: async (object, params, context, resolveInfo) => {
/** const [user, socialMedia] = await Promise.all([
* TODO?: Creates double Nodes! instance.find('User', context.user.id),
*/ instance.create('SocialMedia', params),
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false) ])
const session = context.driver.session() await socialMedia.relateTo(user, 'ownedBy')
await session.run( const response = await socialMedia.toJson()
`MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId})
MERGE (socialMedia)<-[:OWNED]-(owner)
RETURN owner`,
{
userId: context.user.id,
socialMediaId: socialMedia.id,
},
)
session.close()
return socialMedia return response
}, },
DeleteSocialMedia: async (object, params, context, resolveInfo) => { UpdateSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false) const socialMedia = await instance.find('SocialMedia', params.id)
await socialMedia.update({ url: params.url })
const response = await socialMedia.toJson()
return socialMedia return response
},
DeleteSocialMedia: async (object, { id }, context, resolveInfo) => {
const socialMedia = await instance.find('SocialMedia', id)
if (!socialMedia) return null
await socialMedia.delete()
return socialMedia.toJson()
}, },
}, },
SocialMedia: Resolver('SocialMedia', {
idAttribute: 'url',
hasOne: {
ownedBy: '<-[:OWNED_BY]-(related:User)',
},
}),
} }

View File

@ -1,13 +1,70 @@
import { GraphQLClient } from 'graphql-request' import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
import Factory from '../../seed/factories' import Factory from '../../seed/factories'
import { host, login, gql } from '../../jest/helpers' import { gql } from '../../jest/helpers'
import { neode, getDriver } from '../../bootstrap/neo4j'
const driver = getDriver()
const factory = Factory() const factory = Factory()
const instance = neode()
describe('SocialMedia', () => { describe('SocialMedia', () => {
let client let socialMediaAction, someUser, ownerNode, owner
let headers
const mutationC = gql` const ownerParams = {
email: 'pippi@example.com',
password: '1234',
name: 'Pippi Langstrumpf',
}
const userParams = {
email: 'kalle@example.com',
password: 'abcd',
name: 'Kalle Blomqvist',
}
const url = 'https://twitter.com/pippi-langstrumpf'
const newUrl = 'https://twitter.com/bullerby'
const setUpSocialMedia = async () => {
const socialMediaNode = await instance.create('SocialMedia', { url })
await socialMediaNode.relateTo(ownerNode, 'ownedBy')
return socialMediaNode.toJson()
}
beforeEach(async () => {
const someUserNode = await instance.create('User', userParams)
someUser = await someUserNode.toJson()
ownerNode = await instance.create('User', ownerParams)
owner = await ownerNode.toJson()
socialMediaAction = async (user, mutation, variables) => {
const { server } = createServer({
context: () => {
return {
user,
driver,
}
},
})
const { mutate } = createTestClient(server)
return mutate({
mutation,
variables,
})
}
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('create social media', () => {
let mutation, variables
beforeEach(() => {
mutation = gql`
mutation($url: String!) { mutation($url: String!) {
CreateSocialMedia(url: $url) { CreateSocialMedia(url: $url) {
id id
@ -15,7 +72,154 @@ describe('SocialMedia', () => {
} }
} }
` `
const mutationD = gql` variables = { url }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated', () => {
let user
beforeEach(() => {
user = owner
})
it('creates social media with the given url', async () => {
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: {
id: expect.any(String),
url,
},
},
}),
)
})
it('rejects an empty string as url', async () => {
variables = { url: '' }
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" is not allowed to be empty'),
)
})
it('rejects invalid urls', async () => {
variables = { url: 'not-a-url' }
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" must be a valid uri'),
)
})
})
describe('ownedBy', () => {
beforeEach(() => {
mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
url
ownedBy {
name
}
}
}
`
})
it('resolves', async () => {
const user = someUser
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: { url, ownedBy: { name: 'Kalle Blomqvist' } },
},
}),
)
})
})
})
describe('update social media', () => {
let mutation, variables
beforeEach(async () => {
const socialMedia = await setUpSocialMedia()
mutation = gql`
mutation($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
variables = { url: newUrl, id: socialMedia.id }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated as other user', () => {
it('throws authorization error', async () => {
const user = someUser
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated as owner', () => {
let user
beforeEach(() => {
user = owner
})
it('updates social media with the given id', async () => {
const expected = {
data: {
UpdateSocialMedia: { ...variables },
},
}
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining(expected),
)
})
it('does not update if the the given id does not exist', async () => {
variables.id = 'some-id'
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
})
describe('delete social media', () => {
let mutation, variables
beforeEach(async () => {
const socialMedia = await setUpSocialMedia()
mutation = gql`
mutation($id: ID!) { mutation($id: ID!) {
DeleteSocialMedia(id: $id) { DeleteSocialMedia(id: $id) {
id id
@ -23,93 +227,48 @@ describe('SocialMedia', () => {
} }
} }
` `
beforeEach(async () => { variables = { url: newUrl, id: socialMedia.id }
await factory.create('User', {
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg',
id: 'acb2d923-f3af-479e-9f00-61b12e864666',
name: 'Matilde Hermiston',
slug: 'matilde-hermiston',
role: 'user',
email: 'test@example.org',
password: '1234',
})
})
afterEach(async () => {
await factory.cleanDatabase()
}) })
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
client = new GraphQLClient(host) const user = null
const variables = { const result = await socialMediaAction(user, mutation, variables)
url: 'http://nsosp.org',
} expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
await expect(client.request(mutationC, variables)).rejects.toThrow('Not Authorised')
}) })
}) })
describe('authenticated', () => { describe('authenticated as other user', () => {
it('throws authorization error', async () => {
const user = someUser
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated as owner', () => {
let user
beforeEach(async () => { beforeEach(async () => {
headers = await login({ user = owner
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
}) })
it('creates social media with correct URL', async () => { it('deletes social media with the given id', async () => {
const variables = {
url: 'http://nsosp.org',
}
await expect(client.request(mutationC, variables)).resolves.toEqual(
expect.objectContaining({
CreateSocialMedia: {
id: expect.any(String),
url: 'http://nsosp.org',
},
}),
)
})
it('deletes social media', async () => {
const creationVariables = {
url: 'http://nsosp.org',
}
const { CreateSocialMedia } = await client.request(mutationC, creationVariables)
const { id } = CreateSocialMedia
const deletionVariables = {
id,
}
const expected = { const expected = {
data: {
DeleteSocialMedia: { DeleteSocialMedia: {
id: id, id: variables.id,
url: 'http://nsosp.org', url,
},
}, },
} }
await expect(client.request(mutationD, deletionVariables)).resolves.toEqual(expected)
})
it('rejects empty string', async () => { await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
const variables = { expect.objectContaining(expected),
url: '',
}
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" is not allowed to be empty',
) )
}) })
it('validates URLs', async () => {
const variables = {
url: 'not-a-url',
}
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" must be a valid uri',
)
}) })
}) })
}) })

View File

@ -2,57 +2,10 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload' import fileUpload from './fileUpload'
import { neode } from '../../bootstrap/neo4j' import { neode } from '../../bootstrap/neo4j'
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import { undefinedToNull } from '../helpers' import Resolver from './helpers/Resolver'
const instance = neode() const instance = neode()
const _has = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `MATCH(u:User {id: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const count = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `
MATCH(u:User {id: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
export const hasMany = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
export const hasOne = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
export default { export default {
Query: { Query: {
User: async (object, args, context, resolveInfo) => { User: async (object, args, context, resolveInfo) => {
@ -110,7 +63,8 @@ export default {
let [{ email }] = result.records.map(r => r.get('e').properties) let [{ email }] = result.records.map(r => r.get('e').properties)
return email return email
}, },
...undefinedToNull([ ...Resolver('User', {
undefinedToNull: [
'actorId', 'actorId',
'avatar', 'avatar',
'coverImg', 'coverImg',
@ -118,8 +72,8 @@ export default {
'disabled', 'disabled',
'locationName', 'locationName',
'about', 'about',
]), ],
...count({ count: {
contributionsCount: '-[:WROTE]->(related:Post)', contributionsCount: '-[:WROTE]->(related:Post)',
friendsCount: '<-[:FRIENDS]->(related:User)', friendsCount: '<-[:FRIENDS]->(related:User)',
followingCount: '-[:FOLLOWS]->(related:User)', followingCount: '-[:FOLLOWS]->(related:User)',
@ -128,17 +82,17 @@ export default {
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)', commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
shoutedCount: '-[:SHOUTED]->(related:Post)', shoutedCount: '-[:SHOUTED]->(related:Post)',
badgesCount: '<-[:REWARDED]-(related:Badge)', badgesCount: '<-[:REWARDED]-(related:Badge)',
}), },
...hasOne({ hasOne: {
invitedBy: '<-[:INVITED]-(related:User)', invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)', disabledBy: '<-[:DISABLED]-(related:User)',
}), },
...hasMany({ hasMany: {
followedBy: '<-[:FOLLOWS]-(related:User)', followedBy: '<-[:FOLLOWS]-(related:User)',
following: '-[:FOLLOWS]->(related:User)', following: '-[:FOLLOWS]->(related:User)',
friends: '-[:FRIENDS]-(related:User)', friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)', blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED]->(related:SocialMedia)', socialMedia: '-[:OWNED_BY]->(related:SocialMedia',
contributions: '-[:WROTE]->(related:Post)', contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)', comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)', shouted: '-[:SHOUTED]->(related:Post)',
@ -146,6 +100,7 @@ export default {
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)', organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)', categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)', badges: '<-[:REWARDED]-(related:Badge)',
},
}), }),
}, },
} }

View File

@ -131,8 +131,3 @@ type SharedInboxEndpoint {
uri: String uri: String
} }
type SocialMedia {
id: ID!
url: String
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
}

View File

@ -0,0 +1,11 @@
type SocialMedia {
id: ID!
url: String
ownedBy: User! @relation(name: "OWNED_BY", direction: "IN")
}
type Mutation {
CreateSocialMedia(id: ID, url: String!): SocialMedia
UpdateSocialMedia(id: ID!, url: String!): SocialMedia
DeleteSocialMedia(id: ID!): SocialMedia
}

View File

@ -17,7 +17,7 @@ type User {
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
locationName: String locationName: String
about: String about: String
socialMedia: [SocialMedia]! @relation(name: "OWNED", direction: "OUT") socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "OUT")
#createdAt: DateTime #createdAt: DateTime
#updatedAt: DateTime #updatedAt: DateTime

View File

@ -1583,10 +1583,10 @@ apollo-server-caching@0.5.0:
dependencies: dependencies:
lru-cache "^5.0.0" lru-cache "^5.0.0"
apollo-server-core@2.7.2: apollo-server-core@2.8.0:
version "2.7.2" version "2.8.0"
resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.7.2.tgz#4acd9f4d0d235bef0e596e2a821326dfc07ae7b2" resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.8.0.tgz#0bfba3d5eb557c6ffa68ad60e77f69e2634e211d"
integrity sha512-Dv6ZMMf8Y+ovkj1ioMtcYvjbcsSMqnZblbPPzOWo29vvKEjMXAL1OTSL1WBYxGA/WSBSCTnxAzipn71XZkYoCw== integrity sha512-Bilaaaol8c4mpF+8DatsAm+leKd0lbz1jS7M+WIuu8GscAXFzzfT6311dNC7zx0wT5FUNNdHdvQOry/lyCn5GA==
dependencies: dependencies:
"@apollographql/apollo-tools" "^0.4.0" "@apollographql/apollo-tools" "^0.4.0"
"@apollographql/graphql-playground-html" "1.6.24" "@apollographql/graphql-playground-html" "1.6.24"
@ -1601,7 +1601,7 @@ apollo-server-core@2.7.2:
apollo-server-types "0.2.1" apollo-server-types "0.2.1"
apollo-tracing "0.8.1" apollo-tracing "0.8.1"
fast-json-stable-stringify "^2.0.0" fast-json-stable-stringify "^2.0.0"
graphql-extensions "0.8.2" graphql-extensions "0.9.0"
graphql-tag "^2.9.2" graphql-tag "^2.9.2"
graphql-tools "^4.0.0" graphql-tools "^4.0.0"
graphql-upload "^8.0.2" graphql-upload "^8.0.2"
@ -1622,10 +1622,10 @@ apollo-server-errors@2.3.1:
resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.1.tgz#033cf331463ebb99a563f8354180b41ac6714eb6" resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.1.tgz#033cf331463ebb99a563f8354180b41ac6714eb6"
integrity sha512-errZvnh0vUQChecT7M4A/h94dnBSRL213dNxpM5ueMypaLYgnp4hiCTWIEaooo9E4yMGd1qA6WaNbLDG2+bjcg== integrity sha512-errZvnh0vUQChecT7M4A/h94dnBSRL213dNxpM5ueMypaLYgnp4hiCTWIEaooo9E4yMGd1qA6WaNbLDG2+bjcg==
apollo-server-express@2.7.2, apollo-server-express@^2.7.2: apollo-server-express@2.8.0, apollo-server-express@^2.7.2:
version "2.7.2" version "2.8.0"
resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.7.2.tgz#a6b9514f42463c9514d2dda34e07ee240b73f764" resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.8.0.tgz#3815eee2fccfc9cba6d232420fa7411cda062647"
integrity sha512-XW+MTKyjJDrHqeLJt9Z3OzLTCRxp53XzVVhF0f/Bs9GCODPlTiBaoiMwY2mXQ7WqK6gkYAH1kRp7d/psPFKE5w== integrity sha512-7dj4CVyOMz1HeVoF8nw3aKw7QV/5D6PACiweu6k9xPRHurYf0bj3ncYkAMPNnxIAwu1I8FzMn4/84BWoKJ7ZFg==
dependencies: dependencies:
"@apollographql/graphql-playground-html" "1.6.24" "@apollographql/graphql-playground-html" "1.6.24"
"@types/accepts" "^1.3.5" "@types/accepts" "^1.3.5"
@ -1633,7 +1633,7 @@ apollo-server-express@2.7.2, apollo-server-express@^2.7.2:
"@types/cors" "^2.8.4" "@types/cors" "^2.8.4"
"@types/express" "4.17.0" "@types/express" "4.17.0"
accepts "^1.3.5" accepts "^1.3.5"
apollo-server-core "2.7.2" apollo-server-core "2.8.0"
apollo-server-types "0.2.1" apollo-server-types "0.2.1"
body-parser "^1.18.3" body-parser "^1.18.3"
cors "^2.8.4" cors "^2.8.4"
@ -1649,12 +1649,12 @@ apollo-server-plugin-base@0.6.1:
dependencies: dependencies:
apollo-server-types "0.2.1" apollo-server-types "0.2.1"
apollo-server-testing@~2.7.2: apollo-server-testing@~2.8.0:
version "2.7.2" version "2.8.0"
resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.7.2.tgz#c0fb18fa7eef0c945c5b73887d19c704dac5957e" resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.8.0.tgz#57c31575d51d13f09b5a14709c482b9d5986cf58"
integrity sha512-fjWJ6K5t3xilPrXg5rQtqFZN0JbNSthkNyJb4Qfpdj9WA5r0vZCeARAFcIrv7o3pSBstyy1UBvJuNG0Rw6HTzA== integrity sha512-a+9OZcqNeeUkOGVDOfuSmrXsTu3LnG9NvfM/4H2XJBJWHzghiuU6xZV2yHetZSTLXsAvWw3To2j1g8+/A8Yqsg==
dependencies: dependencies:
apollo-server-core "2.7.2" apollo-server-core "2.8.0"
apollo-server-types@0.2.1: apollo-server-types@0.2.1:
version "0.2.1" version "0.2.1"
@ -1665,13 +1665,13 @@ apollo-server-types@0.2.1:
apollo-server-caching "0.5.0" apollo-server-caching "0.5.0"
apollo-server-env "2.4.1" apollo-server-env "2.4.1"
apollo-server@~2.7.2: apollo-server@~2.8.0:
version "2.7.2" version "2.8.0"
resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.7.2.tgz#a3eeb6916f11802502ab40819e9f06a4c553c84a" resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.8.0.tgz#c57261f02f9f2865778ad8e0cdb3c6a80307beb5"
integrity sha512-0FkNi2ViLJoTglTuBTZ8OeUSK2/LOk4sMGmojDYUYkyVuM5lZX+GWVf3pDNvhrnC2po6TkntkNL4EJLXfKwNMA== integrity sha512-WtHbP8/C7WkFBCA44V2uTiyuefgqlVSAb6di4XcCPLyopcg9XGKHYRPyp5uOOKlMDTfryNqV59DWHn5/oXkZmQ==
dependencies: dependencies:
apollo-server-core "2.7.2" apollo-server-core "2.8.0"
apollo-server-express "2.7.2" apollo-server-express "2.8.0"
express "^4.0.0" express "^4.0.0"
graphql-subscriptions "^1.0.0" graphql-subscriptions "^1.0.0"
graphql-tools "^4.0.0" graphql-tools "^4.0.0"
@ -3994,6 +3994,15 @@ graphql-extensions@0.8.2:
apollo-server-env "2.4.1" apollo-server-env "2.4.1"
apollo-server-types "0.2.1" apollo-server-types "0.2.1"
graphql-extensions@0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.9.0.tgz#88fb3b161f84a92f4a9032b2941919113600635d"
integrity sha512-0GQjQ2t2Nkg9OIk3eS5jcvQLzFkJtVB73t4AnEl7bejPwwShtY37XzE7mOlfof1OqbvRKvKFoks+wSjus2Fhzw==
dependencies:
"@apollographql/apollo-tools" "^0.4.0"
apollo-server-env "2.4.1"
apollo-server-types "0.2.1"
graphql-import@0.7.1: graphql-import@0.7.1:
version "0.7.1" version "0.7.1"
resolved "https://registry.yarnpkg.com/graphql-import/-/graphql-import-0.7.1.tgz#4add8d91a5f752d764b0a4a7a461fcd93136f223" resolved "https://registry.yarnpkg.com/graphql-import/-/graphql-import-0.7.1.tgz#4add8d91a5f752d764b0a4a7a461fcd93136f223"
@ -4007,12 +4016,12 @@ graphql-iso-date@~3.6.1:
resolved "https://registry.yarnpkg.com/graphql-iso-date/-/graphql-iso-date-3.6.1.tgz#bd2d0dc886e0f954cbbbc496bbf1d480b57ffa96" resolved "https://registry.yarnpkg.com/graphql-iso-date/-/graphql-iso-date-3.6.1.tgz#bd2d0dc886e0f954cbbbc496bbf1d480b57ffa96"
integrity sha512-AwFGIuYMJQXOEAgRlJlFL4H1ncFM8n8XmoVDTNypNOZyQ8LFDG2ppMFlsS862BSTCDcSUfHp8PD3/uJhv7t59Q== integrity sha512-AwFGIuYMJQXOEAgRlJlFL4H1ncFM8n8XmoVDTNypNOZyQ8LFDG2ppMFlsS862BSTCDcSUfHp8PD3/uJhv7t59Q==
graphql-middleware@~3.0.2: graphql-middleware@~3.0.3:
version "3.0.2" version "3.0.3"
resolved "https://registry.yarnpkg.com/graphql-middleware/-/graphql-middleware-3.0.2.tgz#c8cdb67615eec02aec237b455e679f5fc973ddc4" resolved "https://registry.yarnpkg.com/graphql-middleware/-/graphql-middleware-3.0.3.tgz#58cbce80892fb933d72794447f33f978fc743aa5"
integrity sha512-sRqu1sF+77z42z1OVM1QDHKQWnWY5K3nAgqWiZwx3U4tqNZprrDuXxSChPMliV343IrVkpYdejUYq9w24Ot3FA== integrity sha512-Os8Vt25MqqwIPJUCCcHznzs6EqarGmM0kkNPUiDnMEkX6vqjA+HugCWatinP+7+fqBqecFUsJmoL4ZypdqZZkg==
dependencies: dependencies:
graphql-tools "^4.0.4" graphql-tools "^4.0.5"
graphql-request@~1.8.2: graphql-request@~1.8.2:
version "1.8.2" version "1.8.2"
@ -4062,10 +4071,10 @@ graphql-toolkit@0.4.1:
tslib "^1.9.3" tslib "^1.9.3"
valid-url "1.0.9" valid-url "1.0.9"
graphql-tools@^4.0.0, graphql-tools@^4.0.4: graphql-tools@^4.0.0, graphql-tools@^4.0.4, graphql-tools@^4.0.5:
version "4.0.4" version "4.0.5"
resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.4.tgz#ca08a63454221fdde825fe45fbd315eb2a6d566b" resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.5.tgz#d2b41ee0a330bfef833e5cdae7e1f0b0d86b1754"
integrity sha512-chF12etTIGVVGy3fCTJ1ivJX2KB7OSG4c6UOJQuqOHCmBQwTyNgCDuejZKvpYxNZiEx7bwIjrodDgDe9RIkjlw== integrity sha512-kQCh3IZsMqquDx7zfIGWBau42xe46gmqabwYkpPlCLIjcEY1XK+auP7iGRD9/205BPyoQdY8hT96MPpgERdC9Q==
dependencies: dependencies:
apollo-link "^1.2.3" apollo-link "^1.2.3"
apollo-utilities "^1.0.1" apollo-utilities "^1.0.1"

View File

@ -1,73 +1,70 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps' import { When, Then } from "cypress-cucumber-preprocessor/steps";
When('I search for {string}', value => { When("I search for {string}", value => {
cy.get('#nav-search') cy.get("#nav-search")
.focus() .focus()
.type(value) .type(value);
}) });
Then('I should have one post in the select dropdown', () => { Then("I should have one post in the select dropdown", () => {
cy.get('.ds-select-dropdown').should($li => { cy.get(".input .ds-select-dropdown").should($li => {
expect($li).to.have.length(1) expect($li).to.have.length(1);
}) });
}) });
Then('I should see the following posts in the select dropdown:', table => { Then("I should see the following posts in the select dropdown:", table => {
table.hashes().forEach(({ title }) => { table.hashes().forEach(({ title }) => {
cy.get('.ds-select-dropdown').should('contain', title) cy.get(".ds-select-dropdown").should("contain", title);
}) });
}) });
When('I type {string} and press Enter', value => { When("I type {string} and press Enter", value => {
cy.get('#nav-search') cy.get("#nav-search")
.focus() .focus()
.type(value) .type(value)
.type('{enter}', { force: true }) .type("{enter}", { force: true });
}) });
When('I type {string} and press escape', value => { When("I type {string} and press escape", value => {
cy.get('#nav-search') cy.get("#nav-search")
.focus() .focus()
.type(value) .type(value)
.type('{esc}') .type("{esc}");
}) });
Then('the search field should clear', () => { Then("the search field should clear", () => {
cy.get('#nav-search').should('have.text', '') cy.get("#nav-search").should("have.text", "");
}) });
When('I select an entry', () => { When("I select an entry", () => {
cy.get('.ds-select-dropdown ul li') cy.get(".input .ds-select-dropdown ul li")
.first() .first()
.trigger('click') .trigger("click");
}) });
Then("I should be on the post's page", () => { Then("I should be on the post's page", () => {
cy.location('pathname').should( cy.location("pathname").should("contain", "/post/");
'contain', cy.location("pathname").should(
'/post/' "eq",
) "/post/p1/101-essays-that-will-change-the-way-you-think"
cy.location('pathname').should( );
'eq', });
'/post/p1/101-essays-that-will-change-the-way-you-think'
)
})
Then( Then(
'I should see posts with the searched-for term in the select dropdown', "I should see posts with the searched-for term in the select dropdown",
() => { () => {
cy.get('.ds-select-dropdown').should( cy.get(".ds-select-dropdown").should(
'contain', "contain",
'101 Essays that will change the way you think' "101 Essays that will change the way you think"
) );
} }
) );
Then( Then(
'I should not see posts without the searched-for term in the select dropdown', "I should not see posts without the searched-for term in the select dropdown",
() => { () => {
cy.get('.ds-select-dropdown').should( cy.get(".ds-select-dropdown").should(
'not.contain', "not.contain",
'No searched for content' "No searched for content"
) );
} }
) );

View File

@ -79,7 +79,7 @@ Then('I should be on the {string} page', page => {
}) })
When('I add a social media link', () => { When('I add a social media link', () => {
cy.get("input[name='social-media']") cy.get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan') .type('https://freeradical.zone/peter-pan')
.get('button') .get('button')
.contains('Add link') .contains('Add link')
@ -98,7 +98,7 @@ Then('the new social media link shows up on the page', () => {
Given('I have added a social media link', () => { Given('I have added a social media link', () => {
cy.openPage('/settings/my-social-media') cy.openPage('/settings/my-social-media')
.get("input[name='social-media']") .get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan') .type('https://freeradical.zone/peter-pan')
.get('button') .get('button')
.contains('Add link') .contains('Add link')
@ -121,3 +121,34 @@ Then('it gets deleted successfully', () => {
cy.get('.iziToast-message') cy.get('.iziToast-message')
.should('contain', 'Deleted social media') .should('contain', 'Deleted social media')
}) })
When('I start editing a social media link', () => {
cy.get("a[name='edit']")
.click()
})
Then('I can cancel editing', () => {
cy.get('button#cancel')
.click()
.get('input#editSocialMedia')
.should('have.length', 0)
})
When('I edit and save the link', () => {
cy.get('input#editSocialMedia')
.clear()
.type('https://freeradical.zone/tinkerbell')
.get('button')
.contains('Save')
.click()
})
Then('the new url is displayed', () => {
cy.get("a[href='https://freeradical.zone/tinkerbell']")
.should('have.length', 1)
})
Then('the old url is not displayed', () => {
cy.get("a[href='https://freeradical.zone/peter-pan']")
.should('have.length', 0)
})

View File

@ -15,7 +15,7 @@ Feature: List Social Media Accounts
Then it gets saved successfully Then it gets saved successfully
And the new social media link shows up on the page And the new social media link shows up on the page
Scenario: Other user's viewing my Social Media Scenario: Other users viewing my Social Media
Given I have added a social media link Given I have added a social media link
When people visit my profile page When people visit my profile page
Then they should be able to see my social media links Then they should be able to see my social media links
@ -27,3 +27,16 @@ Feature: List Social Media Accounts
Given I have added a social media link Given I have added a social media link
When I delete a social media link When I delete a social media link
Then it gets deleted successfully Then it gets deleted successfully
Scenario: Editing Social Media
Given I am on the "settings" page
And I click on the "Social media" link
Then I should be on the "/settings/my-social-media" page
Given I have added a social media link
When I start editing a social media link
Then I can cancel editing
When I start editing a social media link
And I edit and save the link
Then it gets saved successfully
And the new url is displayed
But the old url is not displayed

View File

@ -14,6 +14,8 @@ localVue.use(Styleguide)
localVue.use(Filters) localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>' config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('ContributionForm.vue', () => { describe('ContributionForm.vue', () => {
let wrapper let wrapper
@ -24,9 +26,22 @@ describe('ContributionForm.vue', () => {
let mocks let mocks
let propsData let propsData
const postTitle = 'this is a title for a post' const postTitle = 'this is a title for a post'
const postTitleTooShort = 'xx'
let postTitleTooLong = ''
for (let i = 0; i < 65; i++) {
postTitleTooLong += 'x'
}
const postContent = 'this is a post' const postContent = 'this is a post'
const postContentTooShort = 'xx'
let postContentTooLong = ''
for (let i = 0; i < 2001; i++) {
postContentTooLong += 'x'
}
const imageUpload = { const imageUpload = {
file: { filename: 'avataar.svg', previewElement: '' }, file: {
filename: 'avataar.svg',
previewElement: '',
},
url: 'someUrlToImage', url: 'someUrlToImage',
} }
const image = '/uploads/1562010976466-avataaars' const image = '/uploads/1562010976466-avataaars'
@ -34,9 +49,7 @@ describe('ContributionForm.vue', () => {
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn(),
$apollo: { $apollo: {
mutate: jest mutate: jest.fn().mockResolvedValueOnce({
.fn()
.mockResolvedValueOnce({
data: { data: {
CreatePost: { CreatePost: {
title: postTitle, title: postTitle,
@ -46,9 +59,6 @@ describe('ContributionForm.vue', () => {
language: 'en', language: 'en',
}, },
}, },
})
.mockRejectedValue({
message: 'Not Authorised!',
}), }),
}, },
$toast: { $toast: {
@ -71,6 +81,13 @@ describe('ContributionForm.vue', () => {
'editor/placeholder': () => { 'editor/placeholder': () => {
return 'some cool placeholder' return 'some cool placeholder'
}, },
'auth/user': () => {
return {
id: '4711',
name: 'You yourself',
slug: 'you-yourself',
}
},
} }
const store = new Vuex.Store({ const store = new Vuex.Store({
getters, getters,
@ -106,16 +123,53 @@ describe('ContributionForm.vue', () => {
}) })
describe('invalid form submission', () => { describe('invalid form submission', () => {
it('title required for form submission', async () => { it('title and content should not be empty ', async () => {
postTitleInput = wrapper.find('.ds-input') wrapper.find('.submit-button-for-test').trigger('click')
postTitleInput.setValue(postTitle)
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled() expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
}) })
it('content required for form submission', async () => { it('title should not be empty', async () => {
wrapper.vm.updateEditorContent(postContent) await wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit') wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('title should not be too long', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitleTooLong)
await wrapper.vm.updateEditorContent(postContent)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('title should not be too short', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitleTooShort)
await wrapper.vm.updateEditorContent(postContent)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be empty', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be too short', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContentTooShort)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be too long', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContentTooLong)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled() expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
}) })
}) })
@ -136,15 +190,16 @@ describe('ContributionForm.vue', () => {
} }
postTitleInput = wrapper.find('.ds-input') postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle) postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent) await wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit')
}) })
it('with title and content', () => { it('with title and content', () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
}) })
it("sends a fallback language based on a user's locale", () => { it("sends a fallback language based on a user's locale", () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
@ -152,7 +207,7 @@ describe('ContributionForm.vue', () => {
expectedParams.variables.language = 'de' expectedParams.variables.language = 'de'
deutschOption = wrapper.findAll('li').at(0) deutschOption = wrapper.findAll('li').at(0)
deutschOption.trigger('click') deutschOption.trigger('click')
await wrapper.find('form').trigger('submit') wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
@ -160,22 +215,26 @@ describe('ContributionForm.vue', () => {
const categoryIds = ['cat12', 'cat15', 'cat37'] const categoryIds = ['cat12', 'cat15', 'cat37']
expectedParams.variables.categoryIds = categoryIds expectedParams.variables.categoryIds = categoryIds
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
it('supports adding a teaser image', async () => { it('supports adding a teaser image', async () => {
expectedParams.variables.imageUpload = imageUpload expectedParams.variables.imageUpload = imageUpload
wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload) wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload)
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
it("pushes the user to the post's page", async () => { it("pushes the user to the post's page", async () => {
wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate
expect(mocks.$router.push).toHaveBeenCalledTimes(1) expect(mocks.$router.push).toHaveBeenCalledTimes(1)
}) })
it('shows a success toaster', () => { it('shows a success toaster', async () => {
wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate
expect(mocks.$toast.success).toHaveBeenCalledTimes(1) expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
}) })
}) })
@ -191,18 +250,19 @@ describe('ContributionForm.vue', () => {
describe('handles errors', () => { describe('handles errors', () => {
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers() jest.useFakeTimers()
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({
message: 'Not Authorised!',
})
wrapper = Wrapper() wrapper = Wrapper()
postTitleInput = wrapper.find('.ds-input') postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle) postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent) await wrapper.vm.updateEditorContent(postContent)
// second submission causes mutation to reject
await wrapper.find('form').trigger('submit')
}) })
it('shows an error toaster when apollo mutation rejects', async () => { it('shows an error toaster when apollo mutation rejects', async () => {
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate await mocks.$apollo.mutate
expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!') await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
}) })
}) })
}) })
@ -217,7 +277,12 @@ describe('ContributionForm.vue', () => {
content: 'auf Deutsch geschrieben', content: 'auf Deutsch geschrieben',
language: 'de', language: 'de',
image, image,
categories: [{ id: 'cat12', name: 'Democracy & Politics' }], categories: [
{
id: 'cat12',
name: 'Democracy & Politics',
},
],
}, },
} }
wrapper = Wrapper() wrapper = Wrapper()
@ -255,7 +320,7 @@ describe('ContributionForm.vue', () => {
postTitleInput = wrapper.find('.ds-input') postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle) postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent) wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
@ -266,7 +331,7 @@ describe('ContributionForm.vue', () => {
wrapper.vm.updateEditorContent(postContent) wrapper.vm.updateEditorContent(postContent)
expectedParams.variables.categoryIds = categoryIds expectedParams.variables.categoryIds = categoryIds
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
}) })

View File

@ -1,5 +1,5 @@
<template> <template>
<ds-form ref="contributionForm" v-model="form" :schema="formSchema" @submit="submit"> <ds-form ref="contributionForm" v-model="form" :schema="formSchema">
<template slot-scope="{ errors }"> <template slot-scope="{ errors }">
<ds-card> <ds-card>
<hc-teaser-image :contribution="contribution" @addTeaserImage="addTeaserImage"> <hc-teaser-image :contribution="contribution" @addTeaserImage="addTeaserImage">
@ -13,6 +13,7 @@
<hc-user :user="currentUser" :trunc="35" /> <hc-user :user="currentUser" :trunc="35" />
<ds-space /> <ds-space />
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus /> <ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
<small class="smallTag">{{ form.title.length }}/{{ formSchema.title.max }}</small>
<no-ssr> <no-ssr>
<hc-editor <hc-editor
:users="users" :users="users"
@ -20,6 +21,7 @@
:value="form.content" :value="form.content"
@input="updateEditorContent" @input="updateEditorContent"
/> />
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
</no-ssr> </no-ssr>
<ds-space margin-bottom="xxx-large" /> <ds-space margin-bottom="xxx-large" />
<hc-categories-select <hc-categories-select
@ -44,18 +46,20 @@
<div slot="footer" style="text-align: right"> <div slot="footer" style="text-align: right">
<ds-button <ds-button
class="cancel-button" class="cancel-button"
:disabled="loading || disabled" :disabled="loading"
ghost ghost
@click.prevent="$router.back()" @click.prevent="$router.back()"
> >
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
</ds-button> </ds-button>
<ds-button <ds-button
class="submit-button-for-test"
type="submit" type="submit"
icon="check" icon="check"
:loading="loading" :loading="loading"
:disabled="disabled || errors" :disabled="disabledByContent || errors"
primary primary
@click.prevent="submit"
> >
{{ $t('actions.save') }} {{ $t('actions.save') }}
</ds-button> </ds-button>
@ -92,6 +96,7 @@ export default {
form: { form: {
title: '', title: '',
content: '', content: '',
contentLength: 0,
teaserImage: null, teaserImage: null,
image: null, image: null,
language: null, language: null,
@ -100,13 +105,16 @@ export default {
}, },
formSchema: { formSchema: {
title: { required: true, min: 3, max: 64 }, title: { required: true, min: 3, max: 64 },
content: { required: true, min: 3 }, content: [{ required: true }],
}, },
id: null, id: null,
loading: false, loading: false,
disabled: false, disabledByContent: true,
slug: null, slug: null,
users: [], users: [],
contentMin: 3,
contentMax: 2000,
hashtags: [], hashtags: [],
} }
}, },
@ -119,8 +127,9 @@ export default {
} }
this.id = contribution.id this.id = contribution.id
this.slug = contribution.slug this.slug = contribution.slug
this.form.content = contribution.content
this.form.title = contribution.title this.form.title = contribution.title
this.form.content = contribution.content
this.manageContent(this.form.content)
this.form.image = contribution.image this.form.image = contribution.image
this.form.categoryIds = this.categoryIds(contribution.categories) this.form.categoryIds = this.categoryIds(contribution.categories)
}, },
@ -169,7 +178,7 @@ export default {
.then(res => { .then(res => {
this.loading = false this.loading = false
this.$toast.success(this.$t('contribution.success')) this.$toast.success(this.$t('contribution.success'))
this.disabled = true this.disabledByContent = true
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost'] const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({ this.$router.push({
@ -180,12 +189,21 @@ export default {
.catch(err => { .catch(err => {
this.$toast.error(err.message) this.$toast.error(err.message)
this.loading = false this.loading = false
this.disabled = false this.disabledByContent = false
}) })
}, },
updateEditorContent(value) { updateEditorContent(value) {
// this.form.content = value // TODO: Do smth????? what is happening
this.$refs.contributionForm.update('content', value) this.$refs.contributionForm.update('content', value)
this.manageContent(value)
},
manageContent(content) {
// filter HTML out of content value
const str = content.replace(/<\/?[^>]+(>|$)/gm, '')
// Set counter length of text
this.form.contentLength = str.length
// Enable save button if requirements are met
this.disabledByContent = !(this.contentMin <= str.length && str.length <= this.contentMax)
}, },
availableLocales() { availableLocales() {
orderBy(locales, 'name').map(locale => { orderBy(locales, 'name').map(locale => {
@ -242,6 +260,11 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
.smallTag {
width: 100%;
position: relative;
left: 90%;
}
.post-title { .post-title {
margin-top: $space-x-small; margin-top: $space-x-small;
margin-bottom: $space-xx-small; margin-bottom: $space-xx-small;

View File

@ -80,8 +80,8 @@ export default i18n => {
export const filterPosts = i18n => { export const filterPosts = i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`
query Post($filter: _PostFilter, $first: Int, $offset: Int) { query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
Post(filter: $filter, first: $first, offset: $offset) { Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id id
title title
contentExcerpt contentExcerpt

View File

@ -23,6 +23,12 @@
"bank": "Bankverbindung", "bank": "Bankverbindung",
"germany": "Deutschland" "germany": "Deutschland"
}, },
"sorting": {
"newest": "Neuste",
"oldest": "Älteste",
"popular": "Beliebt",
"commented": "meist Kommentiert"
},
"login": { "login": {
"copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.", "copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.",
"login": "Einloggen", "login": "Einloggen",
@ -168,7 +174,8 @@
}, },
"social-media": { "social-media": {
"name": "Soziale Medien", "name": "Soziale Medien",
"placeholder": "Füge eine Social-Media URL hinzu", "placeholder": "Deine Social-Media URL",
"requireUnique": "Dieser Link existiert bereits",
"submit": "Link hinzufügen", "submit": "Link hinzufügen",
"successAdd": "Social-Media hinzugefügt. Profil aktualisiert!", "successAdd": "Social-Media hinzugefügt. Profil aktualisiert!",
"successDelete": "Social-Media gelöscht. Profil aktualisiert!" "successDelete": "Social-Media gelöscht. Profil aktualisiert!"
@ -286,6 +293,7 @@
"reportContent": "Melden", "reportContent": "Melden",
"validations": { "validations": {
"email": "muss eine gültige E-Mail Adresse sein", "email": "muss eine gültige E-Mail Adresse sein",
"url": "muss eine gültige URL sein",
"verification-code": "muss genau 6 Buchstaben lang sein" "verification-code": "muss genau 6 Buchstaben lang sein"
} }
}, },

View File

@ -23,6 +23,12 @@
"bank": "bank account", "bank": "bank account",
"germany": "Germany" "germany": "Germany"
}, },
"sorting": {
"newest": "Newest",
"oldest": "Oldest",
"popular": "Popular",
"commented": "most Commented"
},
"login": { "login": {
"copy": "If you already have a human-connection account, login here.", "copy": "If you already have a human-connection account, login here.",
"login": "Login", "login": "Login",
@ -169,7 +175,8 @@
}, },
"social-media": { "social-media": {
"name": "Social media", "name": "Social media",
"placeholder": "Add social media url", "placeholder": "Your social media url",
"requireUnique": "You added this url already",
"submit": "Add link", "submit": "Add link",
"successAdd": "Added social media. Updated user profile!", "successAdd": "Added social media. Updated user profile!",
"successDelete": "Deleted social media. Updated user profile!" "successDelete": "Deleted social media. Updated user profile!"
@ -288,6 +295,7 @@
"reportContent": "Report", "reportContent": "Report",
"validations": { "validations": {
"email": "must be a valid email address", "email": "must be a valid email address",
"url": "must be a valid URL",
"verification-code": "must be 6 characters long" "verification-code": "must be 6 characters long"
} }
}, },

View File

@ -76,7 +76,7 @@
"vue-count-to": "~1.0.13", "vue-count-to": "~1.0.13",
"vue-izitoast": "1.1.2", "vue-izitoast": "1.1.2",
"vue-sweetalert-icons": "~3.2.0", "vue-sweetalert-icons": "~3.2.0",
"vuex-i18n": "~1.11.0", "vuex-i18n": "~1.13.0",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
@ -101,6 +101,7 @@
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.0", "eslint-plugin-standard": "~4.0.0",
"eslint-plugin-vue": "~5.2.3", "eslint-plugin-vue": "~5.2.3",
"flush-promises": "^1.0.2",
"fuse.js": "^3.4.5", "fuse.js": "^3.4.5",
"jest": "~24.8.0", "jest": "~24.8.0",
"node-sass": "~4.12.0", "node-sass": "~4.12.0",

140
webapp/pages/index.spec.js Normal file
View File

@ -0,0 +1,140 @@
import { config, shallowMount, mount, createLocalVue } from '@vue/test-utils'
import PostIndex from './index.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
import VTooltip from 'v-tooltip'
import FilterMenu from '~/components/FilterMenu/FilterMenu'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
localVue.use(VTooltip)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['router-link'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('PostIndex', () => {
let wrapper
let Wrapper
let store
let mocks
beforeEach(() => {
store = new Vuex.Store({
getters: {
'posts/posts': () => {
return [
{
id: 'p23',
name: 'It is a post',
author: {
id: 'u1',
},
},
]
},
'auth/user': () => {
return { id: 'u23' }
},
},
})
mocks = {
$t: key => key,
$filters: {
truncate: a => a,
removeLinks: jest.fn(),
},
// If you are mocking router, than don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$router: {
history: {
push: jest.fn(),
},
push: jest.fn(),
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
queries: {
Post: {
refetch: jest.fn(),
fetchMore: jest.fn().mockResolvedValue([
{
id: 'p23',
name: 'It is a post',
author: {
id: 'u1',
},
},
]),
},
},
},
$route: {
query: {},
},
}
})
describe('shallowMount', () => {
Wrapper = () => {
return shallowMount(PostIndex, {
store,
mocks,
localVue,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('refetches Posts when changeFilterBubble is emitted', () => {
wrapper.find(FilterMenu).vm.$emit('changeFilterBubble')
expect(mocks.$apollo.queries.Post.refetch).toHaveBeenCalledTimes(1)
})
it('clears the search when the filter menu emits clearSearch', () => {
wrapper.find(FilterMenu).vm.$emit('clearSearch')
expect(wrapper.vm.hashtag).toBeNull()
})
it('calls the changeFilterBubble if there are hasthags in the route query', () => {
mocks.$route.query.hashtag = { id: 'hashtag' }
wrapper = Wrapper()
expect(mocks.$apollo.queries.Post.refetch).toHaveBeenCalledTimes(1)
})
describe('mount', () => {
beforeEach(() => {
wrapper = mount(PostIndex, {
store,
mocks,
localVue,
})
})
it('sets the post in the store when there are posts', () => {
wrapper
.findAll('li')
.at(0)
.trigger('click')
expect(wrapper.vm.sorting).toEqual('createdAt_desc')
})
it('loads more posts when a user clicks on the load more button', () => {
wrapper
.findAll('button')
.at(2)
.trigger('click')
expect(mocks.$apollo.queries.Post.fetchMore).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@ -9,6 +9,17 @@
@clearSearch="clearSearch" @clearSearch="clearSearch"
/> />
</ds-flex-item> </ds-flex-item>
<ds-flex-item>
<div class="sorting-dropdown">
<ds-select
v-model="selected"
:options="sortingOptions"
size="large"
v-bind:icon-right="sortingIcon"
@input="toggleOnlySorting"
></ds-select>
</div>
</ds-flex-item>
<hc-post-card <hc-post-card
v-for="(post, index) in posts" v-for="(post, index) in posts"
:key="post.id" :key="post.id"
@ -53,6 +64,36 @@ export default {
pageSize: 12, pageSize: 12,
filter: {}, filter: {},
hashtag, hashtag,
placeholder: this.$t('sorting.newest'),
selected: this.$t('sorting.newest'),
sortingIcon: 'sort-amount-desc',
sorting: 'createdAt_desc',
sortingOptions: [
{
label: this.$t('sorting.newest'),
value: 'Newest',
icons: 'sort-amount-desc',
order: 'createdAt_desc',
},
{
label: this.$t('sorting.oldest'),
value: 'Oldest',
icons: 'sort-amount-asc',
order: 'createdAt_asc',
},
{
label: this.$t('sorting.popular'),
value: 'Popular',
icons: 'fire',
order: 'shoutedCount_desc',
},
{
label: this.$t('sorting.commented'),
value: 'Commented',
icons: 'comment',
order: 'commentsCount_desc',
},
],
} }
}, },
mounted() { mounted() {
@ -89,7 +130,12 @@ export default {
} }
} }
this.filter = filter this.filter = filter
this.$apollo.queries.Post.refresh() this.$apollo.queries.Post.refetch()
},
toggleOnlySorting(x) {
this.sortingIcon = x.icons
this.sorting = x.order
this.$apollo.queries.Post.refetch()
}, },
clearSearch() { clearSearch() {
this.$router.push({ path: '/' }) this.$router.push({ path: '/' })
@ -144,6 +190,7 @@ export default {
filter: this.filter, filter: this.filter,
first: this.pageSize, first: this.pageSize,
offset: 0, offset: 0,
orderBy: this.sorting,
} }
}, },
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
@ -161,4 +208,11 @@ export default {
transform: translate(-120%, -120%); transform: translate(-120%, -120%);
box-shadow: $box-shadow-x-large; box-shadow: $box-shadow-x-large;
} }
.sorting-dropdown {
width: 250px;
position: relative;
float: right;
padding: 0 18px;
}
</style> </style>

View File

@ -1,4 +1,5 @@
import { mount, createLocalVue } from '@vue/test-utils' import { mount, createLocalVue } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import MySocialMedia from './my-social-media.vue' import MySocialMedia from './my-social-media.vue'
import Vuex from 'vuex' import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
@ -12,23 +13,17 @@ localVue.use(Filters)
describe('my-social-media.vue', () => { describe('my-social-media.vue', () => {
let wrapper let wrapper
let store
let mocks let mocks
let getters let getters
let input
let submitBtn
const socialMediaUrl = 'https://freeradical.zone/@mattwr18' const socialMediaUrl = 'https://freeradical.zone/@mattwr18'
const newSocialMediaUrl = 'https://twitter.com/mattwr18'
const faviconUrl = 'https://freeradical.zone/favicon.ico'
beforeEach(() => { beforeEach(() => {
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn(),
$apollo: { $apollo: {
mutate: jest mutate: jest.fn(),
.fn()
.mockRejectedValue({ message: 'Ouch!' })
.mockResolvedValueOnce({
data: { CreateSocialMeda: { id: 's1', url: socialMediaUrl } },
}),
}, },
$toast: { $toast: {
error: jest.fn(), error: jest.fn(),
@ -43,79 +38,161 @@ describe('my-social-media.vue', () => {
}) })
describe('mount', () => { describe('mount', () => {
let form, input, submitButton
const Wrapper = () => { const Wrapper = () => {
store = new Vuex.Store({ const store = new Vuex.Store({
getters, getters,
}) })
return mount(MySocialMedia, { store, mocks, localVue }) return mount(MySocialMedia, { store, mocks, localVue })
} }
it('renders', () => { describe('adding social media link', () => {
beforeEach(() => {
wrapper = Wrapper() wrapper = Wrapper()
expect(wrapper.contains('div')).toBe(true) form = wrapper.find('form')
input = wrapper.find('input#addSocialMedia')
submitButton = wrapper.find('button')
}) })
describe('given currentUser has a social media account linked', () => { it('requires the link to be a valid url', () => {
input.setValue('some value')
form.trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('displays an error message when not saved successfully', async () => {
mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
input.setValue(newSocialMediaUrl)
form.trigger('submit')
await flushPromises()
expect(mocks.$toast.error).toHaveBeenCalledTimes(1)
})
describe('success', () => {
beforeEach(() => {
mocks.$apollo.mutate.mockResolvedValue({
data: { CreateSocialMedia: { id: 's2', url: newSocialMediaUrl } },
})
input.setValue(newSocialMediaUrl)
form.trigger('submit')
})
it('sends the new url to the backend', () => {
const expected = expect.objectContaining({
variables: { url: newSocialMediaUrl },
})
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('displays a success message', async () => {
await flushPromises()
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('clears the form', async () => {
await flushPromises()
expect(input.value).toBe(undefined)
expect(submitButton.vm.$attrs.disabled).toBe(true)
})
})
})
describe('given existing social media links', () => {
beforeEach(() => { beforeEach(() => {
getters = { getters = {
'auth/user': () => { 'auth/user': () => ({
return {
socialMedia: [{ id: 's1', url: socialMediaUrl }], socialMedia: [{ id: 's1', url: socialMediaUrl }],
}
},
}
})
it("displays a link to the currentUser's social media", () => {
wrapper = Wrapper()
const socialMediaLink = wrapper.find('a').attributes().href
expect(socialMediaLink).toBe(socialMediaUrl)
})
beforeEach(() => {
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest
.fn()
.mockRejectedValue({ message: 'Ouch!' })
.mockResolvedValueOnce({
data: { DeleteSocialMeda: { id: 's1', url: socialMediaUrl } },
}), }),
},
$toast: {
error: jest.fn(),
success: jest.fn(),
},
}
getters = {
'auth/user': () => {
return {
socialMedia: [{ id: 's1', url: socialMediaUrl }],
}
},
} }
wrapper = Wrapper()
form = wrapper.find('form')
}) })
it('displays a trash sympol after a social media and allows the user to delete it', () => { describe('for each link it', () => {
wrapper = Wrapper() it('displays the favicon', () => {
const deleteSelector = wrapper.find({ name: 'delete' }) expect(wrapper.find(`img[src="${faviconUrl}"]`).exists()).toBe(true)
expect(deleteSelector).toEqual({ selector: 'Component' }) })
const icon = wrapper.find({ name: 'trash' })
icon.trigger('click') it('displays the url', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) expect(wrapper.find(`a[href="${socialMediaUrl}"]`).exists()).toBe(true)
})
it('displays the edit button', () => {
expect(wrapper.find('a[name="edit"]').exists()).toBe(true)
})
it('displays the delete button', () => {
expect(wrapper.find('a[name="delete"]').exists()).toBe(true)
}) })
}) })
describe('currentUser does not have a social media account linked', () => { it('does not accept a duplicate url', () => {
it('allows a user to add a social media link', () => { input = wrapper.find('input#addSocialMedia')
wrapper = Wrapper()
input = wrapper.find({ name: 'social-media' }) input.setValue(socialMediaUrl)
input.element.value = socialMediaUrl form.trigger('submit')
input.trigger('input')
submitBtn = wrapper.find('.ds-button') expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
submitBtn.trigger('click') })
describe('editing social media link', () => {
beforeEach(() => {
const editButton = wrapper.find('a[name="edit"]')
editButton.trigger('click')
input = wrapper.find('input#editSocialMedia')
})
it('disables adding new links while editing', () => {
const addInput = wrapper.find('input#addSocialMedia')
expect(addInput.exists()).toBe(false)
})
it('sends the new url to the backend', () => {
const expected = expect.objectContaining({
variables: { id: 's1', url: newSocialMediaUrl },
})
input.setValue(newSocialMediaUrl)
form.trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('allows the user to cancel editing', () => {
const cancelButton = wrapper.find('button#cancel')
cancelButton.trigger('click')
expect(wrapper.find('input#editSocialMedia').exists()).toBe(false)
})
})
describe('deleting social media link', () => {
beforeEach(() => {
const deleteButton = wrapper.find('a[name="delete"]')
deleteButton.trigger('click')
})
it('sends the link id to the backend', () => {
const expected = expect.objectContaining({
variables: { id: 's1' },
})
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('displays a success message', async () => {
await flushPromises()
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
}) })
}) })
}) })

View File

@ -1,49 +1,90 @@
<template> <template>
<ds-form
v-model="formData"
:schema="formSchema"
@input="handleInput"
@input-valid="handleInputValid"
@submit="handleSubmitSocialMedia"
>
<ds-card :header="$t('settings.social-media.name')"> <ds-card :header="$t('settings.social-media.name')">
<ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small"> <ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small">
<ds-list> <ds-list>
<ds-list-item v-for="link in socialMediaLinks" :key="link.id"> <ds-list-item v-for="link in socialMediaLinks" :key="link.id" class="list-item--high">
<ds-input
v-if="editingLink.id === link.id"
id="editSocialMedia"
model="socialMediaUrl"
type="text"
:placeholder="$t('settings.social-media.placeholder')"
/>
<template v-else>
<a :href="link.url" target="_blank"> <a :href="link.url" target="_blank">
<img :src="link.favicon | proxyApiUrl" alt="Social Media link" width="16" height="16" /> <img :src="link.favicon" alt="Link:" height="16" width="16" />
{{ link.url }} {{ link.url }}
</a> </a>
&nbsp;&nbsp; <span class="divider">|</span>
<span class="layout-leave-active">|</span> <a name="edit" @click="handleEditSocialMedia(link)">
&nbsp;&nbsp; <ds-icon
<ds-icon name="edit" class="layout-leave-active" /> :aria-label="$t('actions.edit')"
<a name="delete" @click="handleDeleteSocialMedia(link)"> class="icon-button"
<ds-icon name="trash" /> name="edit"
:title="$t('actions.edit')"
/>
</a> </a>
<a name="delete" @click="handleDeleteSocialMedia(link)">
<ds-icon
:aria-label="$t('actions.delete')"
class="icon-button"
name="trash"
:title="$t('actions.delete')"
/>
</a>
</template>
</ds-list-item> </ds-list-item>
</ds-list> </ds-list>
</ds-space> </ds-space>
<ds-space margin-top="base"> <ds-space margin-top="base">
<div>
<ds-input <ds-input
v-model="value" v-if="!editingLink.id"
id="addSocialMedia"
model="socialMediaUrl"
type="text"
:placeholder="$t('settings.social-media.placeholder')" :placeholder="$t('settings.social-media.placeholder')"
name="social-media"
:schema="{ type: 'url' }"
/> />
</div>
<ds-space margin-top="base"> <ds-space margin-top="base">
<div> <ds-button primary :disabled="disabled">
<ds-button primary @click="handleAddSocialMedia"> {{ editingLink.id ? $t('actions.save') : $t('settings.social-media.submit') }}
{{ $t('settings.social-media.submit') }} </ds-button>
<ds-button v-if="editingLink.id" id="cancel" ghost @click="handleCancel()">
{{ $t('actions.cancel') }}
</ds-button> </ds-button>
</div>
</ds-space> </ds-space>
</ds-space> </ds-space>
</ds-card> </ds-card>
</ds-form>
</template> </template>
<script> <script>
import unionBy from 'lodash/unionBy'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
export default { export default {
data() { data() {
return { return {
value: '', formData: {
socialMediaUrl: '',
},
formSchema: {
socialMediaUrl: {
type: 'url',
message: this.$t('common.validations.url'),
},
},
disabled: true,
editingLink: {},
} }
}, },
computed: { computed: {
@ -51,11 +92,10 @@ export default {
currentUser: 'auth/user', currentUser: 'auth/user',
}), }),
socialMediaLinks() { socialMediaLinks() {
const domainRegex = /^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g
const { socialMedia = [] } = this.currentUser const { socialMedia = [] } = this.currentUser
return socialMedia.map(socialMedia => { return socialMedia.map(({ id, url }) => {
const { id, url } = socialMedia const [domain] = url.match(domainRegex) || []
const matches = url.match(/^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
const [domain] = matches || []
const favicon = domain ? `${domain}/favicon.ico` : null const favicon = domain ? `${domain}/favicon.ico` : null
return { id, url, favicon } return { id, url, favicon }
}) })
@ -65,39 +105,28 @@ export default {
...mapMutations({ ...mapMutations({
setCurrentUser: 'auth/SET_USER', setCurrentUser: 'auth/SET_USER',
}), }),
handleAddSocialMedia() { handleCancel() {
this.$apollo this.editingLink = {}
.mutate({ this.formData.socialMediaUrl = ''
mutation: gql` this.disabled = true
mutation($url: String!) { },
CreateSocialMedia(url: $url) { handleEditSocialMedia(link) {
id this.editingLink = link
url this.formData.socialMediaUrl = link.url
},
handleInput(data) {
this.disabled = true
},
handleInputValid(data) {
if (data.socialMediaUrl.length < 1) {
this.disabled = true
} else {
this.disabled = false
} }
}
`,
variables: {
url: this.value,
}, },
update: (store, { data }) => { async handleDeleteSocialMedia(link) {
const socialMedia = [...this.currentUser.socialMedia, data.CreateSocialMedia] try {
this.setCurrentUser({ await this.$apollo.mutate({
...this.currentUser,
socialMedia,
})
},
})
.then(() => {
this.$toast.success(this.$t('settings.social-media.successAdd'))
this.value = ''
})
.catch(error => {
this.$toast.error(error.message)
})
},
handleDeleteSocialMedia(link) {
this.$apollo
.mutate({
mutation: gql` mutation: gql`
mutation($id: ID!) { mutation($id: ID!) {
DeleteSocialMedia(id: $id) { DeleteSocialMedia(id: $id) {
@ -119,19 +148,83 @@ export default {
}) })
}, },
}) })
.then(() => {
this.$toast.success(this.$t('settings.social-media.successDelete')) this.$toast.success(this.$t('settings.social-media.successDelete'))
} catch (err) {
this.$toast.error(err.message)
}
},
async handleSubmitSocialMedia() {
const isEditing = !!this.editingLink.id
const url = this.formData.socialMediaUrl
const duplicateUrl = this.socialMediaLinks.find(link => link.url === url)
if (duplicateUrl && duplicateUrl.id !== this.editingLink.id) {
return this.$toast.error(this.$t('settings.social-media.requireUnique'))
}
let mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
const variables = { url }
let successMessage = this.$t('settings.social-media.successAdd')
if (isEditing) {
mutation = gql`
mutation($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
variables.id = this.editingLink.id
successMessage = this.$t('settings.data.success')
}
try {
await this.$apollo.mutate({
mutation,
variables,
update: (store, { data }) => {
const newSocialMedia = isEditing ? data.UpdateSocialMedia : data.CreateSocialMedia
this.setCurrentUser({
...this.currentUser,
socialMedia: unionBy([newSocialMedia], this.currentUser.socialMedia, 'id'),
}) })
.catch(error => { },
this.$toast.error(error.message)
}) })
this.$toast.success(successMessage)
this.formData.socialMediaUrl = ''
this.disabled = true
this.editingLink = {}
} catch (err) {
this.$toast.error(err.message)
}
}, },
}, },
} }
</script> </script>
<style lang="scss"> <style lang="scss">
.layout-leave-active { .divider {
opacity: 0.4; opacity: 0.4;
padding: 0 $space-small;
}
.icon-button {
cursor: pointer;
}
.list-item--high {
.ds-list-item-prefix {
align-self: center;
}
} }
</style> </style>

View File

@ -4911,6 +4911,11 @@ flatten@^1.0.2:
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
integrity sha1-2uRqnXj74lKSJYzB54CkHZXAN4I= integrity sha1-2uRqnXj74lKSJYzB54CkHZXAN4I=
flush-promises@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flush-promises/-/flush-promises-1.0.2.tgz#4948fd58f15281fed79cbafc86293d5bb09b2ced"
integrity sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==
flush-write-stream@^1.0.0: flush-write-stream@^1.0.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
@ -11379,10 +11384,10 @@ vue@^2.6.10, vue@^2.6.6:
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637"
integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ== integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==
vuex-i18n@~1.11.0: vuex-i18n@~1.13.0:
version "1.11.0" version "1.13.0"
resolved "https://registry.yarnpkg.com/vuex-i18n/-/vuex-i18n-1.11.0.tgz#e6cdc95080c445ab2c211cc6b64a907d217639d3" resolved "https://registry.yarnpkg.com/vuex-i18n/-/vuex-i18n-1.13.0.tgz#161c22548e1d3038523f5af49e5730e17a0ca4f2"
integrity sha512-+Eme0C7FS3VFLIWpAwisohC3KcRDw+YcXFANssUZZq16P2C4z8V2VGbEtFHFw0DzkvZcdM2CAkUj6rdMl9wYmg== integrity sha512-r9ZS8NFr8OAIvtlSnqrQLFXxh07QHhkdeGem8RyWFBKej9/yxPAneBEQaLCUM0BOKPpAzBX8t2cjQmtukWbWLg==
vuex@^3.1.1: vuex@^3.1.1:
version "3.1.1" version "3.1.1"