Merge branch 'master' of github.com:Human-Connection/Human-Connection into dependabot/npm_and_yarn/webapp/tiptap-1.21.0

This commit is contained in:
Matt Rider 2019-06-14 17:22:46 -03:00
commit 4cc810205a
84 changed files with 2107 additions and 1119 deletions

View File

@ -21,12 +21,15 @@ install:
- wait-on http://localhost:7474
script:
- export CYPRESS_RETRIES=1
- export BRANCH=$(if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then echo $TRAVIS_BRANCH; else echo $TRAVIS_PULL_REQUEST_BRANCH; fi)
- echo "TRAVIS_BRANCH=$TRAVIS_BRANCH, PR=$PR, BRANCH=$BRANCH"
# Backend
- docker-compose exec backend yarn run lint
- docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage
- docker-compose exec backend yarn run db:reset
- docker-compose exec backend yarn run db:seed
- docker-compose exec backend yarn run test:cucumber
- docker-compose exec backend yarn run test:cucumber --tags "not @wip"
- docker-compose exec backend yarn run db:reset
- docker-compose exec backend yarn run db:seed
# Frontend
@ -34,7 +37,7 @@ script:
- docker-compose exec webapp yarn run test --ci --verbose=false --coverage
- docker-compose exec -d backend yarn run test:before:seeder
# Fullstack
- CYPRESS_RETRIES=1 yarn run cypress:run
- yarn run cypress:run
# Coverage
- codecov

View File

@ -24,4 +24,5 @@ RUN yarn run build
FROM base as production
ENV NODE_ENV=production
COPY --from=builder /nitro-backend/dist ./dist
COPY ./public/img/ ./public/img/
RUN yarn install --frozen-lockfile --non-interactive

View File

@ -43,16 +43,16 @@
},
"dependencies": {
"activitystrea.ms": "~2.1.3",
"apollo-cache-inmemory": "~1.6.1",
"apollo-client": "~2.6.1",
"apollo-cache-inmemory": "~1.6.2",
"apollo-client": "~2.6.2",
"apollo-link-context": "~1.0.14",
"apollo-link-http": "~1.5.14",
"apollo-server": "~2.6.1",
"apollo-server": "~2.6.3",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~5.2.0",
"date-fns": "2.0.0-alpha.29",
"date-fns": "2.0.0-alpha.33",
"debug": "~4.1.1",
"dotenv": "~8.0.0",
"express": "~4.17.1",
@ -61,7 +61,7 @@
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.2",
"graphql-shield": "~5.3.6",
"graphql-shield": "~5.3.8",
"graphql-tag": "~2.10.1",
"graphql-yoga": "~1.17.4",
"helmet": "~3.18.0",
@ -69,7 +69,6 @@
"linkifyjs": "~2.1.8",
"lodash": "~4.17.11",
"merge-graphql-schemas": "^1.5.8",
"ms": "~2.1.1",
"neo4j-driver": "~1.7.4",
"neo4j-graphql-js": "git+https://github.com/Human-Connection/neo4j-graphql-js.git#temporary_fixes",
"node-fetch": "~2.6.0",
@ -88,7 +87,7 @@
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.4.5",
"@babel/register": "~7.4.4",
"apollo-server-testing": "~2.6.1",
"apollo-server-testing": "~2.6.3",
"babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.1",
"babel-jest": "~24.8.0",
@ -106,7 +105,7 @@
"graphql-request": "~1.8.2",
"jest": "~24.8.0",
"nodemon": "~1.19.1",
"prettier": "~1.17.1",
"prettier": "~1.18.2",
"supertest": "~4.0.2"
}
}

View File

@ -505,9 +505,7 @@ export default class NitroDataSource {
const result2 = await this.client.mutate({
mutation: gql`
mutation {
AddCommentAuthor(from: {id: "${
result.data.CreateComment.id
}"}, to: {id: "${toUserId}"}) {
AddCommentAuthor(from: {id: "${result.data.CreateComment.id}"}, to: {id: "${toUserId}"}) {
id
}
}
@ -519,9 +517,7 @@ export default class NitroDataSource {
result = await this.client.mutate({
mutation: gql`
mutation {
AddCommentPost(from: { id: "${
result.data.CreateComment.id
}", to: { id: "${postId}" }}) {
AddCommentPost(from: { id: "${result.data.CreateComment.id}", to: { id: "${postId}" }}) {
id
}
}

View File

@ -1,11 +1,10 @@
import jwt from 'jsonwebtoken'
import ms from 'ms'
import CONFIG from './../config'
// Generate an Access Token for the given User ID
export default function encode(user) {
const token = jwt.sign(user, CONFIG.JWT_SECRET, {
expiresIn: ms('1d'),
expiresIn: 24 * 60 * 60 * 1000, // one day
issuer: CONFIG.GRAPHQL_URI,
audience: CONFIG.CLIENT_URI,
subject: user.id.toString(),

View File

@ -1,12 +0,0 @@
import replaceParams from './replaceParams'
const replaceFilterBubbleParams = async (resolve, root, args, context, resolveInfo) => {
args = await replaceParams(args, context)
return resolve(root, args, context, resolveInfo)
}
export default {
Query: {
Post: replaceFilterBubbleParams,
},
}

View File

@ -5,6 +5,7 @@ import Factory from '../../seed/factories'
const factory = Factory()
const currentUserParams = {
id: 'u1',
email: 'you@example.org',
name: 'This is you',
password: '1234',
@ -41,7 +42,7 @@ afterEach(async () => {
await factory.cleanDatabase()
})
describe('FilterBubble middleware', () => {
describe('Filter posts by author is followed by sb.', () => {
describe('given an authenticated user', () => {
let authenticatedClient
@ -52,7 +53,7 @@ describe('FilterBubble middleware', () => {
describe('no filter bubble', () => {
it('returns all posts', async () => {
const query = '{ Post( filterBubble: {}) { title } }'
const query = '{ Post(filter: { }) { title } }'
const expected = {
Post: [
{ title: 'This is some random post' },
@ -65,7 +66,7 @@ describe('FilterBubble middleware', () => {
describe('filtering for posts of followed users only', () => {
it('returns only posts authored by followed users', async () => {
const query = '{ Post( filterBubble: { author: following }) { title } }'
const query = '{ Post( filter: { author: { followedBy_some: { id: "u1" } } }) { title } }'
const expected = {
Post: [{ title: 'This is the post of a followed user' }],
}

View File

@ -1,31 +0,0 @@
import { UserInputError } from 'apollo-server'
export default async function replaceParams(args, context) {
const { author = 'all' } = args.filterBubble || {}
const { user } = context
if (author === 'following') {
if (!user)
throw new UserInputError(
"You are unauthenticated - I don't know any users you are following.",
)
const session = context.driver.session()
let { records } = await session.run(
'MATCH(followed:User)<-[:FOLLOWS]-(u {id: $userId}) RETURN followed.id',
{ userId: context.user.id },
)
const followedIds = records.map(record => record.get('followed.id'))
// carefully override `id_in`
args.filter = args.filter || {}
args.filter.author = args.filter.author || {}
args.filter.author.id_in = followedIds
session.close()
}
delete args.filterBubble
return args
}

View File

@ -1,129 +0,0 @@
import replaceParams from './replaceParams.js'
describe('replaceParams', () => {
let args
let context
let run
let action = () => {
return replaceParams(args, context)
}
beforeEach(() => {
args = {}
run = jest.fn().mockResolvedValue({
records: [{ get: () => 1 }, { get: () => 2 }, { get: () => 3 }],
})
context = {
driver: {
session: () => {
return {
run,
close: () => {},
}
},
},
}
})
describe('args == ', () => {
describe('{}', () => {
it('does not crash', async () => {
await expect(action()).resolves.toEqual({})
})
})
describe('unauthenticated user', () => {
beforeEach(() => {
context.user = null
})
describe('{ filterBubble: { author: following } }', () => {
it('throws error', async () => {
args = { filterBubble: { author: 'following' } }
await expect(action()).rejects.toThrow('You are unauthenticated')
})
})
describe('{ filterBubble: { author: all } }', () => {
it('removes filterBubble param', async () => {
const expected = {}
await expect(action()).resolves.toEqual(expected)
})
it('does not make database calls', async () => {
await action()
expect(run).not.toHaveBeenCalled()
})
})
})
describe('authenticated user', () => {
beforeEach(() => {
context.user = { id: 'u4711' }
})
describe('{ filterBubble: { author: following } }', () => {
beforeEach(() => {
args = { filterBubble: { author: 'following' } }
})
it('returns args object with resolved ids of followed users', async () => {
const expected = { filter: { author: { id_in: [1, 2, 3] } } }
await expect(action()).resolves.toEqual(expected)
})
it('makes database calls', async () => {
await action()
expect(run).toHaveBeenCalledTimes(1)
})
describe('given any additional filter args', () => {
describe('merges', () => {
it('empty filter object', async () => {
args.filter = {}
const expected = { filter: { author: { id_in: [1, 2, 3] } } }
await expect(action()).resolves.toEqual(expected)
})
it('filter.title', async () => {
args.filter = { title: 'bla' }
const expected = { filter: { title: 'bla', author: { id_in: [1, 2, 3] } } }
await expect(action()).resolves.toEqual(expected)
})
it('filter.author', async () => {
args.filter = { author: { name: 'bla' } }
const expected = { filter: { author: { name: 'bla', id_in: [1, 2, 3] } } }
await expect(action()).resolves.toEqual(expected)
})
})
})
})
describe('{ filterBubble: { } }', () => {
it('removes filterBubble param', async () => {
const expected = {}
await expect(action()).resolves.toEqual(expected)
})
it('does not make database calls', async () => {
await action()
expect(run).not.toHaveBeenCalled()
})
})
describe('{ filterBubble: { author: all } }', () => {
it('removes filterBubble param', async () => {
const expected = {}
await expect(action()).resolves.toEqual(expected)
})
it('does not make database calls', async () => {
await action()
expect(run).not.toHaveBeenCalled()
})
})
})
})
})

View File

@ -6,8 +6,11 @@ const legacyUrls = [
export const fixUrl = url => {
legacyUrls.forEach(legacyUrl => {
url = url.replace(legacyUrl, '/api')
url = url.replace(legacyUrl, '')
})
if (!url.startsWith('/')) {
url = `/${url}`
}
return url
}

View File

@ -1,12 +1,19 @@
import { fixImageURLs } from './fixImageUrlsMiddleware'
describe('fixImageURLs', () => {
describe('edge case: image url is exact match of legacy url', () => {
it('replaces it with `/`', () => {
const url = 'https://api-alpha.human-connection.org'
expect(fixImageURLs(url)).toEqual('/')
})
})
describe('image url of legacy alpha', () => {
it('removes domain', () => {
const url =
'https://api-alpha.human-connection.org/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png'
expect(fixImageURLs(url)).toEqual(
'/api/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png',
'/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png',
)
})
})
@ -16,7 +23,7 @@ describe('fixImageURLs', () => {
const url =
'https://staging-api.human-connection.org/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg'
expect(fixImageURLs(url)).toEqual(
'/api/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg',
'/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg',
)
})
})

View File

@ -13,7 +13,6 @@ import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware'
import validation from './validation'
import notifications from './notifications'
import filterBubble from './filterBubble/filterBubble'
export default schema => {
const middlewares = {
@ -31,13 +30,11 @@ export default schema => {
user: user,
includedFields: includedFields,
orderBy: orderBy,
filterBubble: filterBubble,
}
let order = [
'permissions',
'activityPub',
'filterBubble',
'password',
'dateTime',
'validation',

View File

@ -87,9 +87,7 @@ describe('currentUser { notifications }', () => {
describe('who mentions me again', () => {
beforeEach(async () => {
const updatedContent = `${
post.content
} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
const updatedContent = `${post.content} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
// The response `post.content` contains a link but the XSSmiddleware
// should have the `mention` CSS class removed. I discovered this
// during development and thought: A feature not a bug! This way we

View File

@ -1,4 +1,4 @@
import { rule, shield, allow, or } from 'graphql-shield'
import { rule, shield, deny, allow, or } from 'graphql-shield'
/*
* TODO: implement
@ -16,6 +16,12 @@ const isAdmin = rule()(async (parent, args, { user }, info) => {
return user && user.role === 'admin'
})
const onlyYourself = rule({
cache: 'no_cache',
})(async (parent, args, context, info) => {
return context.user.id === args.id
})
const isMyOwn = rule({
cache: 'no_cache',
})(async (parent, args, context, info) => {
@ -48,6 +54,13 @@ const belongsToMe = rule({
return Boolean(notification)
})
/* TODO: decide if we want to remove this check: the check
* `onlyEnabledContent` throws authorization errors only if you have
* arguments for `disabled` or `deleted` assuming these are filter
* parameters. Soft-delete middleware obfuscates data on its way out
* anyways. Furthermore, `neo4j-graphql-js` offers many ways to filter for
* data so I believe, this is not a good check anyways.
*/
const onlyEnabledContent = rule({
cache: 'strict',
})(async (parent, args, ctx, info) => {
@ -80,16 +93,35 @@ const isAuthor = rule({
return authorId === user.id
})
const isDeletingOwnAccount = rule({
cache: 'no_cache',
})(async (parent, args, context, info) => {
return context.user.id === args.id
})
// Permissions
const permissions = shield({
const permissions = shield(
{
Query: {
'*': deny,
findPosts: allow,
Category: isAdmin,
Tag: isAdmin,
Report: isModerator,
Notification: isAdmin,
statistics: allow,
currentUser: allow,
Post: or(onlyEnabledContent, isModerator),
Comment: allow,
User: allow,
isLoggedIn: allow,
},
Mutation: {
'*': deny,
login: allow,
UpdateNotification: belongsToMe,
CreateUser: isAdmin,
UpdateUser: onlyYourself,
CreatePost: isAuthenticated,
UpdatePost: isAuthor,
DeletePost: isAuthor,
@ -114,13 +146,17 @@ const permissions = shield({
disable: isModerator,
CreateComment: isAuthenticated,
DeleteComment: isAuthor,
// CreateUser: allow,
DeleteUser: isDeletingOwnAccount,
},
User: {
email: isMyOwn,
password: isMyOwn,
privateKey: isMyOwn,
},
})
},
{
fallbackRule: allow,
},
)
export default permissions

View File

@ -7,12 +7,14 @@ let headers
const factory = Factory()
beforeEach(async () => {
await factory.create('User', { email: 'user@example.org', password: '1234' })
const adminParams = { role: 'admin', email: 'admin@example.org', password: '1234' }
await factory.create('User', adminParams)
await factory.create('User', {
email: 'someone@example.org',
password: '1234',
})
headers = await login({ email: 'user@example.org', password: '1234' })
// we need to be an admin, otherwise we're not authorized to create a user
headers = await login(adminParams)
authenticatedClient = new GraphQLClient(host, { headers })
})

View File

@ -74,6 +74,22 @@ describe('CreatePost', () => {
await expect(client.request(mutation)).resolves.toMatchObject(expected)
})
})
describe('language', () => {
it('allows a user to set the language of the post', async () => {
const createPostWithLanguageMutation = `
mutation {
CreatePost(title: "I am a title", content: "Some content", language: "en") {
language
}
}
`
const expected = { CreatePost: { language: 'en' } }
await expect(client.request(createPostWithLanguageMutation)).resolves.toEqual(
expect.objectContaining(expected),
)
})
})
})
})

View File

@ -315,6 +315,8 @@ describe('change password', () => {
describe('do not expose private RSA key', () => {
let headers
let client
let authenticatedClient
const queryUserPuplicKey = gql`
query($queriedUserSlug: String) {
User(slug: $queriedUserSlug) {
@ -332,7 +334,7 @@ describe('do not expose private RSA key', () => {
}
`
const actionGenUserWithKeys = async () => {
const generateUserWithKeys = async authenticatedClient => {
// Generate user with "privateKey" via 'CreateUser' mutation instead of using the factories "factory.create('User', {...})", see above.
const variables = {
id: 'bcb2d923-f3af-479e-9f00-61b12e864667',
@ -341,7 +343,7 @@ describe('do not expose private RSA key', () => {
name: 'Apfel Strudel',
email: 'apfel-strudel@test.org',
}
await client.request(
await authenticatedClient.request(
gql`
mutation($id: ID, $password: String!, $slug: String, $name: String, $email: String!) {
CreateUser(id: $id, password: $password, slug: $slug, name: $name, email: $email) {
@ -353,14 +355,23 @@ describe('do not expose private RSA key', () => {
)
}
// not authenticate
beforeEach(async () => {
const adminParams = {
role: 'admin',
email: 'admin@example.org',
password: '1234',
}
// create an admin user who has enough permissions to create other users
await factory.create('User', adminParams)
const headers = await login(adminParams)
authenticatedClient = new GraphQLClient(host, { headers })
// but also create an unauthenticated client to issue the `User` query
client = new GraphQLClient(host)
})
describe('unauthenticated query of "publicKey" (does the RSA key pair get generated at all?)', () => {
it('returns publicKey', async () => {
await actionGenUserWithKeys()
await generateUserWithKeys(authenticatedClient)
await expect(
await client.request(queryUserPuplicKey, { queriedUserSlug: 'apfel-strudel' }),
).toEqual(
@ -378,7 +389,7 @@ describe('do not expose private RSA key', () => {
describe('unauthenticated query of "privateKey"', () => {
it('throws "Not Authorised!"', async () => {
await actionGenUserWithKeys()
await generateUserWithKeys(authenticatedClient)
await expect(
client.request(queryUserPrivateKey, { queriedUserSlug: 'apfel-strudel' }),
).rejects.toThrow('Not Authorised')
@ -393,7 +404,7 @@ describe('do not expose private RSA key', () => {
describe('authenticated query of "publicKey"', () => {
it('returns publicKey', async () => {
await actionGenUserWithKeys()
await generateUserWithKeys(authenticatedClient)
await expect(
await client.request(queryUserPuplicKey, { queriedUserSlug: 'apfel-strudel' }),
).toEqual(
@ -411,7 +422,7 @@ describe('do not expose private RSA key', () => {
describe('authenticated query of "privateKey"', () => {
it('throws "Not Authorised!"', async () => {
await actionGenUserWithKeys()
await generateUserWithKeys(authenticatedClient)
await expect(
client.request(queryUserPrivateKey, { queriedUserSlug: 'apfel-strudel' }),
).rejects.toThrow('Not Authorised')

View File

@ -11,5 +11,27 @@ export default {
params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' })
return neo4jgraphql(object, params, context, resolveInfo, false)
},
DeleteUser: async (object, params, context, resolveInfo) => {
const { resource } = params
const session = context.driver.session()
if (resource && resource.length) {
await Promise.all(
resource.map(async node => {
await session.run(
`
MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId})
SET resource.deleted = true
RETURN author`,
{
userId: context.user.id,
},
)
}),
)
session.close()
}
return neo4jgraphql(object, params, context, resolveInfo, false)
},
},
}

View File

@ -1,6 +1,7 @@
import { GraphQLClient } from 'graphql-request'
import { host } from '../../jest/helpers'
import { login, host } from '../../jest/helpers'
import Factory from '../../seed/factories'
import gql from 'graphql-tag'
const factory = Factory()
let client
@ -18,14 +19,36 @@ describe('users', () => {
}
}
`
client = new GraphQLClient(host)
it('with password and email', async () => {
describe('given valid password and email', () => {
const variables = {
name: 'John Doe',
password: '123',
email: '123@123.de',
}
describe('unauthenticated', () => {
beforeEach(async () => {
client = new GraphQLClient(host)
})
it('is not allowed to create users', async () => {
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
})
})
describe('authenticated admin', () => {
beforeEach(async () => {
const adminParams = {
role: 'admin',
email: 'admin@example.org',
password: '1234',
}
await factory.create('User', adminParams)
const headers = await login(adminParams)
client = new GraphQLClient(host, { headers })
})
it('is allowed to create new users', async () => {
const expected = {
CreateUser: {
id: expect.any(String),
@ -34,11 +57,20 @@ describe('users', () => {
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
})
})
})
})
describe('UpdateUser', () => {
beforeEach(async () => {
await factory.create('User', { id: 'u47', name: 'John Doe' })
})
const userParams = {
email: 'user@example.org',
password: '1234',
id: 'u47',
name: 'John Doe',
}
const variables = {
id: 'u47',
name: 'John Doughnut',
}
const mutation = `
mutation($id: ID!, $name: String) {
@ -48,17 +80,40 @@ describe('users', () => {
}
}
`
client = new GraphQLClient(host)
it('name within specifications', async () => {
const variables = {
id: 'u47',
beforeEach(async () => {
await factory.create('User', userParams)
})
describe('as another user', () => {
beforeEach(async () => {
const someoneElseParams = {
email: 'someoneElse@example.org',
password: '1234',
name: 'James Doe',
}
await factory.create('User', someoneElseParams)
const headers = await login(someoneElseParams)
client = new GraphQLClient(host, { headers })
})
it('is not allowed to change other user accounts', async () => {
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
})
})
describe('as the same user', () => {
beforeEach(async () => {
const headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
it('name within specifications', async () => {
const expected = {
UpdateUser: {
id: 'u47',
name: 'James Doe',
name: 'John Doughnut',
},
}
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
@ -83,3 +138,141 @@ describe('users', () => {
})
})
})
describe('DeleteUser', () => {
let deleteUserVariables
let asAuthor
const deleteUserMutation = gql`
mutation($id: ID!, $resource: [String]) {
DeleteUser(id: $id, resource: $resource) {
id
contributions {
id
deleted
}
comments {
id
deleted
}
}
}
`
beforeEach(async () => {
asAuthor = await factory.create('User', {
email: 'test@example.org',
password: '1234',
id: 'u343',
})
await factory.create('User', {
email: 'friendsAccount@example.org',
password: '1234',
id: 'u565',
})
deleteUserVariables = { id: 'u343', resource: [] }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, { headers })
})
describe("attempting to delete another user's account", () => {
it('throws an authorization error', async () => {
deleteUserVariables = { id: 'u565' }
await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('attempting to delete my own account', () => {
let expectedResponse
beforeEach(async () => {
await asAuthor.authenticateAs({
email: 'test@example.org',
password: '1234',
})
await asAuthor.create('Post', {
id: 'p139',
content: 'Post by user u343',
})
await asAuthor.create('Comment', {
id: 'c155',
postId: 'p139',
content: 'Comment by user u343',
})
expectedResponse = {
DeleteUser: {
id: 'u343',
contributions: [{ id: 'p139', deleted: false }],
comments: [{ id: 'c155', deleted: false }],
},
}
})
it("deletes my account, but doesn't delete posts or comments by default", async () => {
await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual(
expectedResponse,
)
})
describe("deletes a user's", () => {
it('posts on request', async () => {
deleteUserVariables = { id: 'u343', resource: ['Post'] }
expectedResponse = {
DeleteUser: {
id: 'u343',
contributions: [{ id: 'p139', deleted: true }],
comments: [{ id: 'c155', deleted: false }],
},
}
await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual(
expectedResponse,
)
})
it('comments on request', async () => {
deleteUserVariables = { id: 'u343', resource: ['Comment'] }
expectedResponse = {
DeleteUser: {
id: 'u343',
contributions: [{ id: 'p139', deleted: false }],
comments: [{ id: 'c155', deleted: true }],
},
}
await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual(
expectedResponse,
)
})
it('posts and comments on request', async () => {
deleteUserVariables = { id: 'u343', resource: ['Post', 'Comment'] }
expectedResponse = {
DeleteUser: {
id: 'u343',
contributions: [{ id: 'p139', deleted: true }],
comments: [{ id: 'c155', deleted: true }],
},
}
await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual(
expectedResponse,
)
})
})
})
})
})
})

View File

@ -4,7 +4,8 @@ type Query {
currentUser: User
# Get the latest Network Statistics
statistics: Statistics!
findPosts(filter: String!, limit: Int = 10): [Post]! @cypher(
findPosts(filter: String!, limit: Int = 10): [Post]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('full_text_search', $filter)
YIELD node as post, score
@ -37,6 +38,7 @@ type Mutation {
follow(id: ID!, type: FollowTypeEnum): Boolean!
# Unfollow the given Type and ID
unfollow(id: ID!, type: FollowTypeEnum): Boolean!
DeleteUser(id: ID!, resource: [String]): User
}
type Statistics {
@ -53,7 +55,7 @@ type Statistics {
type Notification {
id: ID!
read: Boolean,
read: Boolean
user: User @relation(name: "NOTIFIED", direction: "OUT")
post: Post @relation(name: "NOTIFIED", direction: "IN")
createdAt: String
@ -80,7 +82,8 @@ type Report {
id: ID!
submitter: User @relation(name: "REPORTED", direction: "IN")
description: String
type: String! @cypher(statement: "MATCH (resource)<-[:REPORTED]-(this) RETURN labels(resource)[0]")
type: String!
@cypher(statement: "MATCH (resource)<-[:REPORTED]-(this) RETURN labels(resource)[0]")
createdAt: String
comment: Comment @relation(name: "REPORTED", direction: "OUT")
post: Post @relation(name: "REPORTED", direction: "OUT")
@ -131,4 +134,3 @@ type SocialMedia {
url: String
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
}

View File

@ -1,40 +1,3 @@
enum FilterBubbleAuthorEnum {
following
all
}
input FilterBubble {
author: FilterBubbleAuthorEnum
}
type Query {
Post(
id: ID
activityId: String
objectId: String
title: String
slug: String
content: String
contentExcerpt: String
image: String
imageUpload: Upload
visibility: Visibility
deleted: Boolean
disabled: Boolean
createdAt: String
updatedAt: String
commentsCount: Int
shoutedCount: Int
shoutedByCurrentUser: Boolean
_id: String
first: Int
offset: Int
orderBy: [_PostOrdering]
filter: _PostFilter
filterBubble: FilterBubble
): [Post]
}
type Post {
id: ID!
activityId: String
@ -52,8 +15,9 @@ type Post {
disabledBy: User @relation(name: "DISABLED", direction: "IN")
createdAt: String
updatedAt: String
relatedContributions: [Post]! @cypher(
language: String
relatedContributions: [Post]!
@cypher(
statement: """
MATCH (this)-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
RETURN DISTINCT post
@ -65,13 +29,20 @@ type Post {
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
comments: [Comment]! @relation(name: "COMMENTS", direction: "IN")
commentsCount: Int! @cypher(statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)")
commentsCount: Int!
@cypher(
statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)"
)
shoutedBy: [User]! @relation(name: "SHOUTED", direction: "IN")
shoutedCount: Int! @cypher(statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")
shoutedCount: Int!
@cypher(
statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)"
)
# Has the currently logged in user shouted that post?
shoutedByCurrentUser: Boolean! @cypher(
shoutedByCurrentUser: Boolean!
@cypher(
statement: """
MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId})
RETURN COUNT(u) >= 1

View File

@ -56,14 +56,14 @@ type User {
contributionsCount: Int! @cypher(
statement: """
MATCH (this)-[:WROTE]->(r:Post)
WHERE (NOT exists(r.deleted) OR r.deleted = false)
AND (NOT exists(r.disabled) OR r.disabled = false)
WHERE NOT r.deleted = true AND NOT r.disabled = true
RETURN COUNT(r)
"""
)
comments: [Comment]! @relation(name: "WROTE", direction: "OUT")
commentsCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)")
commentedCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(r:Comment)-[:COMMENTS]->(p:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true AND NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))")
shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT")
shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")

View File

@ -10,6 +10,7 @@ Feature: Follow a user
| stuart-little |
| tero-vota |
@wip
Scenario: Send a follow to a user inbox and make sure it's added to the right followers collection
When I send a POST request with the following activity to "/activitypub/users/tero-vota/inbox":
"""

View File

@ -27,6 +27,7 @@ Feature: Like an object like an article or note
}
"""
@wip
Scenario: Send a like of a person to an users inbox and make sure it's added to the likes collection
When I send a POST request with the following activity to "/activitypub/users/karl-heinz/inbox":
"""

View File

@ -1029,10 +1029,10 @@
"@types/express-serve-static-core" "*"
"@types/serve-static" "*"
"@types/express@4.16.1":
version "4.16.1"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.16.1.tgz#d756bd1a85c34d87eaf44c888bad27ba8a4b7cf0"
integrity sha512-V0clmJow23WeyblmACoxbHBu2JKlE5TiIme6Lem14FnPW9gsttyHtk6wq7njcdIWH1njAaFgR8gW09lgY98gQg==
"@types/express@4.17.0":
version "4.17.0"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.0.tgz#49eaedb209582a86f12ed9b725160f12d04ef287"
integrity sha512-CjaMu57cjgjuZbh9DpkloeGxV45CnMGlVd+XpG7Gm9QgVrd7KFq+X4HY0vM+2v0bczS48Wg7bvnMY5TN+Xmcfw==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "*"
@ -1119,10 +1119,10 @@
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.9.tgz#693e76a52f61a2f1e7fb48c0eef167b95ea4ffd0"
integrity sha512-sCZy4SxP9rN2w30Hlmg5dtdRwgYQfYRiLo9usw8X9cxlf+H4FqM1xX7+sNH7NNKVdbXMJWqva7iyy+fxh/V7fA==
"@types/yup@0.26.14":
version "0.26.14"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.14.tgz#d31f3b9a04039cca70ebb4db4d6c7fc3f694e80b"
integrity sha512-OcBtVLHvYULVSltpuBdhFiVOKoSsOS58D872HydO93oBf3OdGq5zb+LnqGo18TNNSV2aW8hjIdS6H+wp68zFtQ==
"@types/yup@0.26.16":
version "0.26.16"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.16.tgz#75c428236207c48d9f8062dd1495cda8c5485a15"
integrity sha512-E2RNc7DSeQ+2EIJ1H3+yFjYu6YiyQBUJ7yNpIxomrYJ3oFizLZ5yDS3T1JTUNBC2OCRkgnhLS0smob5UuCHfNA==
"@types/zen-observable@^0.5.3":
version "0.5.4"
@ -1141,6 +1141,13 @@
dependencies:
tslib "^1.9.3"
"@wry/equality@^0.1.2":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.7.tgz#512234d078341c32cabda66b89b5dddb5741d9b9"
integrity sha512-p1rhJ6PQzpsBr9cMJMHvvx3LQEA28HFX7fAQx6khAX+1lufFeBuk+iRCAyHwj3v6JbpGKvHNa66f+9cpU8c7ew==
dependencies:
tslib "^1.9.3"
abab@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f"
@ -1281,13 +1288,13 @@ anymatch@^2.0.0:
micromatch "^3.1.4"
normalize-path "^2.1.1"
apollo-cache-control@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.7.1.tgz#3d4fba232f561f096f61051e103bf58ee4bf8b54"
integrity sha512-3h1TEoMnzex6IIiFb5Ja3owTyLwT5YzK69cRgrSpSscdpYc3ID4KVs0Ht9cbOUmb/L/UKtYVkRC8KeVAYmHEjQ==
apollo-cache-control@0.7.2:
version "0.7.2"
resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.7.2.tgz#b8852422d973c582493e85c776abc9c660090162"
integrity sha512-7prjFN8H9lRE0npqGG8kM3XICvNCcgQt6eCy8kkcPOIZwM+F8m8ShjEfNF9UWW32i+poOk3G67HegPRyjCc6/Q==
dependencies:
apollo-server-env "2.4.0"
graphql-extensions "0.7.1"
graphql-extensions "0.7.2"
apollo-cache-control@^0.1.0:
version "0.1.1"
@ -1296,34 +1303,34 @@ apollo-cache-control@^0.1.0:
dependencies:
graphql-extensions "^0.0.x"
apollo-cache-inmemory@~1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.1.tgz#536b6f366461f6264250041f9146363e2faa1d4c"
integrity sha512-c/WJjh9MTWcdussCTjLKufpPjTx3qOFkBPHIDOOpQ+U0B7K1PczPl9N0LaC4ir3wAWL7s4A0t2EKtoR+6UP92g==
apollo-cache-inmemory@~1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.2.tgz#bbf2e4e1eacdf82b2d526f5c2f3b37e5acee3c5e"
integrity sha512-AyCl3PGFv5Qv1w4N9vlg63GBPHXgMCekZy5mhlS042ji0GW84uTySX+r3F61ZX3+KM1vA4m9hQyctrEGiv5XjQ==
dependencies:
apollo-cache "^1.3.1"
apollo-utilities "^1.3.1"
apollo-cache "^1.3.2"
apollo-utilities "^1.3.2"
optimism "^0.9.0"
ts-invariant "^0.4.0"
tslib "^1.9.3"
apollo-cache@1.3.1, apollo-cache@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.1.tgz#c015f93a9a7f32b3eeea0c471addd6e854da754c"
integrity sha512-BJ/Mehr3u6XCaHYSmgZ6DM71Fh30OkW6aEr828WjHvs+7i0RUuP51/PM7K6T0jPXtuw7UbArFFPZZsNgXnyyJA==
apollo-cache@1.3.2, apollo-cache@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.2.tgz#df4dce56240d6c95c613510d7e409f7214e6d26a"
integrity sha512-+KA685AV5ETEJfjZuviRTEImGA11uNBp/MJGnaCvkgr+BYRrGLruVKBv6WvyFod27WEB2sp7SsG8cNBKANhGLg==
dependencies:
apollo-utilities "^1.3.1"
apollo-utilities "^1.3.2"
tslib "^1.9.3"
apollo-client@~2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.1.tgz#fcf328618d6ad82b750a988bec113fe6edc8ba94"
integrity sha512-Tb6ZthPZUHlGqeoH1WC8Qg/tLnkk9H5+xj4e5nzOAC6dCOW3pVU9tYXscrWdmZ65UDUg1khvTNjrQgPhdf4aTQ==
apollo-client@~2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.2.tgz#03b6af651e09b6e413e486ddc87464c85bd6e514"
integrity sha512-oks1MaT5x7gHcPeC8vPC1UzzsKaEIC0tye+jg72eMDt5OKc7BobStTeS/o2Ib3e0ii40nKxGBnMdl/Xa/p56Yg==
dependencies:
"@types/zen-observable" "^0.8.0"
apollo-cache "1.3.1"
apollo-cache "1.3.2"
apollo-link "^1.0.0"
apollo-utilities "1.3.1"
apollo-utilities "1.3.2"
symbol-observable "^1.0.2"
ts-invariant "^0.4.0"
tslib "^1.9.3"
@ -1337,24 +1344,24 @@ apollo-datasource@0.5.0:
apollo-server-caching "0.4.0"
apollo-server-env "2.4.0"
apollo-engine-reporting-protobuf@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.3.0.tgz#2c764c054ff9968387cf16115546e0d5b04ee9f1"
integrity sha512-PYowpx/E+TJT/8nKpp3JmJuKh3x1SZcxDF6Cquj0soV205TUpFFCZQMi91i5ACiEp2AkYvM/GDBIrw+rfIwzTg==
apollo-engine-reporting-protobuf@0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.3.1.tgz#a581257fa8e3bb115ce38bf1b22e052d1475ad69"
integrity sha512-Ui3nPG6BSZF8BEqxFs6EkX6mj2OnFLMejxEHSOdM82bakyeouCGd7J0fiy8AD6liJoIyc4X7XfH4ZGGMvMh11A==
dependencies:
protobufjs "^6.8.6"
apollo-engine-reporting@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.2.1.tgz#0b77fad2e9221d62f4a29b8b4fab8f7f47dcc1d6"
integrity sha512-DVXZhz/nSZR4lphakjb1guAD0qJ7Wm1PVtZEBjN097cnOor4XSOzQlPfTaYtVuhlxUKUuCx1syiBbOuV8sKqXg==
apollo-engine-reporting@1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.3.1.tgz#f2c2c63f865871a57c15cdbb2a3bcd4b4af28115"
integrity sha512-e0Xp+0yite8DH/xm9fnJt42CxfWAcY6waiq3icCMAgO9T7saXzVOPpl84SkuA+hIJUBtfaKrTnC+7Jxi/I7OrQ==
dependencies:
apollo-engine-reporting-protobuf "0.3.0"
apollo-engine-reporting-protobuf "0.3.1"
apollo-graphql "^0.3.0"
apollo-server-core "2.6.1"
apollo-server-core "2.6.3"
apollo-server-env "2.4.0"
async-retry "^1.2.1"
graphql-extensions "0.7.1"
graphql-extensions "0.7.2"
apollo-env@0.5.1:
version "0.5.1"
@ -1424,24 +1431,24 @@ apollo-server-caching@0.4.0:
dependencies:
lru-cache "^5.0.0"
apollo-server-core@2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.6.1.tgz#d0d878b0a4959b6c661fc43300ce45b29996176a"
integrity sha512-jO2BtcP7ozSSK5qtw1gGDwO66WSNtzhvpDJD7erkA9byv8Z0jB2QIPNWN6iaj311LaPahM05k+8hMIhFy9oHWg==
apollo-server-core@2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.6.3.tgz#786c8251c82cf29acb5cae9635a321f0644332ae"
integrity sha512-tfC0QO1NbJW3ShkB5pRCnUaYEkW2AwnswaTeedkfv//EO3yiC/9LeouCK5F22T8stQG+vGjvCqf0C8ldI/XsIA==
dependencies:
"@apollographql/apollo-tools" "^0.3.6"
"@apollographql/graphql-playground-html" "1.6.20"
"@types/ws" "^6.0.0"
apollo-cache-control "0.7.1"
apollo-cache-control "0.7.2"
apollo-datasource "0.5.0"
apollo-engine-reporting "1.2.1"
apollo-engine-reporting "1.3.1"
apollo-server-caching "0.4.0"
apollo-server-env "2.4.0"
apollo-server-errors "2.3.0"
apollo-server-plugin-base "0.5.1"
apollo-tracing "0.7.1"
apollo-server-plugin-base "0.5.2"
apollo-tracing "0.7.2"
fast-json-stable-stringify "^2.0.0"
graphql-extensions "0.7.1"
graphql-extensions "0.7.2"
graphql-subscriptions "^1.0.0"
graphql-tag "^2.9.2"
graphql-tools "^4.0.0"
@ -1472,18 +1479,18 @@ apollo-server-errors@2.3.0:
resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.0.tgz#700622b66a16dffcad3b017e4796749814edc061"
integrity sha512-rUvzwMo2ZQgzzPh2kcJyfbRSfVKRMhfIlhY7BzUfM4x6ZT0aijlgsf714Ll3Mbf5Fxii32kD0A/DmKsTecpccw==
apollo-server-express@2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.6.1.tgz#1e2649d3fd38c0c0a2c830090fd41e086b259c9f"
integrity sha512-TVu68LVp+COMGOXuxc0OFeCUQiPApxy7Isv2Vk85nikZV4t4FXlODB6PrRKf5rfvP31dvGsfE6GHPJTLLbKfyg==
apollo-server-express@2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.6.3.tgz#62034c978f84207615c0430fb37ab006f71146fe"
integrity sha512-8ca+VpKArgNzFar0D3DesWnn0g9YDtFLhO56TQprHh2Spxu9WxTnYNjsYs2MCCNf+iV/uy7vTvEknErvnIcZaQ==
dependencies:
"@apollographql/graphql-playground-html" "1.6.20"
"@types/accepts" "^1.3.5"
"@types/body-parser" "1.17.0"
"@types/cors" "^2.8.4"
"@types/express" "4.16.1"
"@types/express" "4.17.0"
accepts "^1.3.5"
apollo-server-core "2.6.1"
apollo-server-core "2.6.3"
body-parser "^1.18.3"
cors "^2.8.4"
graphql-subscriptions "^1.0.0"
@ -1511,36 +1518,36 @@ apollo-server-module-graphiql@^1.3.4, apollo-server-module-graphiql@^1.4.0:
resolved "https://registry.yarnpkg.com/apollo-server-module-graphiql/-/apollo-server-module-graphiql-1.4.0.tgz#c559efa285578820709f1769bb85d3b3eed3d8ec"
integrity sha512-GmkOcb5he2x5gat+TuiTvabnBf1m4jzdecal3XbXBh/Jg+kx4hcvO3TTDFQ9CuTprtzdcVyA11iqG7iOMOt7vA==
apollo-server-plugin-base@0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.5.1.tgz#b81056666763879bdc98d8d58f3c4c43cbb30da6"
integrity sha512-UejnBk6XDqYQ+Ydkbm+gvlOzP+doQA8glVUULs8rCi0/MshsFSsBVl6rtzruELDBVgZhJgGsd4pUexcvNc3aZA==
apollo-server-plugin-base@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.5.2.tgz#f97ba983f1e825fec49cba8ff6a23d00e1901819"
integrity sha512-j81CpadRLhxikBYHMh91X4aTxfzFnmmebEiIR9rruS6dywWCxV2aLW87l9ocD1MiueNam0ysdwZkX4F3D4csNw==
apollo-server-testing@~2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.6.1.tgz#447f34980819fa52b120f26c632fab4efc55435b"
integrity sha512-Qq0u79uKw3g14bq0nGxtUUiueFOv2ETkAax2mum+3f9Lm85VXELkY6c9bCWDVGjkUtt9Aog5qwSzWELb1KiUug==
apollo-server-testing@~2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.6.3.tgz#a0199a5d42000e60ecf0dea44b851f5f581e280e"
integrity sha512-LTkegcGVSkM+pA0FINDSYVl3TiFYKZyfjlKrEr/LN6wLiL6gbRgy6LMtk2j+qli/bnTDqqQREX8OEqmV8FKUoQ==
dependencies:
apollo-server-core "2.6.1"
apollo-server-core "2.6.3"
apollo-server@~2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.6.1.tgz#1b1fc6020b75c0913550da5fa0f2005c62f1bc53"
integrity sha512-Ed0zZjluRYPMC3Yr6oXQjcR11izu86nkjiS2MhjJA1mF8IXJfxbPp2hnX4Jf4vXPSkOP2e5ZHw0cdaIcu9GnRw==
apollo-server@~2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.6.3.tgz#71235325449c6d3881a5143975ca44c07a07d2d7"
integrity sha512-pTIXE5xEMAikKLTIBIqLNvimMETiZbzmiqDb6BGzIUicAz4Rxa1/+bDi1ZeJWrZQjE/TfBLd2Si3qam7dZGrjw==
dependencies:
apollo-server-core "2.6.1"
apollo-server-express "2.6.1"
apollo-server-core "2.6.3"
apollo-server-express "2.6.3"
express "^4.0.0"
graphql-subscriptions "^1.0.0"
graphql-tools "^4.0.0"
apollo-tracing@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.7.1.tgz#6a7356b619f3aa0ca22c623b5d8bb1af5ca1c74c"
integrity sha512-1BYQua+jCWFkZZJP0/rSpzY4XbLLbCrRHCYu8sJn0RCH/hs34BMdFXscS9uSglgIpXwUAIafgsU0hAVCrJjbTw==
apollo-tracing@0.7.2:
version "0.7.2"
resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.7.2.tgz#7730159a4670bca465ac1bfa01f9902610a7aba4"
integrity sha512-bT4/n8Vy9DweC3+XWJelJD41FBlKMXR0OVxjLMiCe9clb4yTgKhYxRGTyh9KjmhWsng9gG/DphO0ixWsOgdXmA==
dependencies:
apollo-server-env "2.4.0"
graphql-extensions "0.7.1"
graphql-extensions "0.7.2"
apollo-tracing@^0.1.0:
version "0.1.4"
@ -1559,13 +1566,13 @@ apollo-upload-server@^7.0.0:
http-errors "^1.7.0"
object-path "^0.11.4"
apollo-utilities@1.3.1, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1, apollo-utilities@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.1.tgz#4c45f9b52783c324e2beef822700bdea374f82d1"
integrity sha512-P5cJ75rvhm9hcx9V/xCW0vlHhRd0S2icEcYPoRYNTc5djbynpuO+mQuJ4zMHgjNDpvvDxDfZxXTJ6ZUuJZodiQ==
apollo-utilities@1.3.2, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1, apollo-utilities@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9"
integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg==
dependencies:
"@wry/equality" "^0.1.2"
fast-json-stable-stringify "^2.0.0"
lodash.isequal "^4.5.0"
ts-invariant "^0.4.0"
tslib "^1.9.3"
@ -2579,10 +2586,10 @@ data-urls@^1.0.0:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
date-fns@2.0.0-alpha.29:
version "2.0.0-alpha.29"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.29.tgz#9d4a36e3ebba63d009e957fea8fdfef7921bc6cb"
integrity sha512-AIFZ0hG/1fdb7HZHTDyiEJdNiaFyZxXcx/kF8z3I9wxbhkN678KrrLSneKcsb0Xy5KqCA4wCIxmGpdVWSNZnpA==
date-fns@2.0.0-alpha.33:
version "2.0.0-alpha.33"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.33.tgz#c2f73c3cc50ac301c9217eb93603c9bc40e891bf"
integrity sha512-tqUVEk3oxnJuNIvwAMKHAMo4uFRG0zXvjxZQll+BonoPt+m4NMcUgO14NDxbHuy7uYcrVErd2GdSsw02EDZQ7w==
debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
version "2.6.9"
@ -3704,10 +3711,10 @@ graphql-deduplicator@^2.0.1:
resolved "https://registry.yarnpkg.com/graphql-deduplicator/-/graphql-deduplicator-2.0.2.tgz#d8608161cf6be97725e178df0c41f6a1f9f778f3"
integrity sha512-0CGmTmQh4UvJfsaTPppJAcHwHln8Ayat7yXXxdnuWT+Mb1dBzkbErabCWzjXyKh/RefqlGTTA7EQOZHofMaKJA==
graphql-extensions@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.7.1.tgz#f55b01ac8ddf09a215e21f34caeee3ae66a88f21"
integrity sha512-4NkAz/f0j5a1DSPl3V77OcesBmwhHz56Soj0yTImlcDdRv9knyO2e+ehi1TIeKBOyIKS7d3A7zqOW/4ieGxlVA==
graphql-extensions@0.7.2:
version "0.7.2"
resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.7.2.tgz#8711543f835661eaf24b48d6ac2aad44dbbd5506"
integrity sha512-TuVINuAOrEtzQAkAlCZMi9aP5rcZ+pVaqoBI5fD2k5O9fmb8OuXUQOW028MUhC66tg4E7h4YSF1uYUIimbu4SQ==
dependencies:
"@apollographql/apollo-tools" "^0.3.6"
@ -3765,12 +3772,12 @@ graphql-request@~1.8.2:
dependencies:
cross-fetch "2.2.2"
graphql-shield@~5.3.6:
version "5.3.6"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.3.6.tgz#20061b02f77056c0870a623c530ef28a1bf4fff4"
integrity sha512-ihw/i4X+d1kpj1SVA6iBkVl2DZhPsI+xV08geR2TX3FWhpU7zakk/16yBzDRJTTCUgKsWfgyebrgIBsuhTwMnA==
graphql-shield@~5.3.8:
version "5.3.8"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.3.8.tgz#f9e7ad2285f6cfbe20a8a49154ce6c1b184e3893"
integrity sha512-33rQ8U5jMurHIapctHk7hBcUg3nxC7fmMIMtyWiomJXhBmztFq/SG7jNaapnL5M7Q/0BmoaSQd3FLSpelP9KPw==
dependencies:
"@types/yup" "0.26.14"
"@types/yup" "0.26.16"
lightercollective "^0.3.0"
object-hash "^1.3.1"
yup "^0.27.0"
@ -5230,11 +5237,6 @@ lodash.isboolean@^3.0.3:
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
@ -5520,7 +5522,7 @@ ms@2.0.0:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
ms@2.1.1, ms@^2.1.1, ms@~2.1.1:
ms@2.1.1, ms@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
@ -6228,10 +6230,10 @@ prettier-linter-helpers@^1.0.0:
dependencies:
fast-diff "^1.1.2"
prettier@~1.17.1:
version "1.17.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.17.1.tgz#ed64b4e93e370cb8a25b9ef7fef3e4fd1c0995db"
integrity sha512-TzGRNvuUSmPgwivDqkZ9tM/qTGW9hqDKWOE9YHiyQdixlKbv7kvEqsmDPrcHJTKwthU774TQwZXVtaQ/mMsvjg==
prettier@~1.18.2:
version "1.18.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.18.2.tgz#6823e7c5900017b4bd3acf46fe9ac4b4d7bda9ea"
integrity sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==
pretty-format@^24.8.0:
version "24.8.0"

View File

@ -0,0 +1,6 @@
# SSH Access
# SSH_USERNAME='username'
# SSH_HOST='example.org'
# UPLOADS_DIRECTORY=/var/www/api/uploads
OUTPUT_DIRECTORY='/uploads/'

View File

@ -1,6 +1,11 @@
#!/usr/bin/env bash
set -e
# import .env config
set -o allexport
source $(dirname "$0")/.env
set +o allexport
for var in "SSH_USERNAME" "SSH_HOST" "UPLOADS_DIRECTORY"
do
if [[ -z "${!var}" ]]; then
@ -9,4 +14,4 @@ do
fi
done
rsync --archive --update --verbose ${SSH_USERNAME}@${SSH_HOST}:${UPLOADS_DIRECTORY}/ /uploads/
rsync --archive --update --verbose ${SSH_USERNAME}@${SSH_HOST}:${UPLOADS_DIRECTORY}/ ${OUTPUT_DIRECTORY}

View File

@ -22,8 +22,8 @@
"codecov": "^3.5.0",
"cross-env": "^5.2.0",
"cypress": "^3.3.1",
"cypress-cucumber-preprocessor": "^1.11.2",
"cypress-file-upload": "^3.1.2",
"cypress-cucumber-preprocessor": "^1.12.0",
"cypress-file-upload": "^3.1.4",
"cypress-plugin-retries": "^1.2.2",
"dotenv": "^8.0.0",
"faker": "^4.1.0",

View File

@ -10,7 +10,7 @@ $easeOut: cubic-bezier(0.19, 1, 0.22, 1);
&::before {
@include border-radius($border-radius-x-large);
box-shadow: inset 0 0 0 5px $color-danger;
content: "";
content: '';
display: block;
position: absolute;
width: 100%;
@ -102,10 +102,10 @@ hr {
height: 1px !important;
}
[class$=menu-trigger] {
[class$='menu-trigger'] {
user-select: none;
}
[class$=menu-popover] {
[class$='menu-popover'] {
display: inline-block;
nav {
@ -145,10 +145,11 @@ hr {
}
}
[class$="menu-popover"] {
[class$='menu-popover'] {
min-width: 130px;
a, button {
a,
button {
display: flex;
align-content: center;
align-items: center;

View File

@ -1,9 +1,11 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import Avatar from './Avatar.vue'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Filters)
describe('Avatar.vue', () => {
let propsData = {}

View File

@ -1,5 +1,10 @@
<template>
<ds-avatar :image="avatarUrl" :name="userName" class="avatar" :size="size" />
<ds-avatar
:image="user && user.avatar | proxyApiUrl"
:name="userName"
class="avatar"
:size="size"
/>
</template>
<script>
@ -10,11 +15,6 @@ export default {
size: { type: String, default: 'small' },
},
computed: {
avatarUrl() {
const { avatar: imageSrc } = this.user || {}
if (!imageSrc) return imageSrc
return imageSrc.startsWith('/') ? imageSrc.replace('/', '/api/') : imageSrc
},
userName() {
const { name } = this.user || {}
// The name is used to display the initials in case

View File

@ -1,17 +1,13 @@
<template>
<div :class="[badges.length === 2 && 'hc-badges-dual']" class="hc-badges">
<div v-for="badge in badges" :key="badge.key" class="hc-badge-container">
<hc-image :title="badge.key" :image-props="{ src: badge.icon }" class="hc-badge" />
<img :title="badge.key" :src="badge.icon | proxyApiUrl" class="hc-badge" />
</div>
</div>
</template>
<script>
import HcImage from './Image'
export default {
components: {
HcImage,
},
props: {
badges: {
type: Array,

View File

@ -25,6 +25,9 @@ describe('Comment.vue', () => {
success: jest.fn(),
error: jest.fn(),
},
$filters: {
truncate: a => a,
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
},

View File

@ -9,12 +9,13 @@
<ds-space margin-bottom="x-small">
<hc-user :user="author" :date-time="comment.createdAt" />
</ds-space>
<!-- Content Menu (can open Modals) -->
<no-ssr>
<content-menu
placement="bottom-end"
resource-type="comment"
:resource="comment"
:callbacks="{ confirm: deleteCommentCallback, cancel: null }"
:modalsData="menuModalsData"
style="float-right"
:is-owner="isAuthor(author.id)"
/>
@ -59,6 +60,30 @@ export default {
if (this.deleted) return {}
return this.comment.author || {}
},
menuModalsData() {
return {
delete: {
titleIdent: 'delete.comment.title',
messageIdent: 'delete.comment.message',
messageParams: {
name: this.$filters.truncate(this.comment.contentExcerpt, 30),
},
buttons: {
confirm: {
danger: true,
icon: 'trash',
textIdent: 'delete.submit',
callback: this.deleteCommentCallback,
},
cancel: {
icon: 'close',
textIdent: 'delete.cancel',
callback: () => {},
},
},
},
}
},
},
methods: {
isAuthor(id) {

View File

@ -43,7 +43,13 @@ export default {
return value.match(/(contribution|comment|organization|user)/)
},
},
callbacks: { type: Object, required: true },
modalsData: {
type: Object,
required: false,
// default: () => {
// return {}
// },
},
},
computed: {
routes() {
@ -145,7 +151,7 @@ export default {
data: {
type: this.resourceType,
resource: this.resource,
callbacks: this.callbacks,
modalsData: this.modalsData,
},
})
},

View File

@ -0,0 +1,142 @@
import { config, mount, createLocalVue } from '@vue/test-utils'
import ContributionForm from './index.vue'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Styleguide)
config.stubs['no-ssr'] = '<span><slot /></span>'
describe('ContributionForm.vue', () => {
let wrapper
let postTitleInput
let expectedParams
let deutschOption
let cancelBtn
let mocks
const postTitle = 'this is a title for a post'
const postContent = 'this is a post'
const computed = { locale: () => 'English' }
beforeEach(() => {
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',
},
},
})
.mockRejectedValue({ message: 'Not Authorised!' }),
},
$toast: {
error: jest.fn(),
success: jest.fn(),
},
$i18n: {
locale: () => 'en',
},
$router: {
back: jest.fn(),
push: jest.fn(),
},
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(ContributionForm, { mocks, localVue, computed })
}
beforeEach(() => {
wrapper = Wrapper()
wrapper.setData({ form: { languageOptions: [{ label: 'Deutsch', value: 'de' }] } })
})
describe('CreatePost', () => {
describe('invalid form submission', () => {
it('title required for form submission', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue('this is a title for a post')
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content required for form submission', async () => {
wrapper.vm.updateEditorContent('this is a post')
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
})
describe('valid form submission', () => {
expectedParams = {
variables: { title: postTitle, content: postContent, language: 'en', id: null },
}
beforeEach(async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue('this is a title for a post')
wrapper.vm.updateEditorContent('this is a post')
await wrapper.find('form').trigger('submit')
})
it('with title and content', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
it("sends a fallback language based on a user's locale", () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it('supports changing the language', async () => {
expectedParams.variables.language = 'de'
deutschOption = wrapper.findAll('li').at(0)
deutschOption.trigger('click')
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it("pushes the user to the post's page", async () => {
expect(mocks.$router.push).toHaveBeenCalledTimes(1)
})
it('shows a success toaster', () => {
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
})
describe('cancel', () => {
it('calls $router.back() when cancel button clicked', () => {
cancelBtn = wrapper.find('.cancel-button')
cancelBtn.trigger('click')
expect(mocks.$router.back).toHaveBeenCalledTimes(1)
})
})
describe('handles errors', () => {
beforeEach(async () => {
wrapper = Wrapper()
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue('this is a title for a post')
wrapper.vm.updateEditorContent('this is a post')
// second submission causes mutation to reject
await wrapper.find('form').trigger('submit')
})
it('shows an error toaster when apollo mutation rejects', async () => {
await wrapper.find('form').trigger('submit')
await mocks.$apollo.mutate
expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
})
})
})
})
})

View File

@ -6,8 +6,27 @@
<no-ssr>
<hc-editor :users="users" :value="form.content" @input="updateEditorContent" />
</no-ssr>
<ds-space margin-bottom="xxx-large" />
<ds-flex class="contribution-form-footer">
<ds-flex-item :width="{ base: '10%', sm: '10%', md: '10%', lg: '15%' }" />
<ds-flex-item :width="{ base: '80%', sm: '30%', md: '30%', lg: '20%' }">
<ds-space margin-bottom="small" />
<ds-select
model="language"
:options="form.languageOptions"
icon="globe"
:placeholder="form.placeholder"
:label="$t('contribution.languageSelectLabel')"
/>
</ds-flex-item>
</ds-flex>
<div slot="footer" style="text-align: right">
<ds-button :disabled="loading || disabled" ghost @click.prevent="$router.back()">
<ds-button
:disabled="loading || disabled"
ghost
class="cancel-button"
@click="$router.back()"
>
{{ $t('actions.cancel') }}
</ds-button>
<ds-button
@ -28,6 +47,9 @@
<script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor'
import orderBy from 'lodash/orderBy'
import locales from '~/locales'
import PostMutations from '~/graphql/PostMutations.js'
export default {
components: {
@ -41,6 +63,9 @@ export default {
form: {
title: '',
content: '',
language: null,
languageOptions: [],
placeholder: '',
},
formSchema: {
title: { required: true, min: 3, max: 64 },
@ -64,26 +89,38 @@ export default {
this.slug = contribution.slug
this.form.content = contribution.content
this.form.title = contribution.title
this.form.language = this.locale
this.form.placeholder = this.locale
},
},
},
computed: {
locale() {
const locale = this.contribution.language
? locales.find(loc => this.contribution.language === loc.code)
: locales.find(loc => this.$i18n.locale() === loc.code)
return locale.name
},
},
mounted() {
this.availableLocales()
},
methods: {
submit() {
const postMutations = require('~/graphql/PostMutations.js').default(this)
this.loading = true
this.$apollo
.mutate({
mutation: this.id ? postMutations.UpdatePost : postMutations.CreatePost,
mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
variables: {
id: this.id,
title: this.form.title,
content: this.form.content,
language: this.form.language ? this.form.language.value : this.$i18n.locale(),
},
})
.then(res => {
this.loading = false
this.$toast.success('Saved!')
this.$toast.success(this.$t('contribution.success'))
this.disabled = true
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
@ -103,6 +140,11 @@ export default {
// this.form.content = value
this.$refs.contributionForm.update('content', value)
},
availableLocales() {
orderBy(locales, 'name').map(locale => {
this.form.languageOptions.push({ label: locale.name, value: locale.code })
})
},
},
apollo: {
User: {
@ -135,4 +177,8 @@ export default {
padding-right: 0;
}
}
.contribution-form-footer {
border-top: $border-size-base solid $border-color-softest;
}
</style>

View File

@ -0,0 +1,183 @@
import { mount, createLocalVue } from '@vue/test-utils'
import DeleteData from './DeleteData.vue'
import Styleguide from '@human-connection/styleguide'
import Vuex from 'vuex'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
describe('DeleteData.vue', () => {
let mocks
let wrapper
let getters
let actions
let deleteAccountBtn
let enableDeletionInput
let enableContributionDeletionCheckbox
let enableCommentDeletionCheckbox
const deleteAccountName = 'Delete MyAccount'
const deleteContributionsMessage = 'Delete my 2 posts'
const deleteCommentsMessage = 'Delete my 3 comments'
beforeEach(() => {
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest
.fn()
.mockResolvedValueOnce({
data: {
DeleteData: {
id: 'u343',
},
},
})
.mockRejectedValue({ message: 'Not authorised!' }),
},
$toast: {
error: jest.fn(),
success: jest.fn(),
},
$router: {
history: {
push: jest.fn(),
},
},
}
getters = {
'auth/user': () => {
return { id: 'u343', name: deleteAccountName, contributionsCount: 2, commentsCount: 3 }
},
}
actions = { 'auth/logout': jest.fn() }
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters,
actions,
})
return mount(DeleteData, { mocks, localVue, store })
}
beforeEach(() => {
wrapper = Wrapper()
})
afterEach(() => {
jest.clearAllMocks()
})
it('defaults to deleteContributions to false', () => {
expect(wrapper.vm.deleteContributions).toEqual(false)
})
it('defaults to deleteComments to false', () => {
expect(wrapper.vm.deleteComments).toEqual(false)
})
it('defaults to deleteEnabled to false', () => {
expect(wrapper.vm.deleteEnabled).toEqual(false)
})
it('does not call the delete user mutation if deleteEnabled is false', () => {
deleteAccountBtn = wrapper.find('.ds-button-danger')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
describe('calls the delete user mutation', () => {
beforeEach(() => {
enableDeletionInput = wrapper.find('.enable-deletion-input input')
enableDeletionInput.setValue(deleteAccountName)
deleteAccountBtn = wrapper.find('.ds-button-danger')
})
it('if deleteEnabled is true and only deletes user by default', () => {
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
id: 'u343',
resource: [],
},
}),
)
})
it("deletes a user's posts if requested", () => {
mocks.$t.mockImplementation(() => deleteContributionsMessage)
enableContributionDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(0)
enableContributionDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
id: 'u343',
resource: ['Post'],
},
}),
)
})
it("deletes a user's comments if requested", () => {
mocks.$t.mockImplementation(() => deleteCommentsMessage)
enableCommentDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(1)
enableCommentDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
id: 'u343',
resource: ['Comment'],
},
}),
)
})
it("deletes a user's posts and comments if requested", () => {
mocks.$t.mockImplementation(() => deleteContributionsMessage)
enableContributionDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(0)
enableContributionDeletionCheckbox.trigger('click')
mocks.$t.mockImplementation(() => deleteCommentsMessage)
enableCommentDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(1)
enableCommentDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
id: 'u343',
resource: ['Post', 'Comment'],
},
}),
)
})
it('shows a success toaster after successful mutation', async () => {
await deleteAccountBtn.trigger('click')
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('redirect the user to the homepage', async () => {
await deleteAccountBtn.trigger('click')
expect(mocks.$router.history.push).toHaveBeenCalledWith('/')
})
})
describe('error handling', () => {
it('shows an error toaster when the mutation rejects', async () => {
enableDeletionInput = wrapper.find('.enable-deletion-input input')
enableDeletionInput.setValue(deleteAccountName)
deleteAccountBtn = wrapper.find('.ds-button-danger')
await deleteAccountBtn.trigger('click')
// second submission causes mutation to reject
await deleteAccountBtn.trigger('click')
await mocks.$apollo.mutate
expect(mocks.$toast.error).toHaveBeenCalledWith('Not authorised!')
})
})
})
})

View File

@ -0,0 +1,224 @@
<template>
<div>
<ds-card hover>
<ds-space />
<ds-container>
<ds-flex>
<ds-flex-item :width="{ base: '22%', sm: '12%', md: '12%', lg: '8%' }">
<ds-icon name="warning" size="xxx-large" class="delete-warning-icon" />
</ds-flex-item>
<ds-flex-item :width="{ base: '78%', sm: '88%', md: '88%', lg: '92%' }">
<ds-heading>{{ $t('settings.deleteUserAccount.name') }}</ds-heading>
</ds-flex-item>
<ds-space />
<ds-heading tag="h4">
{{ $t('settings.deleteUserAccount.accountDescription') }}
</ds-heading>
</ds-flex>
</ds-container>
<ds-space />
<ds-container>
<transition name="slide-up">
<div v-if="deleteEnabled">
<label v-if="currentUser.contributionsCount" class="checkbox-container">
<input type="checkbox" v-model="deleteContributions" />
<span class="checkmark"></span>
{{
$t('settings.deleteUserAccount.contributionsCount', {
count: currentUser.contributionsCount,
})
}}
</label>
<ds-space margin-bottom="small" />
<label v-if="currentUser.commentsCount" class="checkbox-container">
<input type="checkbox" v-model="deleteComments" />
<span class="checkmark"></span>
{{
$t('settings.deleteUserAccount.commentsCount', {
count: currentUser.commentsCount,
})
}}
</label>
<ds-space margin-bottom="small" />
<ds-section id="delete-user-account-warning">
<div v-html="$t('settings.deleteUserAccount.accountWarning')"></div>
</ds-section>
</div>
</transition>
</ds-container>
<template slot="footer" class="delete-data-footer">
<ds-container>
<div
class="delete-input-label"
v-html="$t('settings.deleteUserAccount.pleaseConfirm', { confirm: currentUser.name })"
></div>
<ds-space margin-bottom="xx-small" />
<ds-flex :gutter="{ base: 'xx-small', md: 'small', lg: 'large' }">
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '100%', lg: 1.75 }">
<ds-input
v-model="enableDeletionValue"
@input="enableDeletion"
class="enable-deletion-input"
/>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '100%', lg: 1 }">
<ds-button icon="trash" danger :disabled="!deleteEnabled" @click="handleSubmit">
{{ $t('settings.deleteUserAccount.name') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</ds-container>
</template>
</ds-card>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import gql from 'graphql-tag'
export default {
name: 'DeleteData',
data() {
return {
deleteContributions: false,
deleteComments: false,
deleteEnabled: false,
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
},
methods: {
...mapActions({
logout: 'auth/logout',
}),
enableDeletion() {
if (this.enableDeletionValue === this.currentUser.name) {
this.deleteEnabled = true
}
},
handleSubmit() {
let resourceArgs = []
if (this.deleteContributions) {
resourceArgs.push('Post')
}
if (this.deleteComments) {
resourceArgs.push('Comment')
}
this.$apollo
.mutate({
mutation: gql`
mutation($id: ID!, $resource: [String]) {
DeleteUser(id: $id, resource: $resource) {
id
}
}
`,
variables: { id: this.currentUser.id, resource: resourceArgs },
})
.then(() => {
this.$toast.success(this.$t('settings.deleteUserAccount.success'))
this.logout()
this.$router.history.push('/')
})
.catch(error => {
this.$toast.error(error.message)
})
},
},
}
</script>
<style lang="scss">
.delete-warning-icon {
color: $color-danger;
}
.checkbox-container {
display: block;
position: relative;
padding-left: 35px;
cursor: pointer;
font-size: $font-size-large;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
border: 2px solid $background-color-inverse-softer;
background-color: $background-color-base;
border-radius: $border-radius-x-large;
}
.checkbox-container:hover input ~ .checkmark {
background-color: $background-color-softest;
}
.checkbox-container input:checked ~ .checkmark {
background-color: $background-color-danger-active;
}
.checkmark:after {
content: '';
position: absolute;
display: none;
}
.checkbox-container input:checked ~ .checkmark:after {
display: block;
}
.checkbox-container .checkmark:after {
left: 6px;
top: 3px;
width: 5px;
height: 10px;
border: solid $background-color-base;
border-width: 0 $border-size-large $border-size-large 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.enable-deletion-input input:focus {
border-color: $border-color-danger;
}
.delete-input-label {
font-size: $font-size-base;
}
b.is-danger {
color: $text-color-danger;
}
.delete-data-footer {
border-top: $border-size-base solid $border-color-softest;
background-color: $background-color-danger-inverse;
}
#delete-user-account-warning {
background-color: $background-color-danger-inverse;
border-left: $border-size-x-large solid $background-color-danger-active;
color: $text-color-danger;
margin-left: 0px;
margin-right: 0px;
border-radius: $border-radius-x-large;
}
</style>

View File

@ -9,9 +9,11 @@ localVue.use(Styleguide)
describe('FilterMenu.vue', () => {
let wrapper
let mocks
let propsData
const createWrapper = mountMethod => {
return mountMethod(FilterMenu, {
propsData,
mocks,
localVue,
})
@ -19,6 +21,16 @@ describe('FilterMenu.vue', () => {
beforeEach(() => {
mocks = { $t: () => {} }
propsData = {}
})
describe('given a user', () => {
beforeEach(() => {
propsData = {
user: {
id: '4711',
},
}
})
describe('mount', () => {
@ -38,9 +50,11 @@ describe('FilterMenu.vue', () => {
it('toggles filterBubble.author property', () => {
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(wrapper.emitted('changeFilterBubble')[0]).toEqual([{ author: 'following' }])
expect(wrapper.emitted('changeFilterBubble')[0]).toEqual([
{ author: { followedBy_some: { id: '4711' } } },
])
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(wrapper.emitted('changeFilterBubble')[1]).toEqual([{ author: 'all' }])
expect(wrapper.emitted('changeFilterBubble')[1]).toEqual([{}])
})
it('makes button primary', () => {
@ -52,3 +66,4 @@ describe('FilterMenu.vue', () => {
})
})
})
})

View File

@ -11,7 +11,7 @@
<ds-button
name="filter-by-followed-authors-only"
icon="user-plus"
:primary="onlyFollowed"
:primary="!!filterAuthorIsFollowedById"
@click="toggleOnlyFollowed"
/>
</div>
@ -22,24 +22,30 @@
<script>
export default {
data() {
// We have to fix styleguide here. It uses .includes wich will always be
// false for arrays of objects.
return {
filterBubble: {
author: 'all',
props: {
user: { type: Object, required: true },
},
data() {
return {
filter: {},
}
},
computed: {
onlyFollowed() {
return this.filterBubble.author === 'following'
filterAuthorIsFollowedById() {
const { author = {} } = this.filter
/* eslint-disable camelcase */
const { followedBy_some = {} } = author
const { id } = followedBy_some
/* eslint-enable */
return id
},
},
methods: {
toggleOnlyFollowed() {
this.filterBubble.author = this.onlyFollowed ? 'all' : 'following'
this.$emit('changeFilterBubble', this.filterBubble)
this.filter = this.filterAuthorIsFollowedById
? {}
: { author: { followedBy_some: { id: this.user.id } } }
this.$emit('changeFilterBubble', this.filter)
},
},
}

View File

@ -1,20 +0,0 @@
<template>
<img v-bind="imageProps" :src="imageSrc" />
</template>
<script>
export default {
props: {
imageProps: {
type: Object,
required: true,
},
},
computed: {
imageSrc() {
const src = this.imageProps.src
return src.startsWith('/') ? src.replace('/', '/api/') : src
},
},
}
</script>

View File

@ -1,39 +0,0 @@
import { shallowMount } from '@vue/test-utils'
import Image from '.'
describe('Image', () => {
let propsData = { imageProps: { class: 'hc-badge', src: '' } }
const Wrapper = () => {
return shallowMount(Image, { propsData })
}
it('renders', () => {
expect(Wrapper().is('img')).toBe(true)
})
it('passes properties down to `img`', () => {
expect(Wrapper().classes()).toEqual(['hc-badge'])
})
describe('given a relative `src`', () => {
beforeEach(() => {
propsData.imageProps.src = '/img/badges/fundraisingbox_de_airship.svg'
})
it('adds a prefix to load the image from the backend', () => {
expect(Wrapper().attributes('src')).toBe('/api/img/badges/fundraisingbox_de_airship.svg')
})
})
describe('given an absolute `src`', () => {
beforeEach(() => {
propsData.imageProps.src = 'http://lorempixel.com/640/480/animals'
})
it('keeps the URL as is', () => {
// e.g. our seeds have absolute image URLs
expect(Wrapper().attributes('src')).toBe('http://lorempixel.com/640/480/animals')
})
})
})

View File

@ -1,5 +1,5 @@
<template>
<ds-space margin-top="large" style="text-align: center">
<ds-space class="load-more" margin-top="large" style="text-align: center">
<ds-button :loading="loading" icon="arrow-down" ghost @click="$emit('click')">
{{ $t('actions.loadMore') }}
</ds-button>

View File

@ -1,6 +1,6 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Modal from './Modal.vue'
import DeleteModal from './Modal/DeleteModal.vue'
import ConfirmModal from './Modal/ConfirmModal.vue'
import DisableModal from './Modal/DisableModal.vue'
import ReportModal from './Modal/ReportModal.vue'
import Vuex from 'vuex'
@ -60,7 +60,7 @@ describe('Modal.vue', () => {
it('initially empty', () => {
wrapper = Wrapper()
expect(wrapper.contains(DeleteModal)).toBe(false)
expect(wrapper.contains(ConfirmModal)).toBe(false)
expect(wrapper.contains(DisableModal)).toBe(false)
expect(wrapper.contains(ReportModal)).toBe(false)
})
@ -75,10 +75,6 @@ describe('Modal.vue', () => {
id: 'c456',
title: 'some title',
},
callbacks: {
confirm: null,
cancel: null,
},
},
}
wrapper = Wrapper()
@ -93,10 +89,6 @@ describe('Modal.vue', () => {
type: 'contribution',
name: 'some title',
id: 'c456',
callbacks: {
confirm: null,
cancel: null,
},
})
})
@ -117,20 +109,12 @@ describe('Modal.vue', () => {
name: 'Author name',
},
},
callbacks: {
confirm: null,
cancel: null,
},
}
wrapper = Wrapper()
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'comment',
name: 'Author name',
id: 'c456',
callbacks: {
confirm: null,
cancel: null,
},
})
})
@ -140,20 +124,12 @@ describe('Modal.vue', () => {
resource: {
id: 'c456',
},
callbacks: {
confirm: null,
cancel: null,
},
}
wrapper = Wrapper()
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'comment',
name: '',
id: 'c456',
callbacks: {
confirm: null,
cancel: null,
},
})
})
})

View File

@ -6,7 +6,6 @@
:id="data.resource.id"
:type="data.type"
:name="name"
:callbacks="data.callbacks"
@close="close"
/>
<release-modal
@ -21,22 +20,21 @@
:id="data.resource.id"
:type="data.type"
:name="name"
:callbacks="data.callbacks"
@close="close"
/>
<delete-modal
<confirm-modal
v-if="open === 'delete'"
:id="data.resource.id"
:type="data.type"
:name="name"
:callbacks="data.callbacks"
:modalData="data.modalsData.delete"
@close="close"
/>
</div>
</template>
<script>
import DeleteModal from '~/components/Modal/DeleteModal'
import ConfirmModal from '~/components/Modal/ConfirmModal'
import DisableModal from '~/components/Modal/DisableModal'
import ReleaseModal from '~/components/ReleaseModal/ReleaseModal.vue'
import ReportModal from '~/components/Modal/ReportModal'
@ -48,7 +46,7 @@ export default {
DisableModal,
ReleaseModal,
ReportModal,
DeleteModal,
ConfirmModal,
},
computed: {
...mapGetters({
@ -63,7 +61,7 @@ export default {
switch (this.data.type) {
case 'user':
return name
case 'contribution':
case 'contribution': // REFACTORING: In ConfirmModal Already replaced "title" by "this.menuModalsData.delete.messageParams".
return title
case 'comment':
return author && author.name

View File

@ -1,28 +1,29 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import DeleteModal from './DeleteModal.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import ConfirmModal from './ConfirmModal.vue'
import { postMenuModalsData } from '~/components/utils/PostHelpers'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
describe('DeleteModal.vue', () => {
describe('ConfirmModal.vue', () => {
let Wrapper
let wrapper
let propsData
let mocks
const postName = 'It is a post'
const confirmCallback = jest.fn()
const cancelCallback = jest.fn()
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'p23',
name: 'It is a post',
callbacks: {
confirm: jest.fn(),
cancel: jest.fn(),
},
name: postName,
modalData: postMenuModalsData(postName, confirmCallback, cancelCallback).delete,
}
mocks = {
$t: jest.fn(),
@ -32,9 +33,13 @@ describe('DeleteModal.vue', () => {
}
})
afterEach(() => {
jest.clearAllMocks()
})
describe('shallowMount', () => {
Wrapper = () => {
return shallowMount(DeleteModal, {
return shallowMount(ConfirmModal, {
propsData,
mocks,
localVue,
@ -61,7 +66,7 @@ describe('DeleteModal.vue', () => {
...propsData,
type: 'contribution',
id: 'p23',
name: 'It is a post',
name: postName,
}
wrapper = Wrapper()
})
@ -72,32 +77,7 @@ describe('DeleteModal.vue', () => {
[
'delete.contribution.message',
{
name: 'It is a post',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
describe('given a comment', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'comment',
id: 'c4',
name: 'It is the user of the comment',
}
wrapper = Wrapper()
})
it('mentions comments user name', () => {
const calls = mocks.$t.mock.calls
const expected = [
[
'delete.comment.message',
{
name: 'It is the user of the comment',
name: postName,
},
],
]
@ -108,7 +88,7 @@ describe('DeleteModal.vue', () => {
describe('mount', () => {
Wrapper = () => {
return mount(DeleteModal, {
return mount(ConfirmModal, {
propsData,
mocks,
localVue,
@ -135,7 +115,7 @@ describe('DeleteModal.vue', () => {
})
it('does call the cancel callback', () => {
expect(propsData.callbacks.cancel).toHaveBeenCalledTimes(1)
expect(cancelCallback).toHaveBeenCalledTimes(1)
})
it('emits "close"', () => {
@ -161,10 +141,11 @@ describe('DeleteModal.vue', () => {
})
it('does call the confirm callback', () => {
expect(propsData.callbacks.confirm).toHaveBeenCalledTimes(1)
expect(confirmCallback).toHaveBeenCalledTimes(1)
})
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
it('emits "close"', () => {
expect(wrapper.emitted().close).toHaveLength(1)
})
it('resets success', () => {

View File

@ -10,10 +10,18 @@
<p v-html="message" />
<template slot="footer">
<ds-button class="cancel" icon="close" @click="cancel">{{ $t('delete.cancel') }}</ds-button>
<ds-button class="cancel" :icon="modalData.buttons.cancel.icon" @click="cancel">
{{ $t(modalData.buttons.cancel.textIdent) }}
</ds-button>
<ds-button danger class="confirm" icon="trash" :loading="loading" @click="confirm">
{{ $t('delete.submit') }}
<ds-button
:danger="modalData.buttons.confirm.danger"
class="confirm"
:icon="modalData.buttons.confirm.icon"
:loading="loading"
@click="confirm"
>
{{ $t(modalData.buttons.confirm.textIdent) }}
</ds-button>
</template>
</ds-modal>
@ -23,14 +31,14 @@
import { SweetalertIcon } from 'vue-sweetalert-icons'
export default {
name: 'DeleteModal',
name: 'ConfirmModal',
components: {
SweetalertIcon,
},
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
callbacks: { type: Object, required: true },
modalData: { type: Object, required: true },
id: { type: String, required: true },
},
data() {
@ -42,18 +50,15 @@ export default {
},
computed: {
title() {
return this.$t(`delete.${this.type}.title`)
return this.$t(this.modalData.titleIdent)
},
message() {
const name = this.$filters.truncate(this.name, 30)
return this.$t(`delete.${this.type}.message`, { name })
return this.$t(this.modalData.messageIdent, this.modalData.messageParams)
},
},
methods: {
async cancel() {
if (this.callbacks.cancel) {
await this.callbacks.cancel()
}
await this.modalData.buttons.cancel.callback()
this.isOpen = false
setTimeout(() => {
this.$emit('close')
@ -62,9 +67,7 @@ export default {
async confirm() {
this.loading = true
try {
if (this.callbacks.confirm) {
await this.callbacks.confirm()
}
await this.modalData.buttons.confirm.callback()
this.success = true
setTimeout(() => {
this.isOpen = false

View File

@ -16,22 +16,25 @@ describe('DisableModal.vue', () => {
type: 'contribution',
id: 'c42',
name: 'blah',
callbacks: {
confirm: jest.fn(),
cancel: jest.fn(),
},
}
mocks = {
$filters: {
truncate: a => a,
},
$toast: {
success: () => {},
error: () => {},
success: jest.fn(),
error: jest.fn(),
},
$t: jest.fn(),
$apollo: {
mutate: jest.fn().mockResolvedValue(),
mutate: jest
.fn()
.mockResolvedValueOnce({
enable: 'u4711',
})
.mockRejectedValue({
message: 'Not Authorised!',
}),
},
location: {
reload: jest.fn(),
@ -151,6 +154,10 @@ describe('DisableModal.vue', () => {
await wrapper.find('button.confirm').trigger('click')
})
afterEach(() => {
jest.clearAllMocks()
})
it('calls mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
@ -174,6 +181,18 @@ describe('DisableModal.vue', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
describe('handles errors', () => {
beforeEach(() => {
wrapper = Wrapper()
// second submission causes mutation to reject
wrapper.find('button.confirm').trigger('click')
})
it('shows an error toaster when mutation rejects', async () => {
await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
})
})
})
})
})

View File

@ -21,7 +21,6 @@ export default {
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
callbacks: { type: Object, required: true },
id: { type: String, required: true },
},
data() {
@ -42,9 +41,8 @@ export default {
},
methods: {
async cancel() {
if (this.callbacks.cancel) {
await this.callbacks.cancel()
}
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.cancel.callback()
this.isOpen = false
setTimeout(() => {
this.$emit('close')
@ -52,9 +50,8 @@ export default {
},
async confirm() {
try {
if (this.callbacks.confirm) {
await this.callbacks.confirm()
}
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.confirm.callback()
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
@ -68,9 +65,6 @@ export default {
setTimeout(() => {
this.$emit('close')
}, 1000)
setTimeout(() => {
location.reload()
}, 250)
} catch (err) {
this.$toast.error(err.message)
}

View File

@ -17,10 +17,6 @@ describe('ReportModal.vue', () => {
propsData = {
type: 'contribution',
id: 'c43',
callbacks: {
confirm: jest.fn(),
cancel: jest.fn(),
},
}
mocks = {
$t: jest.fn(),

View File

@ -10,9 +10,7 @@
<p v-html="message" />
<template slot="footer">
<ds-button class="cancel" icon="close" @click="cancel">
{{ $t('report.cancel') }}
</ds-button>
<ds-button class="cancel" icon="close" @click="cancel">{{ $t('report.cancel') }}</ds-button>
<ds-button
danger
@ -39,7 +37,6 @@ export default {
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
callbacks: { type: Object, required: true },
id: { type: String, required: true },
},
data() {
@ -60,9 +57,8 @@ export default {
},
methods: {
async cancel() {
if (this.callbacks.cancel) {
await this.callbacks.cancel()
}
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.cancel.callback()
this.isOpen = false
setTimeout(() => {
this.$emit('close')
@ -71,9 +67,8 @@ export default {
async confirm() {
this.loading = true
try {
if (this.callbacks.confirm) {
await this.callbacks.confirm()
}
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.confirm.callback()
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {

View File

@ -1,10 +1,12 @@
import { mount, createLocalVue } from '@vue/test-utils'
import ChangePassword from './Change.vue'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Filters)
describe('ChangePassword.vue', () => {
let mocks

View File

@ -0,0 +1,130 @@
import { config, shallowMount, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import Vuex from 'vuex'
import Filters from '~/plugins/vue-filters'
import PostCard from '.'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('PostCard', () => {
let store
let stubs
let mocks
let propsData
let getters
let Wrapper
let wrapper
beforeEach(() => {
propsData = {
post: {
id: 'p23',
name: 'It is a post',
author: {
id: 'u1',
},
disabled: false,
},
}
store = new Vuex.Store({
getters: {
'auth/user': () => {
return {}
},
},
})
stubs = {
NuxtLink: RouterLinkStub,
}
mocks = {
$t: jest.fn(),
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
},
}
getters = {
'auth/user': () => {
return {}
},
}
})
describe('shallowMount', () => {
Wrapper = () => {
return shallowMount(PostCard, {
store,
propsData,
mocks,
localVue,
})
}
beforeEach(jest.useFakeTimers)
describe('test Post callbacks', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('deletion of Post from Page by invoking "deletePostCallback()"', () => {
beforeEach(() => {
wrapper.vm.deletePostCallback()
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('does call mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
it('mutation is successful', () => {
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('emits "removePostFromList"', () => {
expect(wrapper.emitted().removePostFromList).toHaveLength(1)
})
})
})
})
})
describe('mount', () => {
Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(PostCard, {
stubs,
mocks,
propsData,
store,
localVue,
})
}
describe('given a post', () => {
beforeEach(() => {
propsData.post = {
title: "It's a title",
}
})
it('renders title', () => {
expect(Wrapper().text()).toContain("It's a title")
})
})
})
})

View File

@ -1,6 +1,9 @@
<template>
<ds-flex-item :width="width">
<ds-card :image="post.image" :class="{ 'post-card': true, 'disabled-content': post.disabled }">
<ds-card
:image="post.image | proxyApiUrl"
:class="{ 'post-card': true, 'disabled-content': post.disabled }"
>
<!-- Post Link Target -->
<nuxt-link
class="post-link"
@ -18,9 +21,7 @@
</div>
<ds-space margin-bottom="small" />
<!-- Post Title -->
<ds-heading tag="h3" no-margin>
{{ post.title }}
</ds-heading>
<ds-heading tag="h3" no-margin>{{ post.title }}</ds-heading>
<ds-space margin-bottom="small" />
<!-- Post Content Excerpt -->
<!-- eslint-disable vue/no-v-html -->
@ -55,7 +56,7 @@
<content-menu
resource-type="contribution"
:resource="post"
:callbacks="{ confirm: deletePostCallback, cancel: null }"
:modalsData="menuModalsData"
:is-owner="isAuthor"
/>
</div>
@ -72,7 +73,7 @@ import HcCategory from '~/components/Category'
import HcRibbon from '~/components/Ribbon'
// import { randomBytes } from 'crypto'
import { mapGetters } from 'vuex'
import PostMutationHelpers from '~/mixins/PostMutationHelpers'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
export default {
name: 'HcPostCard',
@ -82,7 +83,6 @@ export default {
HcRibbon,
ContentMenu,
},
mixins: [PostMutationHelpers],
props: {
post: {
type: Object,
@ -105,6 +105,24 @@ export default {
if (!author) return false
return this.user.id === this.post.author.id
},
menuModalsData() {
return postMenuModalsData(
// "this.post" may not always be defined at the beginning
this.post ? this.$filters.truncate(this.post.title, 30) : '',
this.deletePostCallback,
)
},
},
methods: {
async deletePostCallback() {
try {
await this.$apollo.mutate(deletePostMutation(this.post.id))
this.$toast.success(this.$t('delete.contribution.success'))
this.$emit('removePostFromList')
} catch (err) {
this.$toast.error(err.message)
}
},
},
}
</script>

View File

@ -1,61 +0,0 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import PostCard from '.'
import Styleguide from '@human-connection/styleguide'
import Vuex from 'vuex'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('PostCard', () => {
let stubs
let mocks
let propsData
let getters
beforeEach(() => {
propsData = {}
stubs = {
NuxtLink: RouterLinkStub,
}
mocks = {
$t: jest.fn(),
}
getters = {
'auth/user': () => {
return {}
},
}
})
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(PostCard, {
stubs,
mocks,
propsData,
store,
localVue,
})
}
describe('given a post', () => {
beforeEach(() => {
propsData.post = {
title: "It's a title",
}
})
it('renders title', () => {
expect(Wrapper().text()).toContain("It's a title")
})
})
})

View File

@ -23,12 +23,15 @@ describe('ReleaseModal.vue', () => {
truncate: a => a,
},
$toast: {
success: () => {},
error: () => {},
success: jest.fn(),
error: jest.fn(),
},
$t: jest.fn(),
$apollo: {
mutate: jest.fn().mockResolvedValue(),
mutate: jest
.fn()
.mockResolvedValueOnce({ enable: 'u4711' })
.mockRejectedValue({ message: 'Not Authorised!' }),
},
location: {
reload: jest.fn(),
@ -146,6 +149,10 @@ describe('ReleaseModal.vue', () => {
wrapper.find('button.confirm').trigger('click')
})
afterEach(() => {
jest.clearAllMocks()
})
it('calls mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
@ -169,6 +176,18 @@ describe('ReleaseModal.vue', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
describe('handles errors', () => {
beforeEach(() => {
wrapper = Wrapper()
// second submission causes mutation to reject
wrapper.find('button.confirm').trigger('click')
})
it('shows an error toaster when mutation rejects', async () => {
await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
})
})
})
})
})

View File

@ -40,6 +40,8 @@ export default {
},
methods: {
cancel() {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.cancel.callback()
this.isOpen = false
setTimeout(() => {
this.$emit('close')
@ -47,6 +49,8 @@ export default {
},
async confirm() {
try {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.confirm.callback()
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
@ -57,17 +61,9 @@ export default {
})
this.$toast.success(this.$t('release.success'))
this.isOpen = false
/*
setTimeout(() => {
location.reload()
}, 1500)
*/
setTimeout(() => {
this.$emit('close')
}, 1000)
setTimeout(() => {
location.reload()
}, 250)
} catch (err) {
this.$toast.error(err.message)
}

View File

@ -2,6 +2,7 @@ import { mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import User from './index'
import Vuex from 'vuex'
import VTooltip from 'v-tooltip'
import Filters from '~/plugins/vue-filters'
import Styleguide from '@human-connection/styleguide'
@ -11,6 +12,7 @@ const filter = jest.fn(str => str)
localVue.use(Vuex)
localVue.use(VTooltip)
localVue.use(Styleguide)
localVue.use(Filters)
localVue.filter('truncate', filter)

View File

@ -3,11 +3,13 @@ import CommentList from '.'
import Empty from '~/components/Empty'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Vuex)
localVue.use(Filters)
localVue.filter('truncate', string => string)
config.stubs['v-popover'] = '<span><slot /></span>'
@ -22,7 +24,9 @@ describe('CommentList.vue', () => {
let data
propsData = {
post: { id: 1 },
post: {
id: 1,
},
}
store = new Vuex.Store({
getters: {
@ -33,6 +37,9 @@ describe('CommentList.vue', () => {
})
mocks = {
$t: jest.fn(),
$filters: {
truncate: a => a,
},
$apollo: {
queries: {
Post: {
@ -49,13 +56,24 @@ describe('CommentList.vue', () => {
describe('shallowMount', () => {
const Wrapper = () => {
return mount(CommentList, { store, mocks, localVue, propsData, data })
return mount(CommentList, {
store,
mocks,
localVue,
propsData,
data,
})
}
beforeEach(() => {
wrapper = Wrapper()
wrapper.setData({
comments: [{ id: 'c1', contentExcerpt: 'this is a comment' }],
comments: [
{
id: 'c1',
contentExcerpt: 'this is a comment',
},
],
})
})

View File

@ -0,0 +1,35 @@
import PostMutations from '~/graphql/PostMutations.js'
export function postMenuModalsData(truncatedPostName, confirmCallback, cancelCallback = () => {}) {
return {
delete: {
titleIdent: 'delete.contribution.title',
messageIdent: 'delete.contribution.message',
messageParams: {
name: truncatedPostName,
},
buttons: {
confirm: {
danger: true,
icon: 'trash',
textIdent: 'delete.submit',
callback: confirmCallback,
},
cancel: {
icon: 'close',
textIdent: 'delete.cancel',
callback: cancelCallback,
},
},
},
}
}
export function deletePostMutation(postId) {
return {
mutation: PostMutations().DeletePost,
variables: {
id: postId,
},
}
}

View File

@ -1,28 +1,37 @@
import gql from 'graphql-tag'
export default app => {
export default () => {
return {
CreatePost: gql(`
mutation($title: String!, $content: String!) {
CreatePost(title: $title, content: $content) {
CreatePost: gql`
mutation($title: String!, $content: String!, $language: String) {
CreatePost(title: $title, content: $content, language: $language) {
id
title
slug
content
contentExcerpt
language
}
}
`),
UpdatePost: gql(`
mutation($id: ID!, $title: String!, $content: String!) {
UpdatePost(id: $id, title: $title, content: $content) {
`,
UpdatePost: gql`
mutation($id: ID!, $title: String!, $content: String!, $language: String) {
UpdatePost(id: $id, title: $title, content: $content, language: $language) {
id
title
slug
content
contentExcerpt
language
}
}
`),
`,
DeletePost: gql`
mutation($id: ID!) {
DeletePost(id: $id) {
id
}
}
`,
}
}

View File

@ -0,0 +1,38 @@
import gql from 'graphql-tag'
export default i18n => {
const lang = i18n.locale().toUpperCase()
return gql(`
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
Post(filter: $filter, first: $first, offset: $offset, orderBy: createdAt_desc) {
id
slug
title
contentExcerpt
shoutedCount
commentsCount
deleted
image
createdAt
disabled
deleted
categories {
id
name
icon
}
author {
id
slug
avatar
name
disabled
deleted
location {
name: name${lang}
}
}
}
}
`)
}

View File

@ -1,10 +1,10 @@
import gql from 'graphql-tag'
export default app => {
const lang = app.$i18n.locale().toUpperCase()
export default i18n => {
const lang = i18n.locale().toUpperCase()
return gql(`
query User($slug: String!, $first: Int, $offset: Int) {
User(slug: $slug) {
query User($id: ID!) {
User(id: $id) {
id
slug
name
@ -24,7 +24,7 @@ export default app => {
}
badgesCount
shoutedCount
commentsCount
commentedCount
followingCount
following(first: 7) {
id
@ -69,35 +69,6 @@ export default app => {
}
}
contributionsCount
contributions(first: $first, offset: $offset, orderBy: createdAt_desc) {
id
slug
title
contentExcerpt
shoutedCount
commentsCount
deleted
image
createdAt
disabled
deleted
categories {
id
name
icon
}
author {
id
slug
avatar
name
disabled
deleted
location {
name: name${lang}
}
}
}
socialMedia {
id
url

View File

@ -25,7 +25,16 @@
"shouted": "Empfohlen",
"commented": "Kommentiert",
"userAnonym": "Anonymus",
"socialMedia": "Wo sonst finde ich"
"socialMedia": "Wo sonst finde ich",
"network": {
"title": "Netzwerk",
"following": "folgt:",
"followingNobody": "folgt niemandem.",
"followedBy": "wird gefolgt von:",
"followedByNobody": "wird von niemandem gefolgt.",
"and": "und",
"more": "weitere"
}
},
"notifications": {
"menu": {
@ -73,8 +82,14 @@
"download": {
"name": "Daten herunterladen"
},
"delete": {
"name": "Konto löschen"
"deleteUserAccount": {
"name": "Daten löschen",
"contributionsCount": "Meine {count} Beiträge löschen",
"commentsCount": "Meine {count} Kommentare löschen",
"accountDescription": "Sei dir bewusst, dass deine Beiträge und Kommentare für unsere Community wichtig sind. Wenn du sie trotzdem löschen möchtest, musst du sie unten markieren.",
"accountWarning": "Dein Konto, deine Beiträge oder Kommentare kannst du nach dem Löschen <b>WEDER VERWALTEN NOCH WIEDERHERSTELLEN!</b>",
"success": "Konto erfolgreich gelöscht",
"pleaseConfirm": "<b class='is-danger'>Zerstörerische Aktion!</b> Gib <b>{confirm}</b> ein, um zu bestätigen."
},
"organizations": {
"name": "Meine Organisationen"
@ -163,11 +178,6 @@
}
},
"common": {
"your": {
"post": "Dein Beitrag ::: Deine Beiträge",
"comment": "Dein Kommentar ::: Deine Kommentare",
"shouted": "Deine Empfehlung ::: Deine Empfehlungen"
},
"post": "Beitrag ::: Beiträge",
"comment": "Kommentar ::: Kommentare",
"letsTalk": "Miteinander reden",
@ -235,7 +245,7 @@
"comment": {
"title": "Lösche Kommentar",
"type": "Comment",
"message": "Bist du sicher, dass du den Kommentar von \"<b>{name}</b>\" löschen möchtest?",
"message": "Bist du sicher, dass du den Kommentar \"<b>{name}</b>\" löschen möchtest?",
"success": "Kommentar erfolgreich gelöscht!"
}
},
@ -291,5 +301,9 @@
"avatar": {
"submitted": "Upload erfolgreich"
}
},
"contribution": {
"success": "Gespeichert!",
"languageSelectLabel": "Sprache"
}
}

View File

@ -25,7 +25,16 @@
"shouted": "Shouted",
"commented": "Commented",
"userAnonym": "Anonymous",
"socialMedia": "Where else can I find"
"socialMedia": "Where else can I find",
"network": {
"title": "Network",
"following": "is following:",
"followingNobody": "follows nobody.",
"followedBy": "is followed by:",
"followedByNobody": "is not followed by anyone.",
"and": "and",
"more": "more"
}
},
"notifications": {
"menu": {
@ -73,8 +82,14 @@
"download": {
"name": "Download Data"
},
"delete": {
"name": "Delete Account"
"deleteUserAccount": {
"name": "Delete Data",
"contributionsCount": "Delete my {count} posts",
"commentsCount": "Delete my {count} comments",
"accountDescription": "Be aware that your Post and Comments are important to our community. If you still choose to delete them, you have to mark them below.",
"accountWarning": "You <b>CAN'T MANAGE</b> and <b>CAN'T RECOVER</b> your Account, Posts, or Comments after deleting your account!",
"success": "Account successfully deleted",
"pleaseConfirm": "<b class='is-danger'>Destructive action!</b> Type <b>{confirm}</b> to confirm"
},
"organizations": {
"name": "My Organizations"
@ -163,11 +178,6 @@
}
},
"common": {
"your": {
"post": "Your Post ::: Your Posts",
"comment": "Your Comment ::: Your Comments",
"shout": "Your Shout ::: Your Shouts"
},
"post": "Post ::: Posts",
"comment": "Comment ::: Comments",
"letsTalk": "Let`s Talk",
@ -235,7 +245,7 @@
"comment": {
"title": "Delete Comment",
"type": "Comment",
"message": "Do you really want to delete the comment from \"<b>{name}</b>\"?",
"message": "Do you really want to delete the comment \"<b>{name}</b>\"?",
"success": "Comment successfully deleted!"
}
},
@ -290,5 +300,9 @@
"avatar": {
"submitted": "Upload successful"
}
},
"contribution": {
"success": "Saved!",
"languageSelectLabel": "Language"
}
}

View File

@ -1,39 +0,0 @@
import gql from 'graphql-tag'
export default {
methods: {
async deletePostCallback(postDisplayType = 'list') {
// console.log('inside "deletePostCallback" !!! ', this.post)
try {
var gqlMutation = gql`
mutation($id: ID!) {
DeletePost(id: $id) {
id
}
}
`
await this.$apollo.mutate({
mutation: gqlMutation,
variables: {
id: this.post.id,
},
})
this.$toast.success(this.$t('delete.contribution.success'))
// console.log('called "this.$t" !!!')
switch (postDisplayType) {
case 'list':
this.$emit('deletePost')
// console.log('emitted "deletePost" !!!')
break
default:
// case 'page'
// console.log('called "this.$router.history.push" !!!')
this.$router.history.push('/') // Single page type: Redirect to index (main) page
break
}
} catch (err) {
this.$toast.error(err.message)
}
},
},
}

View File

@ -56,10 +56,10 @@
"@nuxtjs/style-resources": "~0.1.2",
"accounting": "~0.4.1",
"apollo-cache-inmemory": "~1.5.1",
"apollo-client": "~2.6.1",
"cookie-universal-nuxt": "~2.0.14",
"apollo-client": "~2.6.2",
"cookie-universal-nuxt": "~2.0.16",
"cross-env": "~5.2.0",
"date-fns": "2.0.0-alpha.29",
"date-fns": "2.0.0-alpha.33",
"express": "~4.17.1",
"graphql": "~14.3.1",
"jsonwebtoken": "~8.5.1",
@ -70,7 +70,7 @@
"stack-utils": "^1.0.2",
"string-hash": "^1.1.3",
"tiptap": "1.20.1",
"tiptap-extensions": "1.21.0",
"tiptap-extensions": "1.22.2",
"v-tooltip": "~2.0.2",
"vue-count-to": "~1.0.13",
"vue-izitoast": "1.1.2",
@ -104,9 +104,9 @@
"jest": "~24.8.0",
"node-sass": "~4.12.0",
"nodemon": "~1.19.1",
"prettier": "~1.17.1",
"prettier": "~1.18.2",
"sass-loader": "~7.1.0",
"tippy.js": "^4.3.3",
"tippy.js": "^4.3.4",
"vue-jest": "~3.0.4",
"vue-svg-loader": "~0.12.0"
}

View File

@ -2,14 +2,14 @@
<div>
<ds-flex :width="{ base: '100%' }" gutter="base">
<ds-flex-item>
<filter-menu @changeFilterBubble="changeFilterBubble" />
<filter-menu :user="currentUser" @changeFilterBubble="changeFilterBubble" />
</ds-flex-item>
<hc-post-card
v-for="(post, index) in uniq(Post)"
:key="post.id"
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
@deletePost="deletePost(index, post.id)"
@removePostFromList="deletePost(index, post.id)"
/>
</ds-flex>
<no-ssr>
@ -32,6 +32,7 @@ import gql from 'graphql-tag'
import uniqBy from 'lodash/uniqBy'
import HcPostCard from '~/components/PostCard'
import HcLoadMore from '~/components/LoadMore.vue'
import { mapGetters } from 'vuex'
export default {
components: {
@ -45,10 +46,13 @@ export default {
Post: [],
page: 1,
pageSize: 10,
filterBubble: { author: 'all' },
filter: {},
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
tags() {
return this.Post ? this.Post[0].tags.map(tag => tag.name) : '-'
},
@ -57,8 +61,8 @@ export default {
},
},
methods: {
changeFilterBubble(filterBubble) {
this.filterBubble = filterBubble
changeFilterBubble(filter) {
this.filter = filter
this.$apollo.queries.Post.refresh()
},
uniq(items, field = 'id') {
@ -76,7 +80,7 @@ export default {
this.page++
this.$apollo.queries.Post.fetchMore({
variables: {
filterBubble: this.filterBubble,
filter: this.filter,
first: this.pageSize,
offset: this.offset,
},
@ -102,8 +106,8 @@ export default {
Post: {
query() {
return gql(`
query Post($filterBubble: FilterBubble, $first: Int, $offset: Int) {
Post(filterBubble: $filterBubble, first: $first, offset: $offset) {
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
Post(filter: $filter, first: $first, offset: $offset) {
id
title
contentExcerpt
@ -146,7 +150,7 @@ export default {
},
variables() {
return {
filterBubble: this.filterBubble,
filter: this.filter,
first: this.pageSize,
offset: 0,
}

View File

@ -56,7 +56,7 @@ describe('PostSlug', () => {
beforeEach(jest.useFakeTimers)
describe('test mixin "PostMutationHelpers"', () => {
describe('test Post callbacks', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.setData({
@ -70,22 +70,14 @@ describe('PostSlug', () => {
})
})
describe('deletion of Post from Page by invoking "deletePostCallback(`page`)"', () => {
describe('deletion of Post from Page by invoking "deletePostCallback()"', () => {
beforeEach(() => {
wrapper.vm.deletePostCallback('page')
wrapper.vm.deletePostCallback()
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('not emits "deletePost"', () => {
expect(wrapper.emitted().deletePost).toBeFalsy()
})
it('does go to index (main) page', () => {
expect(mocks.$router.history.push).toHaveBeenCalledTimes(1)
})
it('does call mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
@ -93,6 +85,10 @@ describe('PostSlug', () => {
it('mutation is successful', () => {
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('does go to index (main) page', () => {
expect(mocks.$router.history.push).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@ -7,19 +7,18 @@
>
<ds-space margin-bottom="small" />
<hc-user :user="post.author" :date-time="post.createdAt" />
<!-- Content Menu (can open Modals) -->
<no-ssr>
<content-menu
placement="bottom-end"
resource-type="contribution"
:resource="post"
:callbacks="{ confirm: () => deletePostCallback('page'), cancel: null }"
:is-owner="isAuthor(post.author.id)"
:modalsData="menuModalsData"
:is-owner="isAuthor(post.author ? post.author.id : null)"
/>
</no-ssr>
<ds-space margin-bottom="small" />
<ds-heading tag="h3" no-margin>
{{ post.title }}
</ds-heading>
<ds-heading tag="h3" no-margin>{{ post.title }}</ds-heading>
<ds-space margin-bottom="small" />
<!-- Content -->
<!-- eslint-disable vue/no-v-html -->
@ -72,7 +71,7 @@ import HcUser from '~/components/User'
import HcShoutButton from '~/components/ShoutButton.vue'
import HcCommentForm from '~/components/comments/CommentForm'
import HcCommentList from '~/components/comments/CommentList'
import PostMutationHelpers from '~/mixins/PostMutationHelpers'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
export default {
name: 'PostSlug',
@ -89,7 +88,6 @@ export default {
HcCommentForm,
HcCommentList,
},
mixins: [PostMutationHelpers],
head() {
return {
title: this.title,
@ -211,10 +209,28 @@ export default {
this.ready = true
}, 50)
},
computed: {
menuModalsData() {
return postMenuModalsData(
// "this.post" may not always be defined at the beginning
this.post ? this.$filters.truncate(this.post.title, 30) : '',
this.deletePostCallback,
)
},
},
methods: {
isAuthor(id) {
return this.$store.getters['auth/user'].id === id
},
async deletePostCallback() {
try {
await this.$apollo.mutate(deletePostMutation(this.post.id))
this.$toast.success(this.$t('delete.contribution.success'))
this.$router.history.push('/') // Redirect to index (main) page
} catch (err) {
this.$toast.error(err.message)
}
},
},
}
</script>

View File

@ -1,8 +1,6 @@
<template>
<ds-card>
<h2 style="margin-bottom: .2em;">
Mehr Informationen
</h2>
<h2 style="margin-bottom: .2em;">Mehr Informationen</h2>
<p>Hier findest du weitere infos zum Thema.</p>
<ds-space />
<h3>
@ -42,7 +40,7 @@
:key="relatedPost.id"
:post="relatedPost"
:width="{ base: '100%', lg: 1 }"
@deletePost="post.relatedContributions.splice(index, 1)"
@removePostFromList="post.relatedContributions.splice(index, 1)"
/>
</ds-flex>
<hc-empty v-else margin="large" icon="file" message="No related Posts" />

View File

@ -3,9 +3,7 @@
<ds-flex-item :width="{ base: '100%', md: 3 }">
<hc-contribution-form :contribution="contribution" />
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">
&nbsp;
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item>
</ds-flex>
</template>
@ -49,6 +47,7 @@ export default {
deleted
slug
image
language
author {
id
disabled

View File

@ -1,12 +1,19 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'
import { config, mount, createLocalVue } from '@vue/test-utils'
import ProfileSlug from './_slug.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
localVue.filter('date', d => d)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('ProfileSlug', () => {
let wrapper
@ -20,7 +27,13 @@ describe('ProfileSlug', () => {
name: 'It is a post',
},
$t: jest.fn(),
// If you mocking router, than don't use VueRouter with lacalVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
// If you're mocking router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$route: {
params: {
id: '4711',
slug: 'john-doe',
},
},
$router: {
history: {
push: jest.fn(),
@ -31,48 +44,138 @@ describe('ProfileSlug', () => {
error: jest.fn(),
},
$apollo: {
loading: false,
mutate: jest.fn().mockResolvedValue(),
},
}
})
describe('shallowMount', () => {
describe('mount', () => {
Wrapper = () => {
return shallowMount(ProfileSlug, {
return mount(ProfileSlug, {
mocks,
localVue,
})
}
beforeEach(jest.useFakeTimers)
describe('given an authenticated user', () => {
beforeEach(() => {
mocks.$filters = {
removeLinks: c => c,
truncate: a => a,
}
mocks.$store = {
getters: {
'auth/user': {
id: 'u23',
},
},
}
})
describe('test mixin "PostMutationHelpers"', () => {
describe('given a user for the profile', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.setData({
User: [
{
id: 'u3',
name: 'Bob the builder',
contributionsCount: 6,
shoutedCount: 7,
commentedCount: 8,
},
],
})
})
describe('deletion of Post from List by invoking "deletePostCallback(`list`)"', () => {
it('displays name of the user', () => {
expect(wrapper.text()).toContain('Bob the builder')
})
describe('load more button', () => {
const aPost = {
title: 'I am a post',
content: 'This is my content',
contentExcerpt: 'This is my content',
}
describe('currently no posts available (e.g. after tab switching)', () => {
beforeEach(() => {
wrapper.vm.deletePostCallback('list')
wrapper.setData({
Post: null,
})
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('emits "deletePost"', () => {
expect(wrapper.emitted().deletePost.length).toBe(1)
it('displays no "load more" button', () => {
expect(wrapper.find('.load-more').exists()).toBe(false)
})
it('does not go to index (main) page', () => {
expect(mocks.$router.history.push).not.toHaveBeenCalled()
describe('apollo client in `loading` state', () => {
beforeEach(() => {
wrapper.vm.$apollo.loading = true
})
it('does call mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
it('never displays more than one loading spinner', () => {
expect(wrapper.findAll('.ds-spinner')).toHaveLength(1)
})
it('mutation is successful', () => {
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
it('displays a loading spinner below the posts list', () => {
expect(wrapper.find('.user-profile-posts-list .ds-spinner').exists()).toBe(true)
})
})
})
describe('pagination returned less posts than available', () => {
beforeEach(() => {
const posts = [1, 2, 3, 4, 5].map(id => {
return {
...aPost,
id,
}
})
wrapper.setData({
Post: posts,
})
})
it('displays a "load more" button', () => {
expect(wrapper.find('.load-more').exists()).toBe(true)
})
describe('apollo client in `loading` state', () => {
beforeEach(() => {
wrapper.vm.$apollo.loading = true
})
it('never displays more than one loading spinner', () => {
expect(wrapper.findAll('.ds-spinner')).toHaveLength(1)
})
it('displays a loading spinner below the posts list', () => {
expect(wrapper.find('.load-more .ds-spinner').exists()).toBe(true)
})
})
})
describe('pagination returned as many posts as available', () => {
beforeEach(() => {
const posts = [1, 2, 3, 4, 5, 6].map(id => {
return {
...aPost,
id,
}
})
wrapper.setData({
Post: posts,
})
})
it('displays no "load more" button', () => {
expect(wrapper.find('.load-more').exists()).toBe(false)
})
})
})
})

View File

@ -12,12 +12,12 @@
>
<hc-upload v-if="myProfile" :user="user" />
<hc-avatar v-else :user="user" class="profile-avatar" size="x-large" />
<!-- Menu -->
<no-ssr>
<content-menu
placement="bottom-end"
resource-type="user"
:resource="user"
:callbacks="{ confirm: deletePostCallback, cancel: null }"
:is-owner="myProfile"
class="user-content-menu"
/>
@ -69,11 +69,13 @@
</ds-card>
<ds-space />
<ds-heading tag="h3" soft style="text-align: center; margin-bottom: 10px;">
Netzwerk
{{ $t('profile.network.title') }}
</ds-heading>
<ds-card style="position: relative; height: auto;">
<ds-space v-if="user.following && user.following.length" margin="x-small">
<ds-text tag="h5" color="soft">Wem folgt {{ userName | truncate(15) }}?</ds-text>
<ds-text tag="h5" color="soft">
{{ userName | truncate(15) }} {{ $t('profile.network.following') }}
</ds-text>
</ds-space>
<template v-if="user.following && user.following.length">
<ds-space v-for="follow in uniq(user.following)" :key="follow.id" margin="x-small">
@ -84,18 +86,23 @@
</ds-space>
<ds-space v-if="user.followingCount - user.following.length" margin="small">
<ds-text size="small" color="softer">
und {{ user.followingCount - user.following.length }} weitere
{{ $t('profile.network.and') }} {{ user.followingCount - user.following.length }}
{{ $t('profile.network.more') }}
</ds-text>
</ds-space>
</template>
<template v-else>
<p style="text-align: center; opacity: .5;">{{ userName }} folgt niemandem</p>
<p style="text-align: center; opacity: .5;">
{{ userName }} {{ $t('profile.network.followingNobody') }}
</p>
</template>
</ds-card>
<ds-space />
<ds-card style="position: relative; height: auto;">
<ds-space v-if="user.followedBy && user.followedBy.length" margin="x-small">
<ds-text tag="h5" color="soft">Wer folgt {{ userName | truncate(15) }}?</ds-text>
<ds-text tag="h5" color="soft">
{{ userName | truncate(15) }} {{ $t('profile.network.followedBy') }}
</ds-text>
</ds-space>
<template v-if="user.followedBy && user.followedBy.length">
<ds-space v-for="follow in uniq(user.followedBy)" :key="follow.id" margin="x-small">
@ -106,12 +113,15 @@
</ds-space>
<ds-space v-if="user.followedByCount - user.followedBy.length" margin="small">
<ds-text size="small" color="softer">
und {{ user.followedByCount - user.followedBy.length }} weitere
{{ $t('profile.network.and') }} {{ user.followedByCount - user.followedBy.length }}
{{ $t('profile.network.more') }}
</ds-text>
</ds-space>
</template>
<template v-else>
<p style="text-align: center; opacity: .5;">niemand folgt {{ userName }}</p>
<p style="text-align: center; opacity: .5;">
{{ userName }} {{ $t('profile.network.followedByNobody') }}
</p>
</template>
</ds-card>
<ds-space v-if="user.socialMedia && user.socialMedia.length" margin="large">
@ -132,69 +142,50 @@
</ds-card>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: 3, md: 5, lg: 3 }">
<ds-flex :width="{ base: '100%' }" gutter="small">
<ds-flex class="user-profile-posts-list" :width="{ base: '100%' }" gutter="small">
<ds-flex-item class="profile-top-navigation">
<ds-card class="ds-tab-nav">
<ds-flex>
<ds-flex-item
v-tooltip="{
content: $t('common.your.post', null, user.contributionsCount),
placement: 'right',
delay: { show: 500 },
}"
class="ds-tab-nav-item pointer ds-tab-nav-item-active"
@click="tabActivity('posts', $event)"
>
<ul class="Tabs">
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'post' }">
<a @click="handleTab('post')">
<ds-space margin="small">
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<no-ssr placeholder="Loading...">
<ds-number :label="$t('common.post', null, user.contributionsCount)">
<hc-count-to slot="count" :end-val="user.contributionsCount" />
</ds-number>
</no-ssr>
</ds-space>
</ds-flex-item>
<ds-flex-item
v-tooltip="{
content: $t('common.your.comment', null, user.commentsCount),
placement: 'right',
delay: { show: 500 },
}"
class="ds-tab-nav-item pointer"
@click="tabActivity('commented', $event)"
>
</a>
</li>
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'comment' }">
<a @click="handleTab('comment')">
<ds-space margin="small">
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<no-ssr placeholder="Loading...">
<ds-number :label="$t('profile.commented')">
<hc-count-to slot="count" :end-val="user.commentsCount" />
<hc-count-to slot="count" :end-val="user.commentedCount" />
</ds-number>
</no-ssr>
</ds-space>
</ds-flex-item>
<ds-flex-item
v-tooltip="{
content: $t('common.your.shouted', null, user.shoutedCount),
placement: 'right',
delay: { show: 500 },
}"
class="ds-tab-nav-item pointer"
>
</a>
</li>
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'shout' }">
<a @click="handleTab('shout')">
<ds-space margin="small">
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<no-ssr placeholder="Loading...">
<ds-number :label="$t('profile.shouted')">
<hc-count-to slot="count" :end-val="user.shoutedCount" />
</ds-number>
</no-ssr>
</ds-space>
</ds-flex-item>
</ds-flex>
</a>
</li>
<li class="Tabs__presentation-slider" role="presentation"></li>
</ul>
</ds-card>
</ds-flex-item>
<ds-flex-item style="text-align: center">
<ds-button
v-if="myProfile"
@ -206,15 +197,23 @@
primary
/>
</ds-flex-item>
<template v-if="activePosts.length">
<hc-post-card
v-for="(post, index) in activePosts"
:key="post.id"
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
@deletePost="user.contributions.splice(index, 1)"
@removePostFromList="activePosts.splice(index, 1)"
/>
</template>
<template v-else-if="$apollo.loading">
<ds-flex-item>
<ds-section centered>
<ds-spinner size="base"></ds-spinner>
</ds-section>
</ds-flex-item>
</template>
<template v-else>
<ds-flex-item :width="{ base: '100%' }">
<hc-empty margin="xx-large" icon="file" />
@ -229,7 +228,6 @@
<script>
import uniqBy from 'lodash/uniqBy'
import User from '~/components/User'
import HcPostCard from '~/components/PostCard'
import HcFollowButton from '~/components/FollowButton.vue'
@ -240,9 +238,19 @@ import HcEmpty from '~/components/Empty.vue'
import ContentMenu from '~/components/ContentMenu'
import HcUpload from '~/components/Upload'
import HcAvatar from '~/components/Avatar/Avatar.vue'
import PostMutationHelpers from '~/mixins/PostMutationHelpers'
import PostQuery from '~/graphql/UserProfile/Post.js'
import UserQuery from '~/graphql/UserProfile/User.js'
const tabToFilterMapping = ({ tab, id }) => {
return {
post: { author: { id } },
comment: { comments_some: { author: { id } } },
shout: { shoutedBy_some: { id } },
}[tab]
}
export default {
name: 'HcUserProfile',
components: {
User,
HcPostCard,
@ -255,22 +263,34 @@ export default {
ContentMenu,
HcUpload,
},
mixins: [PostMutationHelpers],
transition: {
name: 'slide-up',
mode: 'out-in',
},
data() {
const filter = tabToFilterMapping({ tab: 'post', id: this.$route.params.id })
return {
User: [],
Post: [],
activePosts: [],
voted: false,
page: 1,
pageSize: 6,
tabActive: 'post',
filter,
}
},
computed: {
hasMore() {
const total = {
post: this.user.contributionsCount,
shout: this.user.shoutedCount,
comment: this.user.commentedCount,
}[this.tabActive]
return this.Post && this.Post.length < total
},
myProfile() {
return this.$route.params.slug === this.$store.getters['auth/user'].slug
return this.$route.params.id === this.$store.getters['auth/user'].id
},
followedByCount() {
let count = Number(this.user.followedByCount) || 0
@ -282,17 +302,6 @@ export default {
offset() {
return (this.page - 1) * this.pageSize
},
hasMore() {
return (
this.user.contributions && this.user.contributions.length < this.user.contributionsCount
)
},
activePosts() {
if (!this.user.contributions) {
return []
}
return this.uniq(this.user.contributions.filter(post => !post.deleted))
},
socialMediaLinks() {
const { socialMedia = [] } = this.user
return socialMedia.map(socialMedia => {
@ -315,8 +324,16 @@ export default {
throw new Error('User not found!')
}
},
Post(val) {
this.activePosts = this.setActivePosts()
},
},
methods: {
handleTab(tab) {
this.tabActive = tab
this.Post = null
this.filter = tabToFilterMapping({ tab, id: this.$route.params.id })
},
uniq(items, field = 'id') {
return uniqBy(items, field)
},
@ -328,39 +345,51 @@ export default {
// this.page++
// Fetch more data and transform the original result
this.page++
this.$apollo.queries.User.fetchMore({
this.$apollo.queries.Post.fetchMore({
variables: {
slug: this.$route.params.slug,
filter: this.filter,
first: this.pageSize,
offset: this.offset,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
let output = { User: this.User }
output.User[0].contributions = [
...previousResult.User[0].contributions,
...fetchMoreResult.User[0].contributions,
]
let output = { Post: this.Post }
output.Post = [...previousResult.Post, ...fetchMoreResult.Post]
return output
},
fetchPolicy: 'cache-and-network',
})
},
setActivePosts() {
if (!this.Post) {
return []
}
return this.uniq(this.Post.filter(post => !post.deleted))
},
},
apollo: {
User: {
Post: {
query() {
return require('~/graphql/UserProfileQuery.js').default(this)
return PostQuery(this.$i18n)
},
variables() {
return {
slug: this.$route.params.slug,
filter: this.filter,
first: this.pageSize,
offset: 0,
}
},
fetchPolicy: 'cache-and-network',
},
User: {
query() {
return UserQuery(this.$i18n)
},
variables() {
return { id: this.$route.params.id }
},
fetchPolicy: 'cache-and-network',
},
},
}
</script>
@ -369,18 +398,54 @@ export default {
.pointer {
cursor: pointer;
}
.ds-tab-nav .ds-card-content .ds-tab-nav-item:hover {
border-bottom: 3px solid #c9c6ce;
.Tab {
border-collapse: collapse;
padding-bottom: 5px;
}
.Tab:hover {
border-bottom: 2px solid #c9c6ce;
}
.Tabs {
position: relative;
background-color: #fff;
&:after {
content: ' ';
display: table;
clear: both;
}
margin: 0;
padding: 0;
list-style: none;
&__tab {
float: left;
width: 33.333%;
text-align: center;
&:first-child.active ~ .Tabs__presentation-slider {
left: 0;
}
&:nth-child(2).active ~ .Tabs__presentation-slider {
left: 33.333%;
}
&:nth-child(3).active ~ .Tabs__presentation-slider {
left: calc(33.333% * 2);
}
}
&__presentation-slider {
position: absolute;
bottom: 0;
left: 0;
width: 33.333%;
height: 2px;
background-color: #17b53f;
transition: left 0.25s;
}
}
.profile-avatar.ds-avatar {
display: block;
margin: auto;
margin-top: -60px;
border: #fff 5px solid;
}
.page-name-profile-id-slug {
.ds-flex-item:first-child .content-menu {
position: absolute;
@ -388,17 +453,14 @@ export default {
right: $space-x-small;
}
}
.profile-top-navigation {
position: sticky;
top: 53px;
z-index: 2;
}
.ds-tab-nav {
.ds-card-content {
padding: 0 !important;
.ds-tab-nav-item {
&.ds-tab-nav-item-active {
border-bottom: 3px solid #17b53f;

View File

@ -1,8 +1,6 @@
<template>
<div>
<ds-heading tag="h1">
{{ $t('settings.name') }}
</ds-heading>
<ds-heading tag="h1">{{ $t('settings.name') }}</ds-heading>
<ds-flex gutter="small">
<ds-flex-item :width="{ base: '100%', md: '200px' }">
<ds-menu :routes="routes" :is-exact="() => true" />
@ -33,6 +31,10 @@ export default {
name: this.$t('settings.social-media.name'),
path: `/settings/my-social-media`,
},
{
name: this.$t('settings.deleteUserAccount.name'),
path: `/settings/delete-account`,
},
// TODO implement
/* {
name: this.$t('settings.invites.name'),
@ -44,10 +46,6 @@ export default {
path: `/settings/data-download`
}, */
// TODO implement
/* {
name: this.$t('settings.delete.name'),
path: `/settings/delete-account`
}, */
// TODO implement
/* {
name: this.$t('settings.organizations.name'),

View File

@ -1,15 +1,13 @@
<template>
<ds-card :header="$t('settings.delete.name')">
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
<delete-data />
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import DeleteData from '~/components/DeleteData/DeleteData.vue'
export default {
components: {
HcEmpty,
DeleteData,
},
}
</script>

View File

@ -2,11 +2,13 @@ import { mount, createLocalVue } from '@vue/test-utils'
import MySocialMedia from './my-social-media.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
describe('my-social-media.vue', () => {
let wrapper

View File

@ -4,12 +4,7 @@
<ds-list>
<ds-list-item v-for="link in socialMediaLinks" :key="link.id">
<a :href="link.url" target="_blank">
<hc-image
:image-props="{ src: link.favicon }"
alt="Social Media link"
width="16"
height="16"
/>
<img :src="link.favicon | proxyApiUrl" alt="Social Media link" width="16" height="16" />
{{ link.url }}
</a>
&nbsp;&nbsp;
@ -44,12 +39,8 @@
<script>
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex'
import HcImage from '~/components/Image'
export default {
components: {
HcImage,
},
data() {
return {
value: '',

View File

@ -93,6 +93,10 @@ export default ({ app = {} }) => {
return excerpt
},
proxyApiUrl: url => {
if (!url) return url
return url.startsWith('/') ? url.replace('/', '/api/') : url
},
})
// add all methods as filters on each vue component

View File

@ -79,6 +79,8 @@ export const actions = {
role
about
locationName
contributionsCount
commentsCount
socialMedia {
id
url

View File

@ -1684,6 +1684,13 @@
"@webassemblyjs/wast-parser" "1.8.5"
"@xtuc/long" "4.2.2"
"@wry/equality@^0.1.2":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.7.tgz#512234d078341c32cabda66b89b5dddb5741d9b9"
integrity sha512-p1rhJ6PQzpsBr9cMJMHvvx3LQEA28HFX7fAQx6khAX+1lufFeBuk+iRCAyHwj3v6JbpGKvHNa66f+9cpU8c7ew==
dependencies:
tslib "^1.9.3"
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@ -1878,23 +1885,23 @@ apollo-cache-inmemory@^1.5.1, apollo-cache-inmemory@~1.5.1:
ts-invariant "^0.2.1"
tslib "^1.9.3"
apollo-cache@1.3.1, apollo-cache@^1.2.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.1.tgz#c015f93a9a7f32b3eeea0c471addd6e854da754c"
integrity sha512-BJ/Mehr3u6XCaHYSmgZ6DM71Fh30OkW6aEr828WjHvs+7i0RUuP51/PM7K6T0jPXtuw7UbArFFPZZsNgXnyyJA==
apollo-cache@1.3.2, apollo-cache@^1.2.1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.2.tgz#df4dce56240d6c95c613510d7e409f7214e6d26a"
integrity sha512-+KA685AV5ETEJfjZuviRTEImGA11uNBp/MJGnaCvkgr+BYRrGLruVKBv6WvyFod27WEB2sp7SsG8cNBKANhGLg==
dependencies:
apollo-utilities "^1.3.1"
apollo-utilities "^1.3.2"
tslib "^1.9.3"
apollo-client@^2.5.1, apollo-client@~2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.1.tgz#fcf328618d6ad82b750a988bec113fe6edc8ba94"
integrity sha512-Tb6ZthPZUHlGqeoH1WC8Qg/tLnkk9H5+xj4e5nzOAC6dCOW3pVU9tYXscrWdmZ65UDUg1khvTNjrQgPhdf4aTQ==
apollo-client@^2.5.1, apollo-client@~2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.2.tgz#03b6af651e09b6e413e486ddc87464c85bd6e514"
integrity sha512-oks1MaT5x7gHcPeC8vPC1UzzsKaEIC0tye+jg72eMDt5OKc7BobStTeS/o2Ib3e0ii40nKxGBnMdl/Xa/p56Yg==
dependencies:
"@types/zen-observable" "^0.8.0"
apollo-cache "1.3.1"
apollo-cache "1.3.2"
apollo-link "^1.0.0"
apollo-utilities "1.3.1"
apollo-utilities "1.3.2"
symbol-observable "^1.0.2"
ts-invariant "^0.4.0"
tslib "^1.9.3"
@ -2081,13 +2088,13 @@ apollo-upload-client@^10.0.0:
apollo-link-http-common "^0.2.13"
extract-files "^5.0.1"
apollo-utilities@1.3.1, apollo-utilities@^1.0.1, apollo-utilities@^1.0.8, apollo-utilities@^1.2.1, apollo-utilities@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.1.tgz#4c45f9b52783c324e2beef822700bdea374f82d1"
integrity sha512-P5cJ75rvhm9hcx9V/xCW0vlHhRd0S2icEcYPoRYNTc5djbynpuO+mQuJ4zMHgjNDpvvDxDfZxXTJ6ZUuJZodiQ==
apollo-utilities@1.3.2, apollo-utilities@^1.0.1, apollo-utilities@^1.0.8, apollo-utilities@^1.2.1, apollo-utilities@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9"
integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg==
dependencies:
"@wry/equality" "^0.1.2"
fast-json-stable-stringify "^2.0.0"
lodash.isequal "^4.5.0"
ts-invariant "^0.4.0"
tslib "^1.9.3"
@ -3298,18 +3305,18 @@ cookie-signature@1.0.6:
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
cookie-universal-nuxt@~2.0.14:
version "2.0.14"
resolved "https://registry.yarnpkg.com/cookie-universal-nuxt/-/cookie-universal-nuxt-2.0.14.tgz#6fdf8e928eadd7611c04a57614fe2e29b60eb971"
integrity sha512-ih9Z0Z2K6eLaugTttGCVN85nogKseIFF/dqup3klvYC4mQS3+1IloqBqzTL/N7degBBAols2oppwYNDmaRtVig==
cookie-universal-nuxt@~2.0.16:
version "2.0.16"
resolved "https://registry.yarnpkg.com/cookie-universal-nuxt/-/cookie-universal-nuxt-2.0.16.tgz#8d528098c973162b352199240e40da0e5429b13f"
integrity sha512-wRK2zw8w+a5xPehb5kLbgOic/4mbjl2exUCxWZwGuttcwsFgOymiwDrCOzmQslqrDevPDL2SsBbH6wtOm7dB9g==
dependencies:
"@types/cookie" "^0.3.1"
cookie-universal "^2.0.14"
cookie-universal "^2.0.16"
cookie-universal@^2.0.14:
version "2.0.14"
resolved "https://registry.yarnpkg.com/cookie-universal/-/cookie-universal-2.0.14.tgz#1b4f27cffccfc2e47703fa235c1f67f931213041"
integrity sha512-m6J0DQa4/RQvXhzUG37EY1ynK3Uq1BKzp5hotST9olrzjrRx+B0vNPx7azg0/X0XrYQvL7MMbPXwou8m0BNDwg==
cookie-universal@^2.0.16:
version "2.0.16"
resolved "https://registry.yarnpkg.com/cookie-universal/-/cookie-universal-2.0.16.tgz#ec8b55789b502a377ef02ad230923c1dfa5c1061"
integrity sha512-EHtQ5Tg3UoUHG7LmeV3rlV3iYthkhUuYZ0y86EseypxGcUuvzxuHExEb6mHKDhDPrIrdewAHdG/aCHuG/T4zEg==
dependencies:
"@types/cookie" "^0.3.1"
cookie "^0.3.1"
@ -3747,10 +3754,10 @@ data-urls@^1.0.0:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
date-fns@2.0.0-alpha.29:
version "2.0.0-alpha.29"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.29.tgz#9d4a36e3ebba63d009e957fea8fdfef7921bc6cb"
integrity sha512-AIFZ0hG/1fdb7HZHTDyiEJdNiaFyZxXcx/kF8z3I9wxbhkN678KrrLSneKcsb0Xy5KqCA4wCIxmGpdVWSNZnpA==
date-fns@2.0.0-alpha.33:
version "2.0.0-alpha.33"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.33.tgz#c2f73c3cc50ac301c9217eb93603c9bc40e891bf"
integrity sha512-tqUVEk3oxnJuNIvwAMKHAMo4uFRG0zXvjxZQll+BonoPt+m4NMcUgO14NDxbHuy7uYcrVErd2GdSsw02EDZQ7w==
date-now@^0.1.4:
version "0.1.4"
@ -6851,11 +6858,6 @@ lodash.isboolean@^3.0.3:
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
@ -8857,15 +8859,10 @@ prettier@1.16.3:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.16.3.tgz#8c62168453badef702f34b45b6ee899574a6a65d"
integrity sha512-kn/GU6SMRYPxUakNXhpP0EedT/KmaPzr0H5lIsDogrykbaxOpOfAFfk5XA7DZrJyMAv1wlMV3CPcZruGXVVUZw==
prettier@^1.15.2:
version "1.17.0"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.17.0.tgz#53b303676eed22cc14a9f0cec09b477b3026c008"
integrity sha512-sXe5lSt2WQlCbydGETgfm1YBShgOX4HxQkFPvbxkcwgDvGDeqVau8h+12+lmSVlP3rHPz0oavfddSZg/q+Szjw==
prettier@~1.17.1:
version "1.17.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.17.1.tgz#ed64b4e93e370cb8a25b9ef7fef3e4fd1c0995db"
integrity sha512-TzGRNvuUSmPgwivDqkZ9tM/qTGW9hqDKWOE9YHiyQdixlKbv7kvEqsmDPrcHJTKwthU774TQwZXVtaQ/mMsvjg==
prettier@^1.15.2, prettier@~1.18.2:
version "1.18.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.18.2.tgz#6823e7c5900017b4bd3acf46fe9ac4b4d7bda9ea"
integrity sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==
pretty-bytes@^5.2.0:
version "5.2.0"
@ -10561,10 +10558,10 @@ timsort@^0.3.0:
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
tippy.js@^4.3.3:
version "4.3.3"
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-4.3.3.tgz#396304bea577bbff03f2700a1761329e8c1fce86"
integrity sha512-2fPMlquzVQxpLoOd0eJA1sPZ86/R6zD/9985wV0d2zhhX52DiO3aeg7TTS/mBrUjgFwVZh19YLb4l2c8bJkQPw==
tippy.js@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-4.3.4.tgz#9a91fd5ce8c401f181b7adaa6b2c27f3d105f3ba"
integrity sha512-O2ukxHOJTLVYZ/TfHjNd8WgAWoefX9uk5QiWRdHfX2PR2lBpUU4BJQLl7U2Ykc8K7o16gTeHEElpuRfgD5b0aA==
dependencies:
popper.js "^1.14.7"
@ -10582,10 +10579,10 @@ tiptap-commands@^1.10.5, tiptap-commands@^1.10.7:
prosemirror-utils "^0.9.0"
tiptap-utils "^1.5.5"
tiptap-extensions@1.21.0:
version "1.21.0"
resolved "https://registry.yarnpkg.com/tiptap-extensions/-/tiptap-extensions-1.21.0.tgz#c2f228144b6943755d2de4617e11febe08c78a42"
integrity sha512-dIm9Q/G1qL1+sEqQiPXElUzSBcRhXXPuyOdtHgMrncUaCbnaDxsHdkJl700OXfA/GCu7AlhUpd3R67Rmb+voCQ==
tiptap-extensions@1.22.2:
version "1.22.2"
resolved "https://registry.yarnpkg.com/tiptap-extensions/-/tiptap-extensions-1.22.2.tgz#66b24ef63653481da16419efdf6459c7a9f518f7"
integrity sha512-JJe2yAnbaKBWO/16+lfFphePphG9UYhp3wAUkzhSefJx3dtIdDfCmp5jBzZ6NLahUHNT/Z9WeYdhQmngAeVhoA==
dependencies:
lowlight "^1.12.1"
prosemirror-collab "^1.1.2"
@ -10596,7 +10593,7 @@ tiptap-extensions@1.21.0:
prosemirror-transform "^1.1.3"
prosemirror-utils "^0.9.0"
prosemirror-view "^1.9.8"
tiptap "^1.21.1"
tiptap "^1.22.2"
tiptap-commands "^1.10.7"
tiptap-utils@^1.5.3:
@ -10635,10 +10632,10 @@ tiptap@1.20.1:
tiptap-commands "^1.10.5"
tiptap-utils "^1.5.3"
tiptap@^1.21.1:
version "1.21.1"
resolved "https://registry.yarnpkg.com/tiptap/-/tiptap-1.21.1.tgz#c0340375795088b899541b64ce86ae45e98d0369"
integrity sha512-vCKT/UGorAx1SSX5+9vmtZa+WC+LKfJIArgkEJFXmxfZeyBhNXRSwR+rR+UtdPYi8V4CtmIwtv6eRPP+bH6SWA==
tiptap@^1.22.2:
version "1.22.2"
resolved "https://registry.yarnpkg.com/tiptap/-/tiptap-1.22.2.tgz#f3f2b822d9ed087a853520c86593b2e6bd822bdd"
integrity sha512-qMFQJ358Ga8gXzUAMaHGrZDab/IqZf28N8BYYo4hyFIMoEYQVWmBGWuSBHOgpxiueXpC9zMuCoIgrNWN3VpoNQ==
dependencies:
prosemirror-commands "^1.0.8"
prosemirror-dropcursor "^1.1.1"

View File

@ -1805,10 +1805,10 @@ cucumber@^4.2.1:
util-arity "^1.0.2"
verror "^1.9.0"
cypress-cucumber-preprocessor@^1.11.2:
version "1.11.2"
resolved "https://registry.yarnpkg.com/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.11.2.tgz#daa86805e25a39cea1cf2278f3b7cee204478853"
integrity sha512-Cret/EmqGdC6QLUQrszDdzDt+y4aL0ViaOWfZ1PgM4GpAay4gHQ+j0mtTIBvRg8Y86w6NOfzaflcHKGk54v2XQ==
cypress-cucumber-preprocessor@^1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.12.0.tgz#092428ba267331e3d2cc6e1309c331d17632b8b1"
integrity sha512-uKrWbs51hGeHiLgcSZcjFvvVEW9UdStsLVpD1snuPuik9WE61kbZv7xumlPjRmkMF81zTUGnNLwZuAk3CV9dEw==
dependencies:
"@cypress/browserify-preprocessor" "^1.1.2"
chai "^4.1.2"
@ -1822,10 +1822,10 @@ cypress-cucumber-preprocessor@^1.11.2:
glob "^7.1.2"
through "^2.3.8"
cypress-file-upload@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-3.1.2.tgz#4a0024f99ca157565bf2b20c110e6e6874da28cb"
integrity sha512-gZE2G7ZTD2Y8APrcgs+ATRMKs/IgH2rafCmi+8o99q5sDoNRLR+XKxOcoyWLehj9raGnO98YDYO8DY7k1VMGBw==
cypress-file-upload@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-3.1.4.tgz#cc208cb937a3abb136b52309eaf4637d5676c5bd"
integrity sha512-4aZeJOYFhYiP+nk9Mo5YHWqComsT24J9OBQVJzvkEzw7g1v2ogGe7nLT/U7Fsm/Xjl1Tyxsc0xxECa254WfQqg==
cypress-plugin-retries@^1.2.2:
version "1.2.2"