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-link-context": "~1.0.18",
"apollo-link-http": "~1.5.15",
"apollo-server": "~2.7.2",
"apollo-server": "~2.8.0",
"apollo-server-express": "^2.7.2",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
@ -62,7 +62,7 @@
"graphql": "~14.4.2",
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.2",
"graphql-middleware": "~3.0.3",
"graphql-shield": "~6.0.4",
"graphql-tag": "~2.10.1",
"helmet": "~3.20.0",
@ -106,7 +106,7 @@
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~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-eslint": "~10.0.2",
"babel-jest": "~24.8.0",

View File

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

View File

@ -1,23 +1,8 @@
import { UserInputError } from 'apollo-server'
import Joi from '@hapi/joi'
const COMMENT_MIN_LENGTH = 1
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 content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args
@ -57,7 +42,6 @@ const validateUpdateComment = async (resolve, root, args, context, info) => {
export default {
Mutation: {
CreateSocialMedia: validate(socialMediaSchema),
CreateComment: validateCommentCreation,
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'),
InvitationCode: require('./InvitationCode.js'),
EmailAddress: require('./EmailAddress.js'),
SocialMedia: require('./SocialMedia.js'),
}

View File

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

View File

@ -1,5 +1,5 @@
import scrape from './embeds/scraper.js'
import { undefinedToNull } from '../helpers'
import { undefinedToNullResolver } from './helpers/Resolver'
export default {
Query: {
@ -8,7 +8,7 @@ export default {
},
},
Embed: {
...undefinedToNull([
...undefinedToNullResolver([
'type',
'title',
'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 {
Mutation: {
CreateSocialMedia: async (object, params, context, resolveInfo) => {
/**
* TODO?: Creates double Nodes!
*/
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
const session = context.driver.session()
await session.run(
`MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId})
MERGE (socialMedia)<-[:OWNED]-(owner)
RETURN owner`,
{
userId: context.user.id,
socialMediaId: socialMedia.id,
},
)
session.close()
const [user, socialMedia] = await Promise.all([
instance.find('User', context.user.id),
instance.create('SocialMedia', params),
])
await socialMedia.relateTo(user, 'ownedBy')
const response = await socialMedia.toJson()
return socialMedia
return response
},
DeleteSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
UpdateSocialMedia: async (object, params, context, resolveInfo) => {
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,115 +1,274 @@
import { GraphQLClient } from 'graphql-request'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
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 instance = neode()
describe('SocialMedia', () => {
let client
let headers
const mutationC = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
const mutationD = gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`
let socialMediaAction, someUser, ownerNode, owner
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 () => {
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',
})
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('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
const variables = {
url: 'http://nsosp.org',
}
await expect(client.request(mutationC, variables)).rejects.toThrow('Not Authorised')
describe('create social media', () => {
let mutation, variables
beforeEach(() => {
mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
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('authenticated', () => {
describe('update social media', () => {
let mutation, variables
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
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!')
})
})
it('creates social media with correct URL', 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',
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!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`
variables = { url: newUrl, id: socialMedia.id }
})
it('deletes social media', async () => {
const creationVariables = {
url: 'http://nsosp.org',
}
const { CreateSocialMedia } = await client.request(mutationC, creationVariables)
const { id } = CreateSocialMedia
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, mutation, variables)
const deletionVariables = {
id,
}
const expected = {
DeleteSocialMedia: {
id: id,
url: 'http://nsosp.org',
},
}
await expect(client.request(mutationD, deletionVariables)).resolves.toEqual(expected)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
it('rejects empty string', async () => {
const variables = {
url: '',
}
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" is not allowed to be empty',
)
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!')
})
})
it('validates URLs', async () => {
const variables = {
url: 'not-a-url',
}
describe('authenticated as owner', () => {
let user
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" must be a valid uri',
)
beforeEach(async () => {
user = owner
})
it('deletes social media with the given id', async () => {
const expected = {
data: {
DeleteSocialMedia: {
id: variables.id,
url,
},
},
}
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining(expected),
)
})
})
})
})

View File

@ -2,57 +2,10 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload'
import { neode } from '../../bootstrap/neo4j'
import { UserInputError } from 'apollo-server'
import { undefinedToNull } from '../helpers'
import Resolver from './helpers/Resolver'
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 {
Query: {
User: async (object, args, context, resolveInfo) => {
@ -110,42 +63,44 @@ export default {
let [{ email }] = result.records.map(r => r.get('e').properties)
return email
},
...undefinedToNull([
'actorId',
'avatar',
'coverImg',
'deleted',
'disabled',
'locationName',
'about',
]),
...count({
contributionsCount: '-[:WROTE]->(related:Post)',
friendsCount: '<-[:FRIENDS]->(related:User)',
followingCount: '-[:FOLLOWS]->(related:User)',
followedByCount: '<-[:FOLLOWS]-(related:User)',
commentsCount: '-[:WROTE]->(r:Comment)',
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
shoutedCount: '-[:SHOUTED]->(related:Post)',
badgesCount: '<-[:REWARDED]-(related:Badge)',
}),
...hasOne({
invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
}),
...hasMany({
followedBy: '<-[:FOLLOWS]-(related:User)',
following: '-[:FOLLOWS]->(related:User)',
friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED]->(related:SocialMedia)',
contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
...Resolver('User', {
undefinedToNull: [
'actorId',
'avatar',
'coverImg',
'deleted',
'disabled',
'locationName',
'about',
],
count: {
contributionsCount: '-[:WROTE]->(related:Post)',
friendsCount: '<-[:FRIENDS]->(related:User)',
followingCount: '-[:FOLLOWS]->(related:User)',
followedByCount: '<-[:FOLLOWS]-(related:User)',
commentsCount: '-[:WROTE]->(r:Comment)',
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
shoutedCount: '-[:SHOUTED]->(related:Post)',
badgesCount: '<-[:REWARDED]-(related:Badge)',
},
hasOne: {
invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
},
hasMany: {
followedBy: '<-[:FOLLOWS]-(related:User)',
following: '-[:FOLLOWS]->(related:User)',
friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED_BY]->(related:SocialMedia',
contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
},
}),
},
}

View File

@ -131,8 +131,3 @@ type SharedInboxEndpoint {
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")
locationName: String
about: String
socialMedia: [SocialMedia]! @relation(name: "OWNED", direction: "OUT")
socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "OUT")
#createdAt: DateTime
#updatedAt: DateTime

View File

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

View File

@ -1,73 +1,70 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
When('I search for {string}', value => {
cy.get('#nav-search')
import { When, Then } from "cypress-cucumber-preprocessor/steps";
When("I search for {string}", value => {
cy.get("#nav-search")
.focus()
.type(value)
})
.type(value);
});
Then('I should have one post in the select dropdown', () => {
cy.get('.ds-select-dropdown').should($li => {
expect($li).to.have.length(1)
})
})
Then("I should have one post in the select dropdown", () => {
cy.get(".input .ds-select-dropdown").should($li => {
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 }) => {
cy.get('.ds-select-dropdown').should('contain', title)
})
})
cy.get(".ds-select-dropdown").should("contain", title);
});
});
When('I type {string} and press Enter', value => {
cy.get('#nav-search')
When("I type {string} and press Enter", value => {
cy.get("#nav-search")
.focus()
.type(value)
.type('{enter}', { force: true })
})
.type("{enter}", { force: true });
});
When('I type {string} and press escape', value => {
cy.get('#nav-search')
When("I type {string} and press escape", value => {
cy.get("#nav-search")
.focus()
.type(value)
.type('{esc}')
})
.type("{esc}");
});
Then('the search field should clear', () => {
cy.get('#nav-search').should('have.text', '')
})
Then("the search field should clear", () => {
cy.get("#nav-search").should("have.text", "");
});
When('I select an entry', () => {
cy.get('.ds-select-dropdown ul li')
When("I select an entry", () => {
cy.get(".input .ds-select-dropdown ul li")
.first()
.trigger('click')
})
.trigger("click");
});
Then("I should be on the post's page", () => {
cy.location('pathname').should(
'contain',
'/post/'
)
cy.location('pathname').should(
'eq',
'/post/p1/101-essays-that-will-change-the-way-you-think'
)
})
cy.location("pathname").should("contain", "/post/");
cy.location("pathname").should(
"eq",
"/post/p1/101-essays-that-will-change-the-way-you-think"
);
});
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(
'contain',
'101 Essays that will change the way you think'
)
cy.get(".ds-select-dropdown").should(
"contain",
"101 Essays that will change the way you think"
);
}
)
);
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(
'not.contain',
'No searched for content'
)
cy.get(".ds-select-dropdown").should(
"not.contain",
"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', () => {
cy.get("input[name='social-media']")
cy.get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
.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', () => {
cy.openPage('/settings/my-social-media')
.get("input[name='social-media']")
.get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Add link')
@ -121,3 +121,34 @@ Then('it gets deleted successfully', () => {
cy.get('.iziToast-message')
.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
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
When people visit my profile page
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
When I delete a social media link
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)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('ContributionForm.vue', () => {
let wrapper
@ -24,9 +26,22 @@ describe('ContributionForm.vue', () => {
let mocks
let propsData
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 postContentTooShort = 'xx'
let postContentTooLong = ''
for (let i = 0; i < 2001; i++) {
postContentTooLong += 'x'
}
const imageUpload = {
file: { filename: 'avataar.svg', previewElement: '' },
file: {
filename: 'avataar.svg',
previewElement: '',
},
url: 'someUrlToImage',
}
const image = '/uploads/1562010976466-avataaars'
@ -34,22 +49,17 @@ describe('ContributionForm.vue', () => {
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest
.fn()
.mockResolvedValueOnce({
data: {
CreatePost: {
title: postTitle,
slug: 'this-is-a-title-for-a-post',
content: postContent,
contentExcerpt: postContent,
language: 'en',
},
mutate: jest.fn().mockResolvedValueOnce({
data: {
CreatePost: {
title: postTitle,
slug: 'this-is-a-title-for-a-post',
content: postContent,
contentExcerpt: postContent,
language: 'en',
},
})
.mockRejectedValue({
message: 'Not Authorised!',
}),
},
}),
},
$toast: {
error: jest.fn(),
@ -71,6 +81,13 @@ describe('ContributionForm.vue', () => {
'editor/placeholder': () => {
return 'some cool placeholder'
},
'auth/user': () => {
return {
id: '4711',
name: 'You yourself',
slug: 'you-yourself',
}
},
}
const store = new Vuex.Store({
getters,
@ -106,16 +123,53 @@ describe('ContributionForm.vue', () => {
})
describe('invalid form submission', () => {
it('title required for form submission', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.find('form').trigger('submit')
it('title and content should not be empty ', async () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content required for form submission', async () => {
wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit')
it('title should not be empty', async () => {
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 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()
})
})
@ -136,15 +190,16 @@ describe('ContributionForm.vue', () => {
}
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit')
await wrapper.vm.updateEditorContent(postContent)
})
it('with title and content', () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
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))
})
@ -152,7 +207,7 @@ describe('ContributionForm.vue', () => {
expectedParams.variables.language = 'de'
deutschOption = wrapper.findAll('li').at(0)
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))
})
@ -160,22 +215,26 @@ describe('ContributionForm.vue', () => {
const categoryIds = ['cat12', 'cat15', 'cat37']
expectedParams.variables.categoryIds = 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))
})
it('supports adding a teaser image', async () => {
expectedParams.variables.imageUpload = 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))
})
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)
})
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)
})
})
@ -191,18 +250,19 @@ describe('ContributionForm.vue', () => {
describe('handles errors', () => {
beforeEach(async () => {
jest.useFakeTimers()
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({
message: 'Not Authorised!',
})
wrapper = Wrapper()
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent)
// second submission causes mutation to reject
await wrapper.find('form').trigger('submit')
await wrapper.vm.updateEditorContent(postContent)
})
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
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',
language: 'de',
image,
categories: [{ id: 'cat12', name: 'Democracy & Politics' }],
categories: [
{
id: 'cat12',
name: 'Democracy & Politics',
},
],
},
}
wrapper = Wrapper()
@ -255,7 +320,7 @@ describe('ContributionForm.vue', () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
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))
})
@ -266,7 +331,7 @@ describe('ContributionForm.vue', () => {
wrapper.vm.updateEditorContent(postContent)
expectedParams.variables.categoryIds = 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))
})
})

View File

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

View File

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

View File

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

View File

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

View File

@ -76,7 +76,7 @@
"vue-count-to": "~1.0.13",
"vue-izitoast": "1.1.2",
"vue-sweetalert-icons": "~3.2.0",
"vuex-i18n": "~1.11.0",
"vuex-i18n": "~1.13.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
@ -101,6 +101,7 @@
"eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.0",
"eslint-plugin-vue": "~5.2.3",
"flush-promises": "^1.0.2",
"fuse.js": "^3.4.5",
"jest": "~24.8.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"
/>
</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
v-for="(post, index) in posts"
:key="post.id"
@ -53,6 +64,36 @@ export default {
pageSize: 12,
filter: {},
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() {
@ -89,7 +130,12 @@ export default {
}
}
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() {
this.$router.push({ path: '/' })
@ -144,6 +190,7 @@ export default {
filter: this.filter,
first: this.pageSize,
offset: 0,
orderBy: this.sorting,
}
},
fetchPolicy: 'cache-and-network',
@ -161,4 +208,11 @@ export default {
transform: translate(-120%, -120%);
box-shadow: $box-shadow-x-large;
}
.sorting-dropdown {
width: 250px;
position: relative;
float: right;
padding: 0 18px;
}
</style>

View File

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

View File

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

View File

@ -4911,6 +4911,11 @@ flatten@^1.0.2:
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
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:
version "1.1.1"
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"
integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==
vuex-i18n@~1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/vuex-i18n/-/vuex-i18n-1.11.0.tgz#e6cdc95080c445ab2c211cc6b64a907d217639d3"
integrity sha512-+Eme0C7FS3VFLIWpAwisohC3KcRDw+YcXFANssUZZq16P2C4z8V2VGbEtFHFw0DzkvZcdM2CAkUj6rdMl9wYmg==
vuex-i18n@~1.13.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/vuex-i18n/-/vuex-i18n-1.13.0.tgz#161c22548e1d3038523f5af49e5730e17a0ca4f2"
integrity sha512-r9ZS8NFr8OAIvtlSnqrQLFXxh07QHhkdeGem8RyWFBKej9/yxPAneBEQaLCUM0BOKPpAzBX8t2cjQmtukWbWLg==
vuex@^3.1.1:
version "3.1.1"