mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge branch 'master' of github.com:Human-Connection/Human-Connection into 384-emotions-on-posts
This commit is contained in:
commit
08899a4af9
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
15
backend/src/models/SocialMedia.js
Normal file
15
backend/src/models/SocialMedia.js
Normal 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',
|
||||
},
|
||||
}
|
||||
@ -5,4 +5,5 @@ export default {
|
||||
User: require('./User.js'),
|
||||
InvitationCode: require('./InvitationCode.js'),
|
||||
EmailAddress: require('./EmailAddress.js'),
|
||||
SocialMedia: require('./SocialMedia.js'),
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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',
|
||||
|
||||
75
backend/src/schema/resolvers/helpers/Resolver.js
Normal file
75
backend/src/schema/resolvers/helpers/Resolver.js
Normal 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
|
||||
}
|
||||
@ -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)',
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@ -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 { 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`
|
||||
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 () => {
|
||||
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!) {
|
||||
CreateSocialMedia(url: $url) {
|
||||
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!) {
|
||||
DeleteSocialMedia(id: $id) {
|
||||
id
|
||||
@ -23,93 +227,48 @@ describe('SocialMedia', () => {
|
||||
}
|
||||
}
|
||||
`
|
||||
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',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await factory.cleanDatabase()
|
||||
variables = { url: newUrl, id: socialMedia.id }
|
||||
})
|
||||
|
||||
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')
|
||||
const user = null
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
expect(result.errors[0]).toHaveProperty('message', '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 () => {
|
||||
headers = await login({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
user = owner
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes social media', async () => {
|
||||
const creationVariables = {
|
||||
url: 'http://nsosp.org',
|
||||
}
|
||||
const { CreateSocialMedia } = await client.request(mutationC, creationVariables)
|
||||
const { id } = CreateSocialMedia
|
||||
|
||||
const deletionVariables = {
|
||||
id,
|
||||
}
|
||||
it('deletes social media with the given id', async () => {
|
||||
const expected = {
|
||||
data: {
|
||||
DeleteSocialMedia: {
|
||||
id: id,
|
||||
url: 'http://nsosp.org',
|
||||
id: variables.id,
|
||||
url,
|
||||
},
|
||||
},
|
||||
}
|
||||
await expect(client.request(mutationD, deletionVariables)).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('rejects empty string', async () => {
|
||||
const variables = {
|
||||
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',
|
||||
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
|
||||
expect.objectContaining(expected),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,7 +63,8 @@ export default {
|
||||
let [{ email }] = result.records.map(r => r.get('e').properties)
|
||||
return email
|
||||
},
|
||||
...undefinedToNull([
|
||||
...Resolver('User', {
|
||||
undefinedToNull: [
|
||||
'actorId',
|
||||
'avatar',
|
||||
'coverImg',
|
||||
@ -118,8 +72,8 @@ export default {
|
||||
'disabled',
|
||||
'locationName',
|
||||
'about',
|
||||
]),
|
||||
...count({
|
||||
],
|
||||
count: {
|
||||
contributionsCount: '-[:WROTE]->(related:Post)',
|
||||
friendsCount: '<-[:FRIENDS]->(related:User)',
|
||||
followingCount: '-[:FOLLOWS]->(related:User)',
|
||||
@ -128,17 +82,17 @@ export default {
|
||||
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
|
||||
shoutedCount: '-[:SHOUTED]->(related:Post)',
|
||||
badgesCount: '<-[:REWARDED]-(related:Badge)',
|
||||
}),
|
||||
...hasOne({
|
||||
},
|
||||
hasOne: {
|
||||
invitedBy: '<-[:INVITED]-(related:User)',
|
||||
disabledBy: '<-[:DISABLED]-(related:User)',
|
||||
}),
|
||||
...hasMany({
|
||||
},
|
||||
hasMany: {
|
||||
followedBy: '<-[:FOLLOWS]-(related:User)',
|
||||
following: '-[:FOLLOWS]->(related:User)',
|
||||
friends: '-[:FRIENDS]-(related:User)',
|
||||
blacklisted: '-[:BLACKLISTED]->(related:User)',
|
||||
socialMedia: '-[:OWNED]->(related:SocialMedia)',
|
||||
socialMedia: '-[:OWNED_BY]->(related:SocialMedia',
|
||||
contributions: '-[:WROTE]->(related:Post)',
|
||||
comments: '-[:WROTE]->(related:Comment)',
|
||||
shouted: '-[:SHOUTED]->(related:Post)',
|
||||
@ -146,6 +100,7 @@ export default {
|
||||
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
|
||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||
badges: '<-[:REWARDED]-(related:Badge)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -131,8 +131,3 @@ type SharedInboxEndpoint {
|
||||
uri: String
|
||||
}
|
||||
|
||||
type SocialMedia {
|
||||
id: ID!
|
||||
url: String
|
||||
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
|
||||
}
|
||||
|
||||
11
backend/src/schema/types/type/SocialMedia.gql
Normal file
11
backend/src/schema/types/type/SocialMedia.gql
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,9 +49,7 @@ describe('ContributionForm.vue', () => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
$apollo: {
|
||||
mutate: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
mutate: jest.fn().mockResolvedValueOnce({
|
||||
data: {
|
||||
CreatePost: {
|
||||
title: postTitle,
|
||||
@ -46,9 +59,6 @@ describe('ContributionForm.vue', () => {
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockRejectedValue({
|
||||
message: 'Not Authorised!',
|
||||
}),
|
||||
},
|
||||
$toast: {
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@ -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
140
webapp/pages/index.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
describe('adding social media link', () => {
|
||||
beforeEach(() => {
|
||||
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(() => {
|
||||
getters = {
|
||||
'auth/user': () => {
|
||||
return {
|
||||
'auth/user': () => ({
|
||||
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', () => {
|
||||
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)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentUser does not have a social media account linked', () => {
|
||||
it('allows a user to add a social media link', () => {
|
||||
wrapper = Wrapper()
|
||||
input = wrapper.find({ name: 'social-media' })
|
||||
input.element.value = socialMediaUrl
|
||||
input.trigger('input')
|
||||
submitBtn = wrapper.find('.ds-button')
|
||||
submitBtn.trigger('click')
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,49 +1,90 @@
|
||||
<template>
|
||||
<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">
|
||||
<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 | proxyApiUrl" alt="Social Media link" width="16" height="16" />
|
||||
<img :src="link.favicon" alt="Link:" height="16" width="16" />
|
||||
{{ link.url }}
|
||||
</a>
|
||||
|
||||
<span class="layout-leave-active">|</span>
|
||||
|
||||
<ds-icon name="edit" class="layout-leave-active" />
|
||||
<a name="delete" @click="handleDeleteSocialMedia(link)">
|
||||
<ds-icon name="trash" />
|
||||
<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 margin-top="base">
|
||||
<div>
|
||||
<ds-input
|
||||
v-model="value"
|
||||
v-if="!editingLink.id"
|
||||
id="addSocialMedia"
|
||||
model="socialMediaUrl"
|
||||
type="text"
|
||||
: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 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>
|
||||
</div>
|
||||
</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
|
||||
handleCancel() {
|
||||
this.editingLink = {}
|
||||
this.formData.socialMediaUrl = ''
|
||||
this.disabled = true
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
`,
|
||||
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)
|
||||
})
|
||||
},
|
||||
handleDeleteSocialMedia(link) {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
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 (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>
|
||||
|
||||
<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>
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user