Merge branch 'master' of https://github.com/Human-Connection/Human-Connection into C-1187-terms-and-conditions-confirmed-function

This commit is contained in:
ogerly 2019-08-28 14:21:46 +02:00
commit ee03f0e902
59 changed files with 1468 additions and 892 deletions

View File

@ -18,6 +18,7 @@ install:
- docker-compose -f docker-compose.yml -f docker-compose.travis.yml up --build -d
# avoid "Database constraints have changed after this transaction started"
- wait-on http://localhost:7474
- docker-compose exec neo4j db_setup
script:
- export CYPRESS_RETRIES=1

View File

@ -49,24 +49,24 @@
"apollo-client": "~2.6.4",
"apollo-link-context": "~1.0.18",
"apollo-link-http": "~1.5.15",
"apollo-server": "~2.8.2",
"apollo-server-express": "^2.8.1",
"apollo-server": "~2.9.0",
"apollo-server-express": "^2.9.0",
"babel-plugin-transform-runtime": "^6.23.0",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~5.2.0",
"date-fns": "2.0.0",
"date-fns": "2.0.1",
"debug": "~4.1.1",
"dotenv": "~8.1.0",
"express": "^4.17.1",
"faker": "Marak/faker.js#master",
"graphql": "^14.5.0",
"graphql": "^14.5.3",
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.5",
"graphql-middleware-sentry": "^3.2.0",
"graphql-shield": "~6.0.5",
"graphql-shield": "~6.0.6",
"graphql-tag": "~2.10.1",
"helmet": "~3.20.0",
"jsonwebtoken": "~8.5.1",
@ -110,15 +110,15 @@
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.5.5",
"@babel/register": "~7.5.5",
"apollo-server-testing": "~2.8.2",
"apollo-server-testing": "~2.9.0",
"babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.2",
"babel-eslint": "~10.0.3",
"babel-jest": "~24.9.0",
"chai": "~4.2.0",
"cucumber": "~5.1.0",
"eslint": "~6.2.1",
"eslint-config-prettier": "~6.1.0",
"eslint-config-standard": "~14.0.0",
"eslint-config-standard": "~14.0.1",
"eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.15.2",
"eslint-plugin-node": "~9.1.0",

View File

@ -1,96 +0,0 @@
import extractMentionedUsers from './notifications/extractMentionedUsers'
import extractHashtags from './hashtags/extractHashtags'
const notifyMentions = async (label, id, idsOfMentionedUsers, context) => {
if (!idsOfMentionedUsers.length) return
const session = context.driver.session()
const createdAt = new Date().toISOString()
let cypher
if (label === 'Post') {
cypher = `
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfMentionedUsers
AND NOT (user)<-[:BLOCKED]-(author)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt })
MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
`
} else {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfMentionedUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt })
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
`
}
await session.run(cypher, {
idsOfMentionedUsers,
label,
createdAt,
id,
})
session.close()
}
const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return
const session = context.driver.session()
// We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement
// functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted
// and no new Hashtags and relations will be created.
const cypherDeletePreviousRelations = `
MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag)
DELETE previousRelations
RETURN p, t
`
const cypherCreateNewTagsAndRelations = `
MATCH (p: Post { id: $postId})
UNWIND $hashtags AS tagName
MERGE (t: Tag { id: tagName, disabled: false, deleted: false })
MERGE (p)-[:TAGGED]->(t)
RETURN p, t
`
await session.run(cypherDeletePreviousRelations, {
postId,
})
await session.run(cypherCreateNewTagsAndRelations, {
postId,
hashtags,
})
session.close()
}
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfMentionedUsers = extractMentionedUsers(args.content)
const hashtags = extractHashtags(args.content)
const post = await resolve(root, args, context, resolveInfo)
await notifyMentions('Post', post.id, idsOfMentionedUsers, context)
await updateHashtagsOfPost(post.id, hashtags, context)
return post
}
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
const idsOfMentionedUsers = extractMentionedUsers(args.content)
const comment = await resolve(root, args, context, resolveInfo)
await notifyMentions('Comment', comment.id, idsOfMentionedUsers, context)
return comment
}
export default {
Mutation: {
CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost,
CreateComment: handleContentDataOfComment,
UpdateComment: handleContentDataOfComment,
},
}

View File

@ -1,392 +0,0 @@
import { gql } from '../../jest/helpers'
import Factory from '../../seed/factories'
import { createTestClient } from 'apollo-server-testing'
import { neode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
let server
let query
let mutate
let user
let authenticatedUser
const factory = Factory()
const driver = getDriver()
const instance = neode()
const categoryIds = ['cat9']
const createPostMutation = gql`
mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]!) {
CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
id
title
content
}
}
`
const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]!) {
UpdatePost(id: $id, content: $content, title: $title, categoryIds: $categoryIds) {
title
content
}
}
`
beforeAll(() => {
const createServerResult = createServer({
context: () => {
return {
user: authenticatedUser,
neode: instance,
driver,
}
},
})
server = createServerResult.server
const createTestClientResult = createTestClient(server)
query = createTestClientResult.query
mutate = createTestClientResult.mutate
})
beforeEach(async () => {
user = await instance.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234',
})
await instance.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('notifications', () => {
const notificationQuery = gql`
query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
read
post {
content
}
comment {
content
}
}
}
}
`
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = user
})
describe('given another user', () => {
let postAuthor
beforeEach(async () => {
postAuthor = await instance.create('User', {
email: 'post-author@example.org',
password: '1234',
id: 'postAuthor',
})
})
describe('who mentions me in a post', () => {
const title = 'Mentioning Al Capone'
const content =
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
const createPostAction = async () => {
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: createPostMutation,
variables: { id: 'p47', title, content, categoryIds },
})
authenticatedUser = await user.toJson()
}
it('sends you a notification', async () => {
await createPostAction()
const expectedContent =
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
post: {
content: expectedContent,
},
comment: null,
},
],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
describe('who mentions me many times', () => {
const updatePostAction = async () => {
const updatedContent = `
One more mention to
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
and again:
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
and again
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
`
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: updatePostMutation,
variables: {
id: 'p47',
title,
content: updatedContent,
categoryIds,
},
})
authenticatedUser = await user.toJson()
}
it('creates exactly one more notification', async () => {
await createPostAction()
await updatePostAction()
const expectedContent =
'<br>One more mention to<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again:<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>'
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
post: {
content: expectedContent,
},
comment: null,
},
{
read: false,
post: {
content: expectedContent,
},
comment: null,
},
],
},
},
})
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
describe('but the author of the post blocked me', () => {
beforeEach(async () => {
await postAuthor.relateTo(user, 'blocked')
})
it('sends no notification', async () => {
await createPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
describe('but the author of the post blocked me and a mentioner mentions me in a comment', () => {
const createCommentOnPostAction = async () => {
await createPostAction()
const createCommentMutation = gql`
mutation($id: ID, $postId: ID!, $commentContent: String!) {
CreateComment(id: $id, postId: $postId, content: $commentContent) {
id
content
}
}
`
authenticatedUser = await commentMentioner.toJson()
await mutate({
mutation: createCommentMutation,
variables: {
id: 'c47',
postId: 'p47',
commentContent:
'One mention of me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">.',
},
})
authenticatedUser = await user.toJson()
}
let commentMentioner
beforeEach(async () => {
await postAuthor.relateTo(user, 'blocked')
commentMentioner = await instance.create('User', {
id: 'mentioner',
name: 'Mr Mentioner',
slug: 'mr-mentioner',
email: 'mentioner@example.org',
password: '1234',
})
})
it('sends no notification', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
})
})
})
})
describe('Hashtags', () => {
const id = 'p135'
const title = 'Two Hashtags'
const content =
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Democracy">#Democracy</a> should work equal for everybody!? That seems to be the only way to have equal <a class="hashtag" href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
const postWithHastagsQuery = gql`
query($id: ID) {
Post(id: $id) {
tags {
id
}
}
}
`
const postWithHastagsVariables = {
id,
}
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await user.toJson()
})
describe('create a Post with Hashtags', () => {
beforeEach(async () => {
await mutate({
mutation: createPostMutation,
variables: {
id,
title,
content,
categoryIds,
},
})
})
it('both Hashtags are created with the "id" set to their "name"', async () => {
const expected = [{ id: 'Democracy' }, { id: 'Liberty' }]
await expect(
query({
query: postWithHastagsQuery,
variables: postWithHastagsVariables,
}),
).resolves.toEqual(
expect.objectContaining({
data: {
Post: [
{
tags: expect.arrayContaining(expected),
},
],
},
}),
)
})
describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => {
// The already existing Hashtag has no class at this point.
const content =
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Elections">#Elections</a> should work equal for everybody!? That seems to be the only way to have equal <a href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
it('only one previous Hashtag and the new Hashtag exists', async () => {
await mutate({
mutation: updatePostMutation,
variables: {
id,
title,
content,
categoryIds,
},
})
const expected = [{ id: 'Elections' }, { id: 'Liberty' }]
await expect(
query({
query: postWithHastagsQuery,
variables: postWithHastagsVariables,
}),
).resolves.toEqual(
expect.objectContaining({
data: {
Post: [
{
tags: expect.arrayContaining(expected),
},
],
},
}),
)
})
})
})
})
})

View File

@ -0,0 +1,49 @@
import extractHashtags from '../hashtags/extractHashtags'
const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return
const session = context.driver.session()
// We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement
// functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted
// and no new Hashtags and relations will be created.
const cypherDeletePreviousRelations = `
MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag)
DELETE previousRelations
RETURN p, t
`
const cypherCreateNewTagsAndRelations = `
MATCH (p: Post { id: $postId})
UNWIND $hashtags AS tagName
MERGE (t: Tag { id: tagName, disabled: false, deleted: false })
MERGE (p)-[:TAGGED]->(t)
RETURN p, t
`
await session.run(cypherDeletePreviousRelations, {
postId,
})
await session.run(cypherCreateNewTagsAndRelations, {
postId,
hashtags,
})
session.close()
}
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const hashtags = extractHashtags(args.content)
const post = await resolve(root, args, context, resolveInfo)
if (post) {
await updateHashtagsOfPost(post.id, hashtags, context)
}
return post
}
export default {
Mutation: {
CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost,
},
}

View File

@ -0,0 +1,176 @@
import { gql } from '../../jest/helpers'
import Factory from '../../seed/factories'
import { createTestClient } from 'apollo-server-testing'
import { neode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
let server
let query
let mutate
let hashtagingUser
let authenticatedUser
const factory = Factory()
const driver = getDriver()
const instance = neode()
const categoryIds = ['cat9']
const createPostMutation = gql`
mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) {
id
title
content
}
}
`
const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) {
title
content
}
}
`
beforeAll(() => {
const createServerResult = createServer({
context: () => {
return {
user: authenticatedUser,
neode: instance,
driver,
}
},
})
server = createServerResult.server
const createTestClientResult = createTestClient(server)
query = createTestClientResult.query
mutate = createTestClientResult.mutate
})
beforeEach(async () => {
hashtagingUser = await instance.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234',
})
await instance.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('hashtags', () => {
const id = 'p135'
const title = 'Two Hashtags'
const postContent =
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Democracy">#Democracy</a> should work equal for everybody!? That seems to be the only way to have equal <a class="hashtag" href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
const postWithHastagsQuery = gql`
query($id: ID) {
Post(id: $id) {
tags {
id
}
}
}
`
const postWithHastagsVariables = {
id,
}
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await hashtagingUser.toJson()
})
describe('create a Post with Hashtags', () => {
beforeEach(async () => {
await mutate({
mutation: createPostMutation,
variables: {
id,
title,
postContent,
categoryIds,
},
})
})
it('both hashtags are created with the "id" set to their "name"', async () => {
const expected = [
{
id: 'Democracy',
},
{
id: 'Liberty',
},
]
await expect(
query({
query: postWithHastagsQuery,
variables: postWithHastagsVariables,
}),
).resolves.toEqual(
expect.objectContaining({
data: {
Post: [
{
tags: expect.arrayContaining(expected),
},
],
},
}),
)
})
describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => {
// The already existing Hashtag has no class at this point.
const postContent =
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Elections">#Elections</a> should work equal for everybody!? That seems to be the only way to have equal <a href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
it('only one previous Hashtag and the new Hashtag exists', async () => {
await mutate({
mutation: updatePostMutation,
variables: {
id,
title,
postContent,
categoryIds,
},
})
const expected = [
{
id: 'Elections',
},
{
id: 'Liberty',
},
]
await expect(
query({
query: postWithHastagsQuery,
variables: postWithHastagsVariables,
}),
).resolves.toEqual(
expect.objectContaining({
data: {
Post: [
{
tags: expect.arrayContaining(expected),
},
],
},
}),
)
})
})
})
})
})

View File

@ -12,26 +12,30 @@ import user from './userMiddleware'
import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware'
import validation from './validation/validationMiddleware'
import handleContentData from './handleHtmlContent/handleContentData'
import notifications from './notifications/notificationsMiddleware'
import hashtags from './hashtags/hashtagsMiddleware'
import email from './email/emailMiddleware'
import sentry from './sentryMiddleware'
export default schema => {
const middlewares = {
permissions: permissions,
sentry: sentry,
activityPub: activityPub,
dateTime: dateTime,
validation: validation,
sluggify: sluggify,
excerpt: excerpt,
handleContentData: handleContentData,
xss: xss,
softDelete: softDelete,
user: user,
includedFields: includedFields,
orderBy: orderBy,
email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }),
permissions,
sentry,
activityPub,
dateTime,
validation,
sluggify,
excerpt,
notifications,
hashtags,
xss,
softDelete,
user,
includedFields,
orderBy,
email: email({
isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT,
}),
}
let order = [
@ -43,7 +47,8 @@ export default schema => {
'sluggify',
'excerpt',
'email',
'handleContentData',
'notifications',
'hashtags',
'xss',
'softDelete',
'user',

View File

@ -0,0 +1,122 @@
import extractMentionedUsers from './mentions/extractMentionedUsers'
const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
if (!idsOfUsers.length) return
// Checked here, because it does not go through GraphQL checks at all in this file.
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post']
if (!reasonsAllowed.includes(reason)) {
throw new Error('Notification reason is not allowed!')
}
if (
(label === 'Post' && reason !== 'mentioned_in_post') ||
(label === 'Comment' && !['mentioned_in_comment', 'comment_on_post'].includes(reason))
) {
throw new Error('Notification does not fit the reason!')
}
const session = context.driver.session()
const createdAt = new Date().toISOString()
let cypher
switch (reason) {
case 'mentioned_in_post': {
cypher = `
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
`
break
}
case 'mentioned_in_comment': {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
`
break
}
case 'comment_on_post': {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (author)<-[:BLOCKED]-(user)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
`
break
}
}
await session.run(cypher, {
label,
id,
idsOfUsers,
reason,
createdAt,
})
session.close()
}
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content)
const post = await resolve(root, args, context, resolveInfo)
if (post) {
await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
}
return post
}
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content)
const comment = await resolve(root, args, context, resolveInfo)
if (comment) {
await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
}
return comment
}
const handleCreateComment = async (resolve, root, args, context, resolveInfo) => {
const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo)
if (comment) {
const session = context.driver.session()
const cypherFindUser = `
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN user { .id }
`
const result = await session.run(cypherFindUser, {
commentId: comment.id,
})
session.close()
const [postAuthor] = await result.records.map(record => {
return record.get('user')
})
if (context.user.id !== postAuthor.id) {
await notifyUsers('Comment', comment.id, [postAuthor.id], 'comment_on_post', context)
}
}
return comment
}
export default {
Mutation: {
CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost,
CreateComment: handleCreateComment,
UpdateComment: handleContentDataOfComment,
},
}

View File

@ -0,0 +1,463 @@
import { gql } from '../../jest/helpers'
import Factory from '../../seed/factories'
import { createTestClient } from 'apollo-server-testing'
import { neode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
let server
let query
let mutate
let notifiedUser
let authenticatedUser
const factory = Factory()
const driver = getDriver()
const instance = neode()
const categoryIds = ['cat9']
const createPostMutation = gql`
mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) {
id
title
content
}
}
`
const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) {
title
content
}
}
`
const createCommentMutation = gql`
mutation($id: ID, $postId: ID!, $commentContent: String!) {
CreateComment(id: $id, postId: $postId, content: $commentContent) {
id
content
}
}
`
beforeAll(() => {
const createServerResult = createServer({
context: () => {
return {
user: authenticatedUser,
neode: instance,
driver,
}
},
})
server = createServerResult.server
const createTestClientResult = createTestClient(server)
query = createTestClientResult.query
mutate = createTestClientResult.mutate
})
beforeEach(async () => {
notifiedUser = await instance.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234',
})
await instance.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('notifications', () => {
const notificationQuery = gql`
query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
read
reason
post {
content
}
comment {
content
}
}
}
}
`
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await notifiedUser.toJson()
})
describe('given another user', () => {
let title
let postContent
let postAuthor
const createPostAction = async () => {
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: createPostMutation,
variables: {
id: 'p47',
title,
postContent,
categoryIds,
},
})
authenticatedUser = await notifiedUser.toJson()
}
let commentContent
let commentAuthor
const createCommentOnPostAction = async () => {
await createPostAction()
authenticatedUser = await commentAuthor.toJson()
await mutate({
mutation: createCommentMutation,
variables: {
id: 'c47',
postId: 'p47',
commentContent,
},
})
authenticatedUser = await notifiedUser.toJson()
}
describe('comments on my post', () => {
beforeEach(async () => {
title = 'My post'
postContent = 'My post content.'
postAuthor = notifiedUser
})
describe('commenter is not me', () => {
beforeEach(async () => {
commentContent = 'Commenters comment.'
commentAuthor = await instance.create('User', {
id: 'commentAuthor',
name: 'Mrs Comment',
slug: 'mrs-comment',
email: 'commentauthor@example.org',
password: '1234',
})
})
it('sends me a notification', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
reason: 'comment_on_post',
post: null,
comment: {
content: commentContent,
},
},
],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
it('sends me no notification if I have blocked the comment author', async () => {
await notifiedUser.relateTo(commentAuthor, 'blocked')
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
describe('commenter is me', () => {
beforeEach(async () => {
commentContent = 'My comment.'
commentAuthor = notifiedUser
})
it('sends me no notification', async () => {
await notifiedUser.relateTo(commentAuthor, 'blocked')
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
})
beforeEach(async () => {
postAuthor = await instance.create('User', {
id: 'postAuthor',
name: 'Mrs Post',
slug: 'mrs-post',
email: 'post-author@example.org',
password: '1234',
})
})
describe('mentions me in a post', () => {
beforeEach(async () => {
title = 'Mentioning Al Capone'
postContent =
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
})
it('sends me a notification', async () => {
await createPostAction()
const expectedContent =
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
reason: 'mentioned_in_post',
post: {
content: expectedContent,
},
comment: null,
},
],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
describe('many times', () => {
const updatePostAction = async () => {
const updatedContent = `
One more mention to
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
and again:
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
and again
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
`
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: updatePostMutation,
variables: {
id: 'p47',
title,
postContent: updatedContent,
categoryIds,
},
})
authenticatedUser = await notifiedUser.toJson()
}
it('creates exactly one more notification', async () => {
await createPostAction()
await updatePostAction()
const expectedContent =
'<br>One more mention to<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again:<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>'
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
reason: 'mentioned_in_post',
post: {
content: expectedContent,
},
comment: null,
},
{
read: false,
reason: 'mentioned_in_post',
post: {
content: expectedContent,
},
comment: null,
},
],
},
},
})
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
describe('but the author of the post blocked me', () => {
beforeEach(async () => {
await postAuthor.relateTo(notifiedUser, 'blocked')
})
it('sends no notification', async () => {
await createPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
})
describe('mentions me in a comment', () => {
beforeEach(async () => {
title = 'Post where I get mentioned in a comment'
postContent = 'Content of post where I get mentioned in a comment.'
})
describe('I am not blocked at all', () => {
beforeEach(async () => {
commentContent =
'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.'
commentAuthor = await instance.create('User', {
id: 'commentAuthor',
name: 'Mrs Comment',
slug: 'mrs-comment',
email: 'comment-author@example.org',
password: '1234',
})
})
it('sends a notification', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [
{
read: false,
reason: 'mentioned_in_comment',
post: null,
comment: {
content: commentContent,
},
},
],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
describe('but the author of the post blocked me', () => {
beforeEach(async () => {
await postAuthor.relateTo(notifiedUser, 'blocked')
commentContent =
'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.'
commentAuthor = await instance.create('User', {
id: 'commentAuthor',
name: 'Mrs Comment',
slug: 'mrs-comment',
email: 'comment-author@example.org',
password: '1234',
})
})
it('sends no notification', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
})
})
})
})

View File

@ -14,13 +14,16 @@ if (sentryConfigs.SENTRY_DSN_BACKEND) {
},
withScope: (scope, error, context) => {
scope.setUser({
id: context.user.id,
id: context.user && context.user.id,
})
scope.setExtra('body', context.req.body)
scope.setExtra('origin', context.req.headers.origin)
scope.setExtra('user-agent', context.req.headers['user-agent'])
},
})
} else {
// eslint-disable-next-line no-console
if (process.env.NODE_ENV !== 'test') console.log('Warning: Sentry middleware inactive.')
}
export default sentryMiddleware

View File

@ -2,6 +2,8 @@ import { UserInputError } from 'apollo-server'
const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const NO_CATEGORIES_ERR_MESSAGE =
'You cannot save a post without at least one category or more than three'
const validateCommentCreation = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
@ -19,6 +21,7 @@ const validateCommentCreation = async (resolve, root, args, context, info) => {
postId,
},
)
session.close()
const [post] = postQueryRes.records.map(record => {
return record.get('post')
})
@ -43,9 +46,33 @@ const validateUpdateComment = async (resolve, root, args, context, info) => {
const validatePost = async (resolve, root, args, context, info) => {
const { categoryIds } = args
if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) {
throw new UserInputError(
'You cannot save a post without at least one category or more than three',
)
throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE)
}
return resolve(root, args, context, info)
}
const validateUpdatePost = async (resolve, root, args, context, info) => {
const { id, categoryIds } = args
const session = context.driver.session()
const categoryQueryRes = await session.run(
`
MATCH (post:Post {id: $id})-[:CATEGORIZED]->(category:Category)
RETURN category`,
{ id },
)
session.close()
const [category] = categoryQueryRes.records.map(record => {
return record.get('category')
})
if (category) {
if (categoryIds && categoryIds.length > 3) {
throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE)
}
} else {
if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) {
throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE)
}
}
return resolve(root, args, context, info)
}
@ -55,6 +82,6 @@ export default {
CreateComment: validateCommentCreation,
UpdateComment: validateUpdateComment,
CreatePost: validatePost,
UpdatePost: validatePost,
UpdatePost: validateUpdatePost,
},
}

View File

@ -1,9 +1,26 @@
import uuid from 'uuid/v4'
module.exports = {
id: { type: 'uuid', primary: true, default: uuid },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
read: { type: 'boolean', default: false },
id: {
type: 'uuid',
primary: true,
default: uuid,
},
read: {
type: 'boolean',
default: false,
},
reason: {
type: 'string',
valid: ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post'],
invalid: [null],
default: 'mentioned_in_post',
},
createdAt: {
type: 'string',
isoDate: true,
default: () => new Date().toISOString(),
},
user: {
type: 'relationship',
relationship: 'NOTIFIED',

View File

@ -69,23 +69,29 @@ describe('currentUser notifications', () => {
factory.create('User', neighborParams),
factory.create('Notification', {
id: 'post-mention-not-for-you',
reason: 'mentioned_in_post',
}),
factory.create('Notification', {
id: 'post-mention-already-seen',
read: true,
reason: 'mentioned_in_post',
}),
factory.create('Notification', {
id: 'post-mention-unseen',
reason: 'mentioned_in_post',
}),
factory.create('Notification', {
id: 'comment-mention-not-for-you',
reason: 'mentioned_in_comment',
}),
factory.create('Notification', {
id: 'comment-mention-already-seen',
read: true,
reason: 'mentioned_in_comment',
}),
factory.create('Notification', {
id: 'comment-mention-unseen',
reason: 'mentioned_in_comment',
}),
])
await factory.authenticateAs(neighborParams)
@ -287,9 +293,11 @@ describe('UpdateNotification', () => {
factory.create('User', mentionedParams),
factory.create('Notification', {
id: 'post-mention-to-be-updated',
reason: 'mentioned_in_post',
}),
factory.create('Notification', {
id: 'comment-mention-to-be-updated',
reason: 'mentioned_in_comment',
}),
])
await factory.authenticateAs(userParams)

View File

@ -75,22 +75,28 @@ export default {
delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
const session = context.driver.session()
const cypherDeletePreviousRelations = `
MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations
RETURN post, category
let updatePostCypher = `MATCH (post:Post {id: $params.id})
SET post = $params
`
await session.run(cypherDeletePreviousRelations, { params })
if (categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations
RETURN post, category
`
const updatePostCypher = `MATCH (post:Post {id: $params.id})
SET post = $params
WITH post
await session.run(cypherDeletePreviousRelations, { params })
updatePostCypher += `WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
RETURN post`
`
}
updatePostCypher += `RETURN post`
const updatePostVariables = { categoryIds, params }
const transactionRes = await session.run(updatePostCypher, updatePostVariables)

View File

@ -13,6 +13,7 @@ let client
let userParams
let authorParams
const postId = 'p3589'
const postTitle = 'I am a title'
const postContent = 'Some content'
const oldTitle = 'Old title'
@ -96,7 +97,7 @@ beforeEach(async () => {
}),
])
createPostVariables = {
id: 'p3589',
id: postId,
title: postTitle,
content: postContent,
categoryIds,
@ -218,7 +219,7 @@ describe('CreatePost', () => {
Post: [
{
title: postTitle,
id: 'p3589',
id: postId,
categories: expect.arrayContaining(categoryIdsArray),
},
],
@ -246,17 +247,16 @@ describe('UpdatePost', () => {
await asAuthor.create('User', authorParams)
await asAuthor.authenticateAs(authorParams)
await asAuthor.create('Post', {
id: 'p1',
id: postId,
title: oldTitle,
content: oldContent,
categoryIds,
})
updatePostVariables = {
id: 'p1',
id: postId,
title: newTitle,
content: newContent,
categoryIds: null,
}
})
@ -291,55 +291,96 @@ describe('UpdatePost', () => {
})
it('updates a post', async () => {
updatePostVariables.categoryIds = ['cat9']
const expected = { UpdatePost: { id: 'p1', content: newContent } }
updatePostVariables.categoryIds = ['cat27']
const expected = { UpdatePost: { id: postId, content: newContent } }
await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual(
expected,
)
})
describe('categories', () => {
beforeEach(async () => {
await client.request(createPostMutation, createPostVariables)
updatePostVariables = {
id: 'p3589',
title: newTitle,
content: newContent,
categoryIds: ['cat27'],
}
it('allows a user to update other attributes without passing in categoryIds explicitly', async () => {
const expected = { UpdatePost: { id: postId, content: newContent } }
await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual(
expected,
)
})
it('allows a user to update the categories of a post', async () => {
updatePostVariables.categoryIds = ['cat27']
await client.request(updatePostMutation, updatePostVariables)
const expected = [{ id: 'cat27' }]
const postQueryWithCategoriesVariables = {
id: 'p3589',
id: postId,
}
await expect(
client.request(postQueryWithCategories, postQueryWithCategoriesVariables),
).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] })
})
it('throws an error if categoryIds is not an array', async () => {
updatePostVariables.categoryIds = null
await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow(
postSaveError,
)
})
it('requires at least one category for successful update', async () => {
updatePostVariables.categoryIds = []
await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow(
postSaveError,
)
})
it('allows a maximum of three category for a successful update', async () => {
updatePostVariables.categoryIds = ['cat9', 'cat27', 'cat15', 'cat4']
await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow(
postSaveError,
)
})
describe('post created without categories somehow', () => {
let ownerNode, owner, postMutationAction
beforeEach(async () => {
const postSomehowCreated = await instance.create('Post', {
id: 'how-was-this-created',
title: postTitle,
content: postContent,
})
ownerNode = await instance.create('User', {
id: 'author-of-post-without-category',
name: 'Hacker',
slug: 'hacker',
email: 'hacker@example.org',
password: '1234',
})
owner = await ownerNode.toJson()
await postSomehowCreated.relateTo(ownerNode, 'author')
postMutationAction = async (user, mutation, variables) => {
const { server } = createServer({
context: () => {
return {
user,
neode: instance,
driver,
}
},
})
const { mutate } = createTestClient(server)
return mutate({
mutation,
variables,
})
}
updatePostVariables.id = 'how-was-this-created'
})
it('throws an error if categoryIds is not an array', async () => {
const mustAddCategoryToPost = await postMutationAction(
owner,
updatePostMutation,
updatePostVariables,
)
expect(mustAddCategoryToPost.errors[0]).toHaveProperty('message', postSaveError)
})
it('requires at least one category for successful update', async () => {
updatePostVariables.categoryIds = []
const mustAddCategoryToPost = await postMutationAction(
owner,
updatePostMutation,
updatePostVariables,
)
expect(mustAddCategoryToPost.errors[0]).toHaveProperty('message', postSaveError)
})
})
})
})
})
@ -355,7 +396,7 @@ describe('DeletePost', () => {
`
const variables = {
id: 'p1',
id: postId,
}
beforeEach(async () => {
@ -363,7 +404,7 @@ describe('DeletePost', () => {
await asAuthor.create('User', authorParams)
await asAuthor.authenticateAs(authorParams)
await asAuthor.create('Post', {
id: 'p1',
id: postId,
content: 'To be deleted',
categoryIds,
})
@ -396,7 +437,7 @@ describe('DeletePost', () => {
})
it('deletes a post', async () => {
const expected = { DeletePost: { id: 'p1', content: 'To be deleted' } }
const expected = { DeletePost: { id: postId, content: 'To be deleted' } }
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
})
})

View File

@ -0,0 +1,5 @@
enum ReasonNotification {
mentioned_in_post
mentioned_in_comment
comment_on_post
}

View File

@ -1,8 +1,9 @@
type Notification {
id: ID!
read: Boolean
reason: ReasonNotification
createdAt: String
user: User @relation(name: "NOTIFIED", direction: "OUT")
post: Post @relation(name: "NOTIFIED", direction: "IN")
comment: Comment @relation(name: "NOTIFIED", direction: "IN")
createdAt: String
}

View File

@ -62,15 +62,26 @@ export default function Factory(options = {}) {
lastResponse: null,
neodeInstance,
async authenticateAs({ email, password }) {
const headers = await authenticatedHeaders({ email, password }, seedServerHost)
const headers = await authenticatedHeaders(
{
email,
password,
},
seedServerHost,
)
this.lastResponse = headers
this.graphQLClient = new GraphQLClient(seedServerHost, { headers })
this.graphQLClient = new GraphQLClient(seedServerHost, {
headers,
})
return this
},
async create(node, args = {}) {
const { factory, mutation, variables } = this.factories[node](args)
if (factory) {
this.lastResponse = await factory({ args, neodeInstance })
this.lastResponse = await factory({
args,
neodeInstance,
})
return this.lastResponse
} else {
this.lastResponse = await this.graphQLClient.request(mutation, variables)
@ -121,11 +132,15 @@ export default function Factory(options = {}) {
},
async invite({ email }) {
const mutation = ` mutation($email: String!) { invite( email: $email) } `
this.lastResponse = await this.graphQLClient.request(mutation, { email })
this.lastResponse = await this.graphQLClient.request(mutation, {
email,
})
return this
},
async cleanDatabase() {
this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver })
this.lastResponse = await cleanDatabase({
driver: this.neo4jDriver,
})
return this
},
async emote({ to, data }) {

View File

@ -1675,10 +1675,10 @@ apollo-server-caching@0.5.0:
dependencies:
lru-cache "^5.0.0"
apollo-server-core@2.8.2:
version "2.8.2"
resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.8.2.tgz#d7e5a94c43457dd5c5a171c79b1c554b418581d4"
integrity sha512-ePMy1Ci5PflvM9XUWdnF2C+B6kZF2mhmsoV+SUN7O2jWFb5cW2XvWd4Pppov6reusqkz4VlABgZDfjr+Ck09+g==
apollo-server-core@2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.9.0.tgz#5db251093ee121a5f4d90a24d51aa4c21e421243"
integrity sha512-IvKIgqOqEEB8nszlpHWzlhAu4376So2PgNhFP6UrlfNTllt/WDti5YMOHnVimPWIDHmLPKFan0+wfzpsoRCRdg==
dependencies:
"@apollographql/apollo-tools" "^0.4.0"
"@apollographql/graphql-playground-html" "1.6.24"
@ -1694,7 +1694,7 @@ apollo-server-core@2.8.2:
apollo-server-types "0.2.1"
apollo-tracing "0.8.1"
fast-json-stable-stringify "^2.0.0"
graphql-extensions "0.9.2"
graphql-extensions "0.10.0"
graphql-tag "^2.9.2"
graphql-tools "^4.0.0"
graphql-upload "^8.0.2"
@ -1715,10 +1715,10 @@ apollo-server-errors@2.3.1:
resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.1.tgz#033cf331463ebb99a563f8354180b41ac6714eb6"
integrity sha512-errZvnh0vUQChecT7M4A/h94dnBSRL213dNxpM5ueMypaLYgnp4hiCTWIEaooo9E4yMGd1qA6WaNbLDG2+bjcg==
apollo-server-express@2.8.2, apollo-server-express@^2.8.1:
version "2.8.2"
resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.8.2.tgz#cd1c6994cf5adceea057a088aff52f289bb36377"
integrity sha512-eA7IupNbx3PjIW4E0uMjQU9WvxcHznzgdFWRxJ4RqDiIwrrwROb7dgmPm3TJaatU/etjGq482pdfJIlMDNYPeA==
apollo-server-express@2.9.0, apollo-server-express@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.9.0.tgz#9d2a2d9823422ef26bca15931669d3153dc8a08b"
integrity sha512-+057V6Ui1BX69jUlV6YDQ7Xw9CCBfowN/GauvyF09KnsjYUJ+cB1xf4mkj/HAjaz4ReXQaALJNr2qPYPXS4R6w==
dependencies:
"@apollographql/graphql-playground-html" "1.6.24"
"@types/accepts" "^1.3.5"
@ -1726,12 +1726,13 @@ apollo-server-express@2.8.2, apollo-server-express@^2.8.1:
"@types/cors" "^2.8.4"
"@types/express" "4.17.1"
accepts "^1.3.5"
apollo-server-core "2.8.2"
apollo-server-core "2.9.0"
apollo-server-types "0.2.1"
body-parser "^1.18.3"
cors "^2.8.4"
graphql-subscriptions "^1.0.0"
graphql-tools "^4.0.0"
parseurl "^1.3.2"
subscriptions-transport-ws "^0.9.16"
type-is "^1.6.16"
@ -1742,12 +1743,12 @@ apollo-server-plugin-base@0.6.1:
dependencies:
apollo-server-types "0.2.1"
apollo-server-testing@~2.8.2:
version "2.8.2"
resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.8.2.tgz#8faa8f1778fa4893f5bf705f7cea84a69477aa3f"
integrity sha512-ccp1DpmjdmLT98ww4NtSiDPbeIPlVZJ5Iy408ToyhAGwNXRHk5f8Czf+JAgSayvgt4cxCm1fzxnVe1OjO8oIvA==
apollo-server-testing@~2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.9.0.tgz#fb5276e0761992ed493d84e998eaa4f696914519"
integrity sha512-N6c+wx5MaDZ0mWPzA11nKkkJjX+E3ubATY3G5ejprUsN8BiHENyEQ0EZh+dO0yL9+q/mUHix3p7Utax9odxBcw==
dependencies:
apollo-server-core "2.8.2"
apollo-server-core "2.9.0"
apollo-server-types@0.2.1:
version "0.2.1"
@ -1758,13 +1759,13 @@ apollo-server-types@0.2.1:
apollo-server-caching "0.5.0"
apollo-server-env "2.4.1"
apollo-server@~2.8.2:
version "2.8.2"
resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.8.2.tgz#8dd9edef656f7466402be8d7715e788d73d2c50e"
integrity sha512-7mZVsM+p8mf0cA3pTiMuEw8uYilQjZErKx092XNYRzAjoDdGddIC3GvUuhxMkmqvD2YhrWRNRL3QlxHZKnYXQw==
apollo-server@~2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.9.0.tgz#32685712215d420ff5f3298b3b34e972e21ec1c6"
integrity sha512-KouRjMWn8pnR4KvVsFXT1GZYzH53J0+v9KwnLUKrLNo2G4KiZu5KhP+tEkF7uTlpHzdPMQAIbwjdXKzOH/r6ew==
dependencies:
apollo-server-core "2.8.2"
apollo-server-express "2.8.2"
apollo-server-core "2.9.0"
apollo-server-express "2.9.0"
express "^4.0.0"
graphql-subscriptions "^1.0.0"
graphql-tools "^4.0.0"
@ -1976,17 +1977,17 @@ babel-core@~7.0.0-0:
resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece"
integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==
babel-eslint@~10.0.2:
version "10.0.2"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.2.tgz#182d5ac204579ff0881684b040560fdcc1558456"
integrity sha512-UdsurWPtgiPgpJ06ryUnuaSXC2s0WoSZnQmEpbAH65XZSdwowgN5MvyP7e88nW07FYXv72erVtpBkxyDVKhH1Q==
babel-eslint@~10.0.3:
version "10.0.3"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.3.tgz#81a2c669be0f205e19462fed2482d33e4687a88a"
integrity sha512-z3U7eMY6r/3f3/JB9mTsLjyxrv0Yb1zb8PCWCLpguxfCzBIZUwy23R1t/XKewP+8mEN2Ck8Dtr4q20z6ce6SoA==
dependencies:
"@babel/code-frame" "^7.0.0"
"@babel/parser" "^7.0.0"
"@babel/traverse" "^7.0.0"
"@babel/types" "^7.0.0"
eslint-scope "3.7.1"
eslint-visitor-keys "^1.0.0"
resolve "^1.12.0"
babel-jest@^24.9.0, babel-jest@~24.9.0:
version "24.9.0"
@ -2833,10 +2834,10 @@ data-urls@^1.0.0:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
date-fns@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0"
integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w==
date-fns@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.1.tgz#c5f30e31d3294918e6b6a82753a4e719120e203d"
integrity sha512-C14oTzTZy8DH1Eq8N78owrCWvf3+cnJw88BTK/N3DYWVxDJuJzPaNdplzYxDYuuXXGvqBcO4Vy5SOrwAooXSWw==
debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
version "2.6.9"
@ -3284,10 +3285,10 @@ eslint-config-prettier@~6.1.0:
dependencies:
get-stdin "^6.0.0"
eslint-config-standard@~14.0.0:
version "14.0.0"
resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-14.0.0.tgz#1de7bf5af37542dc6eef879ab7eb5e5e0f830747"
integrity sha512-bV6e2LFvJEetrLjVAy4KWPOUsIhPWr040c649MigTPR6yUtaGuOt6CEAyNeez2lRiC+2+vjGWa02byjs25EB3A==
eslint-config-standard@~14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-14.0.1.tgz#375c3636fb4bd453cb95321d873de12e4eef790b"
integrity sha512-1RWsAKTDTZgA8bIM6PSC9aTGDAUlKqNkYNJlTZ5xYD/HYkIM6GlcefFvgcJ8xi0SWG5203rttKYX28zW+rKNOg==
eslint-import-resolver-node@^0.3.2:
version "0.3.2"
@ -3366,14 +3367,6 @@ eslint-plugin-standard@~4.0.1:
resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz#ff0519f7ffaff114f76d1bd7c3996eef0f6e20b4"
integrity sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ==
eslint-scope@3.7.1:
version "3.7.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8"
integrity sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=
dependencies:
esrecurse "^4.1.0"
estraverse "^4.1.1"
eslint-scope@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848"
@ -4079,6 +4072,15 @@ graphql-custom-directives@~0.2.14:
moment "^2.22.2"
numeral "^2.0.6"
graphql-extensions@0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.10.0.tgz#ceafc42e16554930b0dc90f64d5727ee2a9e9cf9"
integrity sha512-qz9Ev0NgsRxdTYqYSCpYwBWS9r1imm+vCBt3PmHzqZlE7SEpUPGddn9oKcLRB/P8uXT6dsr60hDmDHukIxiVOw==
dependencies:
"@apollographql/apollo-tools" "^0.4.0"
apollo-server-env "2.4.1"
apollo-server-types "0.2.1"
graphql-extensions@0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.8.1.tgz#f5f1fed5fe49620c4e70c5d08bdbd0039e91c402"
@ -4097,15 +4099,6 @@ graphql-extensions@0.9.1:
apollo-server-env "2.4.1"
apollo-server-types "0.2.1"
graphql-extensions@0.9.2:
version "0.9.2"
resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.9.2.tgz#4bdd81d5d9102e20b7ad3d790b16624fb97c7ab7"
integrity sha512-7yP6Mr6cDBadrM5dl4CIlp1wTMyPPpL64FtcsOABmaOdf9sOb/X7E3wJSi80UsB8sw0CY2V/HCeU3CIXParQjw==
dependencies:
"@apollographql/apollo-tools" "^0.4.0"
apollo-server-env "2.4.1"
apollo-server-types "0.2.1"
graphql-import@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/graphql-import/-/graphql-import-0.7.1.tgz#4add8d91a5f752d764b0a4a7a461fcd93136f223"
@ -4138,10 +4131,10 @@ graphql-request@~1.8.2:
dependencies:
cross-fetch "2.2.2"
graphql-shield@~6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-6.0.5.tgz#e55c7eb1984c684863c897746044bb216a285b41"
integrity sha512-+uRVptAv6RvaM5GVqZjEsanlZ2OTmUgDu+x/UW/qD6+Zb+I6nTGZ7nII8LTFHuUdXrCICfxesyMODhQYXcEZWQ==
graphql-shield@~6.0.6:
version "6.0.6"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-6.0.6.tgz#ef8c53f1dd972c2d1828ffd45ce9b1f877576534"
integrity sha512-rwhno5ZvEBbedQ8mEOi/Lk71J5CrpQCOcyuDIO+qb1hqm7cvWLtLVyZFrhVp7vN/vULV9oX30j0clC/1d05LpQ==
dependencies:
"@types/yup" "0.26.23"
lightercollective "^0.3.0"
@ -4200,10 +4193,10 @@ graphql-upload@^8.0.2:
http-errors "^1.7.2"
object-path "^0.11.4"
graphql@^14.2.1, graphql@^14.5.0:
version "14.5.0"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.0.tgz#4801e6460942c9c591944617f6dd224a9e531520"
integrity sha512-wnGcTD181L2xPnIwHHjx/moV4ulxA2Kms9zcUY+B/SIrK+2N+iOC6WNgnR2zVTmg1Z8P+CZq5KXibTnatg3WUw==
graphql@^14.2.1, graphql@^14.5.3:
version "14.5.3"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.3.tgz#e025851cc413e153220f4edbbb25d49f55104fa0"
integrity sha512-W8A8nt9BsMg0ZK2qA3DJIVU6muWhxZRYLTmc+5XGwzWzVdUdPVlAAg5hTBjiTISEnzsKL/onasu6vl3kgGTbYg==
dependencies:
iterall "^1.2.2"
@ -6773,7 +6766,7 @@ parse5@^3.0.1:
dependencies:
"@types/node" "*"
parseurl@~1.3.3:
parseurl@^1.3.2, parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
@ -7404,7 +7397,7 @@ resolve@1.1.7:
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=
resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0:
resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6"
integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==

View File

@ -25,13 +25,20 @@
- name: nitro-neo4j
image: humanconnection/neo4j:latest
imagePullPolicy: Always
resources:
requests:
memory: "1G"
limits:
memory: "2G"
env:
- name: NEO4J_apoc_import_file_enabled
value: "true"
- name: NEO4J_dbms_memory_pagecache_size
value: 1G
value: "490M"
- name: NEO4J_dbms_memory_heap_max__size
value: 1G
value: "500M"
- name: NEO4J_dbms_memory_heap_initial__size
value: "500M"
- name: NEO4J_dbms_security_procedures_unrestricted
value: "algo.*,apoc.*"
envFrom:

View File

@ -5,7 +5,6 @@ ARG BUILD_COMMIT
ENV BUILD_COMMIT=$BUILD_COMMIT
COPY db_setup.sh /usr/local/bin/db_setup
COPY entrypoint.sh /docker-entrypoint-wrapper.sh
RUN apt-get update && apt-get -y install procps wget
RUN apt-get update && apt-get -y install wget htop
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.4/apoc-3.5.0.4-all.jar -P plugins/
ENTRYPOINT ["/docker-entrypoint-wrapper.sh"]

View File

@ -18,6 +18,16 @@ docker-compose up
You can access Neo4J through [http://localhost:7474/](http://localhost:7474/)
for an interactive cypher shell and a visualization of the graph.
### Database Indices and Constraints
Database indices and constraints need to be created when the database is
running. So start the container with the command above and run:
```bash
docker-compose exec neo4j db_setup
```
## Installation without Docker
Install the community edition of [Neo4j](https://neo4j.com/) along with the plugin
@ -35,6 +45,20 @@ Then make sure to allow Apoc procedures by adding the following line to your Neo
```
dbms.security.procedures.unrestricted=apoc.*
```
### Database Indices and Constraints
If you have `cypher-shell` available with your local installation of neo4j you
can run:
```bash
# in folder neo4j/
$ cp .env.template .env
$ ./db_setup.sh
```
Otherwise, if you don't have `cypher-shell` available, copy the cypher
statements [from the `db_setup.sh` script](https://github.com/Human-Connection/Human-Connection/blob/master/neo4j/db_setup.sh) and paste the scripts into your
[database browser frontend](http://localhost:7474).
### Alternatives
@ -50,21 +74,3 @@ in `backend/.env`.
Start Neo4J and confirm the database is running at [http://localhost:7474](http://localhost:7474).
## Database Indices and Constraints
If you are not running our dedicated Neo4J [docker image](https://hub.docker.com/r/humanconnection/neo4j),
which is the case if you setup Neo4J locally without docker, then you have to
setup unique indices and database constraints manually.
If you have `cypher-shell` available with your local installation of neo4j you
can run:
```bash
# in folder neo4j/
$ cp .env.template .env
$ ./db_setup.sh
```
Otherwise, if you don't have `cypher-shell` available, copy the cypher
statements [from the `db_setup.sh` script](https://github.com/Human-Connection/Human-Connection/blob/master/neo4j/db_setup.sh) and paste the scripts into your
[database browser frontend](http://localhost:7474).

View File

@ -1,21 +0,0 @@
#!/bin/bash
# credits: https://github.com/javamonkey79
# https://github.com/neo4j/docker-neo4j/issues/166
# turn on bash's job control
set -m
# Start the primary process and put it in the background
/docker-entrypoint.sh neo4j &
# Start the helper process
db_setup
# the my_helper_process might need to know how to wait on the
# primary process to start before it does its work and returns
# now we bring the primary process back into the foreground
# and leave it there
fg %1

View File

@ -23,7 +23,7 @@
"codecov": "^3.5.0",
"cross-env": "^5.2.0",
"cypress": "^3.4.1",
"cypress-cucumber-preprocessor": "^1.14.1",
"cypress-cucumber-preprocessor": "^1.15.0",
"cypress-file-upload": "^3.3.3",
"cypress-plugin-retries": "^1.2.2",
"dotenv": "^8.1.0",
@ -34,4 +34,4 @@
"npm-run-all": "^4.1.5",
"slug": "^1.1.0"
}
}
}

View File

@ -8,7 +8,7 @@ const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
describe('Comment.vue', () => {
let propsData

View File

@ -15,7 +15,7 @@
<hc-user :user="author" :date-time="comment.createdAt" />
</ds-space>
<!-- Content Menu (can open Modals) -->
<no-ssr>
<client-only>
<content-menu
placement="bottom-end"
resource-type="comment"
@ -25,7 +25,7 @@
:is-owner="isAuthor(author.id)"
@showEditCommentMenu="editCommentMenu"
/>
</no-ssr>
</client-only>
<ds-space margin-bottom="small" />
<div v-if="openEditCommentMenu">

View File

@ -14,7 +14,7 @@ localVue.filter('truncate', string => string)
config.stubs['v-popover'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
describe('CommentList.vue', () => {
let mocks

View File

@ -16,7 +16,7 @@ localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'

View File

@ -14,7 +14,7 @@
<ds-space />
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
<small class="smallTag">{{ form.title.length }}/{{ formSchema.title.max }}</small>
<no-ssr>
<client-only>
<hc-editor
:users="users"
:value="form.content"
@ -22,7 +22,7 @@
@input="updateEditorContent"
/>
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
</no-ssr>
</client-only>
<ds-space margin-bottom="xxx-large" />
<hc-categories-select
model="categoryIds"

View File

@ -1,6 +1,6 @@
<template>
<span>
<no-ssr placeholder="0" tag="span">
<client-only placeholder="0" tag="span">
<count-to
:start-val="startVal"
:end-val="endVal"
@ -8,7 +8,7 @@
:autoplay="autoplay"
:separator="separator"
/>
</no-ssr>
</client-only>
</span>
</template>

View File

@ -2,7 +2,7 @@
<ds-form v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<ds-card>
<!-- with no-ssr the content is not shown -->
<!-- with client-only the content is not shown -->
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<ds-space />
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">

View File

@ -10,7 +10,7 @@ localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('PostCard', () => {

View File

@ -13,9 +13,9 @@
<ds-space margin-bottom="small" />
<!-- Username, Image & Date of Post -->
<div>
<no-ssr>
<client-only>
<hc-user :user="post.author" :trunc="35" :date-time="post.createdAt" />
</no-ssr>
</client-only>
<hc-ribbon :text="$t('post.name')" />
</div>
<ds-space margin-bottom="small" />
@ -42,7 +42,7 @@
:icon="category.icon"
/>
</div>
<no-ssr>
<client-only>
<div style="display: inline-block; float: right">
<!-- Shouts Count -->
<span :style="{ opacity: post.shoutedCount ? 1 : 0.5 }">
@ -63,7 +63,7 @@
:is-owner="isAuthor"
/>
</div>
</no-ssr>
</client-only>
</template>
</ds-card>
</template>

View File

@ -9,17 +9,21 @@
:schema="formSchema"
@submit="handleSubmit"
>
<h1>{{ $t('registration.signup.title') }}</h1>
<h1>{{ invitation ? $t('profile.invites.title') : $t('registration.signup.title') }}</h1>
<ds-space v-if="token" margin-botton="large">
<ds-text v-html="$t('registration.signup.form.invitation-code', { code: token })" />
</ds-space>
<ds-space margin-botton="large">
<ds-text>
{{ $t('registration.signup.form.description') }}
{{
invitation
? $t('profile.invites.description')
: $t('registration.signup.form.description')
}}
</ds-text>
</ds-space>
<ds-input
:placeholder="$t('login.email')"
:placeholder="invitation ? $t('profile.invites.emailPlaceholder') : $t('login.email')"
type="email"
id="email"
model="email"
@ -45,9 +49,7 @@
</template>
<template v-else>
<sweetalert-icon icon="error" />
<ds-text align="center">
{{ error.message }}
</ds-text>
<ds-text align="center">{{ error.message }}</ds-text>
</template>
</div>
</ds-space>
@ -73,11 +75,13 @@ export const SignupByInvitationMutation = gql`
}
`
export default {
name: 'Signup',
components: {
SweetalertIcon,
},
props: {
token: { type: String, default: null },
invitation: { type: Boolean, default: false },
},
data() {
return {

View File

@ -25,9 +25,9 @@
<div v-if="dateTime" style="display: inline;">
<ds-text align="left" size="small" color="soft">
<ds-icon name="clock" />
<no-ssr>
<client-only>
<hc-relative-date-time :date-time="dateTime" />
</no-ssr>
</client-only>
</ds-text>
</div>
</div>

View File

@ -8,16 +8,17 @@ const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
describe('Notification', () => {
let stubs
let mocks
let propsData
let wrapper
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn(),
$t: key => key,
}
stubs = {
NuxtLink: RouterLinkStub,
@ -33,37 +34,159 @@ describe('Notification', () => {
})
}
describe('given a notification', () => {
describe('given a notification about a comment on a post', () => {
beforeEach(() => {
propsData.notification = {
post: {
title: "It's a title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt: '<a href="/profile/u3" target="_blank">@jenny-rostock</a> is the best',
reason: 'comment_on_post',
post: null,
comment: {
id: 'comment-1',
contentExcerpt:
'<a href="/profile/u123" target="_blank">@dagobert-duck</a> is the best on this comment.',
post: {
title: "It's a post title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt: 'Post content.',
},
},
}
})
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
'notifications.menu.comment_on_post',
)
})
it('renders title', () => {
expect(Wrapper().text()).toContain("It's a title")
wrapper = Wrapper()
expect(wrapper.text()).toContain("It's a post title")
})
it('renders the "Comment:"', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('Comment:')
})
it('renders the contentExcerpt', () => {
expect(Wrapper().text()).toContain('@jenny-rostock is the best')
wrapper = Wrapper()
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
})
it('has no class "read"', () => {
expect(Wrapper().classes()).not.toContain('read')
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
})
describe('that is read', () => {
beforeEach(() => {
propsData.notification.read = true
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(Wrapper().classes()).toContain('read')
expect(wrapper.classes()).toContain('read')
})
})
})
describe('given a notification about a mention in a post', () => {
beforeEach(() => {
propsData.notification = {
reason: 'mentioned_in_post',
post: {
title: "It's a post title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt:
'<a href="/profile/u3" target="_blank">@jenny-rostock</a> is the best on this post.',
},
comment: null,
}
})
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
'notifications.menu.mentioned_in_post',
)
})
it('renders title', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain("It's a post title")
})
it('renders the contentExcerpt', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@jenny-rostock is the best on this post.')
})
it('has no class "read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
})
describe('that is read', () => {
beforeEach(() => {
propsData.notification.read = true
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(wrapper.classes()).toContain('read')
})
})
})
describe('given a notification about a mention in a comment', () => {
beforeEach(() => {
propsData.notification = {
reason: 'mentioned_in_comment',
post: null,
comment: {
id: 'comment-1',
contentExcerpt:
'<a href="/profile/u123" target="_blank">@dagobert-duck</a> is the best on this comment.',
post: {
title: "It's a post title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt: 'Post content.',
},
},
}
})
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
'notifications.menu.mentioned_in_comment',
)
})
it('renders title', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain("It's a post title")
})
it('renders the "Comment:"', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('Comment:')
})
it('renders the contentExcerpt', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
})
it('has no class "read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
})
describe('that is read', () => {
beforeEach(() => {
propsData.notification.read = true
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(wrapper.classes()).toContain('read')
})
})
})

View File

@ -1,6 +1,6 @@
<template>
<ds-space :class="[{ read: notification.read }, notification]" margin-bottom="x-small">
<no-ssr>
<client-only>
<ds-space margin-bottom="x-small">
<hc-user
v-if="resourceType == 'Post'"
@ -10,10 +10,10 @@
/>
<hc-user v-else :user="comment.author" :date-time="comment.createdAt" :trunc="35" />
</ds-space>
<ds-text color="soft">
{{ $t('notifications.menu.mentioned', { resource: resourceType }) }}
<ds-text class="reason-text-for-test" color="soft">
{{ $t(`notifications.menu.${notification.reason}`) }}
</ds-text>
</no-ssr>
</client-only>
<ds-space margin-bottom="x-small" />
<nuxt-link
class="notification-mention-post"

View File

@ -13,7 +13,7 @@ localVue.use(Styleguide)
localVue.use(Filters)
localVue.filter('truncate', string => string)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('NotificationList.vue', () => {

View File

@ -78,12 +78,13 @@ export default i18n => {
export const currentUserNotificationsQuery = () => {
return gql`
{
query {
currentUser {
id
notifications(read: false, orderBy: createdAt_desc) {
id
read
reason
createdAt
post {
id

View File

@ -37,14 +37,14 @@
:width="{ base: '15%', sm: '15%', md: '10%', lg: '10%' }"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
>
<no-ssr>
<client-only>
<filter-posts
v-show="showFilterPostsDropdown"
placement="top-start"
offset="8"
:categories="categories"
/>
</no-ssr>
</client-only>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '10%', lg: '2%' }" />
<ds-flex-item
@ -59,14 +59,14 @@
'hide-mobile-menu': !toggleMobileMenu,
}"
>
<no-ssr>
<client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
</no-ssr>
</client-only>
<template v-if="isLoggedIn">
<no-ssr>
<client-only>
<notification-menu placement="top" />
</no-ssr>
<no-ssr>
</client-only>
<client-only>
<dropdown class="avatar-menu" offset="8">
<template slot="default" slot-scope="{ toggleMenu }">
<a
@ -113,7 +113,7 @@
</div>
</template>
</dropdown>
</no-ssr>
</client-only>
</template>
</div>
</ds-flex-item>
@ -140,9 +140,9 @@
<nuxt-link to="/changelog">{{ $t('site.changelog') }}</nuxt-link>
</div>
<div id="overlay" />
<no-ssr>
<client-only>
<modal />
</no-ssr>
</client-only>
</div>
</template>

View File

@ -14,7 +14,7 @@
"all": "Alle"
},
"general": {
"header": "Filtern nach..."
"header": "Filtern nach"
},
"followers": {
"label": "Benutzern, denen ich folge"
@ -96,7 +96,7 @@
}
},
"editor": {
"placeholder": "Schreib etwas Inspirierendes...",
"placeholder": "Schreib etwas Inspirierendes",
"mention": {
"noUsersFound": "Keine Benutzer gefunden"
},
@ -122,13 +122,19 @@
"followingNobody": "folgt niemandem.",
"followedBy": "wird gefolgt von:",
"followedByNobody": "wird von niemandem gefolgt.",
"and": "und",
"more": "weitere"
"andMore": "und {number} weitere …"
},
"invites": {
"title": "Lade jemanden zu Human Connection ein!",
"description": "Für die Einladung trage seine E-Mail-Adresse hier ein.",
"emailPlaceholder": "E-Mail-Adresse für die Einladung"
}
},
"notifications": {
"menu": {
"mentioned": "hat dich in einem {resource} erwähnt"
"mentioned_in_post": "Hat dich in einem Beitrag erwähnt …",
"mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …",
"comment_on_post": "Hat deinen Beitrag kommentiert …"
}
},
"search": {
@ -271,8 +277,8 @@
},
"invites": {
"name": "Benutzer einladen",
"title": "Benutzer als Admin anmelden",
"description": "Dieses Anmeldeformular ist zu sehen sobald die Anmeldung öffentlich zugänglich ist."
"title": "Leute einladen",
"description": "Einladungen sind ein wunderbarer Weg, deine Freund in deinem Netzwerk zu haben …"
}
},
"post": {
@ -300,7 +306,7 @@
},
"comment": {
"content": {
"unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar"
"unavailable-placeholder": "dieser Kommentar ist nicht mehr verfügbar"
},
"menu": {
"edit": "Kommentar bearbeiten",

View File

@ -14,7 +14,7 @@
"all": "All"
},
"general": {
"header": "Filter by..."
"header": "Filter by"
},
"followers": {
"label": "Users I follow"
@ -96,7 +96,7 @@
}
},
"editor": {
"placeholder": "Leave your inspirational thoughts...",
"placeholder": "Leave your inspirational thoughts",
"mention": {
"noUsersFound": "No users found"
},
@ -122,13 +122,19 @@
"followingNobody": "follows nobody.",
"followedBy": "is followed by:",
"followedByNobody": "is not followed by anyone.",
"and": "and",
"more": "more"
"andMore": "and {number} more …"
},
"invites": {
"title": "Invite somebody to Human Connection!",
"description": "Enter thier email address for invitation.",
"emailPlaceholder": "Email to invite"
}
},
"notifications": {
"menu": {
"mentioned": "mentioned you in a {resource}"
"mentioned_in_post": "Mentioned you in a post …",
"mentioned_in_comment": "Mentioned you in a comment …",
"comment_on_post": "Commented on your post …"
}
},
"search": {
@ -271,8 +277,8 @@
},
"invites": {
"name": "Invite users",
"title": "Signup users as admin",
"description": "This registration form will be visible as soon as the registration is open to the public."
"title": "Invite people",
"description": "Invitations are a wonderful way to have your friends in your network …"
}
},
"post": {
@ -300,7 +306,7 @@
},
"comment": {
"content": {
"unavailable-placeholder": "...this comment is not available anymore"
"unavailable-placeholder": "this comment is not available anymore"
},
"menu": {
"edit": "Edit Comment",

View File

@ -51,7 +51,7 @@
},
"dependencies": {
"@human-connection/styleguide": "0.5.19",
"@nuxtjs/apollo": "^4.0.0-rc11",
"@nuxtjs/apollo": "^4.0.0-rc12",
"@nuxtjs/axios": "~5.6.0",
"@nuxtjs/dotenv": "~1.4.0",
"@nuxtjs/sentry": "^3.0.0",
@ -61,9 +61,9 @@
"apollo-client": "~2.6.4",
"cookie-universal-nuxt": "~2.0.17",
"cross-env": "~5.2.0",
"date-fns": "2.0.0",
"date-fns": "2.0.1",
"express": "~4.17.1",
"graphql": "~14.5.0",
"graphql": "~14.5.3",
"isemail": "^3.2.0",
"jsonwebtoken": "~8.5.1",
"linkify-it": "~2.2.0",
@ -95,7 +95,7 @@
"@vue/server-test-utils": "~1.0.0-beta.29",
"@vue/test-utils": "~1.0.0-beta.29",
"babel-core": "~7.0.0-bridge.0",
"babel-eslint": "~10.0.2",
"babel-eslint": "~10.0.3",
"babel-jest": "~24.9.0",
"babel-loader": "~8.0.6",
"babel-preset-vue": "~2.0.2",
@ -104,7 +104,7 @@
"eslint": "~5.16.0",
"eslint-config-prettier": "~6.1.0",
"eslint-config-standard": "~12.0.0",
"eslint-loader": "~2.2.1",
"eslint-loader": "~3.0.0",
"eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.15.2",
"eslint-plugin-node": "~9.1.0",

View File

@ -1,23 +1,23 @@
<template>
<ds-card>
<no-ssr>
<client-only>
<ds-space margin="large">
<ds-flex>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number :count="0" :label="$t('admin.dashboard.users')" size="x-large" uppercase>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countUsers" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number :count="0" :label="$t('admin.dashboard.posts')" size="x-large" uppercase>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countPosts" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
@ -29,9 +29,9 @@
size="x-large"
uppercase
>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countComments" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
@ -43,9 +43,9 @@
size="x-large"
uppercase
>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countNotifications" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
@ -57,9 +57,9 @@
size="x-large"
uppercase
>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countOrganizations" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
@ -71,42 +71,42 @@
size="x-large"
uppercase
>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countProjects" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number :count="0" :label="$t('admin.dashboard.invites')" size="x-large" uppercase>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countInvites" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number :count="0" :label="$t('admin.dashboard.follows')" size="x-large" uppercase>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countFollows" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number :count="0" :label="$t('admin.dashboard.shouts')" size="x-large" uppercase>
<no-ssr slot="count">
<client-only slot="count">
<hc-count-to :end-val="statistics.countShouts" />
</no-ssr>
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
</ds-flex>
</ds-space>
</no-ssr>
</client-only>
</ds-card>
</template>

View File

@ -1,14 +1,11 @@
<template>
<!-- This registration form will be visible to the public as soon as the registration is open to the public. -->
<ds-section>
<ds-space>
<ds-heading size="h3">
{{ $t('admin.invites.title') }}
</ds-heading>
<ds-text>
{{ $t('admin.invites.description') }}
</ds-text>
<ds-heading size="h3">{{ $t('admin.invites.title') }}</ds-heading>
<ds-text>{{ $t('admin.invites.description') }}</ds-text>
</ds-space>
<signup />
<signup :invitation="true" />
</ds-section>
</template>

View File

@ -15,7 +15,7 @@ localVue.use(Filters)
localVue.use(VTooltip)
localVue.use(InfiniteScroll)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['router-link'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'

View File

@ -36,7 +36,7 @@
</ds-grid-item>
</template>
</masonry-grid>
<no-ssr>
<client-only>
<ds-button
v-tooltip="{ content: 'Create a new Post', placement: 'left', delay: { show: 500 } }"
:path="{ name: 'post-create' }"
@ -45,14 +45,14 @@
size="x-large"
primary
/>
</no-ssr>
</client-only>
<div
v-if="hasMore"
v-infinite-scroll="showMoreContributions"
:infinite-scroll-immediate-check="true"
:infinite-scroll-disabled="$apollo.loading"
:infinite-scroll-distance="10"
:infinite-scroll-throttle-delay="800"
:infinite-scroll-immediate-check="true"
>
<hc-load-more v-if="true" :loading="$apollo.loading" @click="showMoreContributions" />
</div>

View File

@ -10,9 +10,9 @@
<ds-card class="login-card">
<ds-flex gutter="small">
<ds-flex-item :width="{ base: '100%', sm: '50%' }" centered>
<no-ssr>
<client-only>
<locale-switch class="login-locale-switch" offset="5" />
</no-ssr>
</client-only>
<ds-space margin-top="small" margin-bottom="xxx-small" centered>
<img
class="login-image"

View File

@ -10,7 +10,7 @@ localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
describe('PostSlug', () => {
let wrapper

View File

@ -8,7 +8,7 @@
<ds-space margin-bottom="small" />
<hc-user :user="post.author" :date-time="post.createdAt" />
<!-- Content Menu (can open Modals) -->
<no-ssr>
<client-only>
<content-menu
placement="bottom-end"
resource-type="contribution"
@ -16,7 +16,7 @@
:modalsData="menuModalsData"
:is-owner="isAuthor(post.author ? post.author.id : null)"
/>
</no-ssr>
</client-only>
<ds-space margin-bottom="small" />
<ds-heading tag="h3" no-margin>{{ post.title }}</ds-heading>
<ds-space margin-bottom="small" />

View File

@ -13,7 +13,7 @@ localVue.use(Filters)
localVue.use(InfiniteScroll)
localVue.filter('date', d => d)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'

View File

@ -15,7 +15,7 @@
</hc-upload>
<hc-avatar v-else :user="user" class="profile-avatar" size="x-large" />
<!-- Menu -->
<no-ssr>
<client-only>
<content-menu
placement="bottom-end"
resource-type="user"
@ -25,7 +25,7 @@
@block="block"
@unblock="unblock"
/>
</no-ssr>
</client-only>
<ds-space margin="small">
<ds-heading tag="h3" align="center" no-margin>{{ userName }}</ds-heading>
<ds-text v-if="user.location" align="center" color="soft" size="small">
@ -41,18 +41,18 @@
</ds-space>
<ds-flex>
<ds-flex-item>
<no-ssr>
<client-only>
<ds-number :label="$t('profile.followers')">
<hc-count-to slot="count" :end-val="user.followedByCount" />
</ds-number>
</no-ssr>
</client-only>
</ds-flex-item>
<ds-flex-item>
<no-ssr>
<client-only>
<ds-number :label="$t('profile.following')">
<hc-count-to slot="count" :end-val="user.followingCount" />
</ds-number>
</no-ssr>
</client-only>
</ds-flex-item>
</ds-flex>
<ds-space margin="small">
@ -89,14 +89,17 @@
<template v-if="user.following && user.following.length">
<ds-space v-for="follow in uniq(user.following)" :key="follow.id" margin="x-small">
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<client-only>
<user :user="follow" :trunc="15" />
</no-ssr>
</client-only>
</ds-space>
<ds-space v-if="user.followingCount - user.following.length" margin="small">
<ds-text size="small" color="softer">
{{ $t('profile.network.and') }} {{ user.followingCount - user.following.length }}
{{ $t('profile.network.more') }}
{{
$t('profile.network.andMore', {
number: user.followingCount - user.following.length,
})
}}
</ds-text>
</ds-space>
</template>
@ -116,14 +119,17 @@
<template v-if="user.followedBy && user.followedBy.length">
<ds-space v-for="follow in uniq(user.followedBy)" :key="follow.id" margin="x-small">
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<client-only>
<user :user="follow" :trunc="15" />
</no-ssr>
</client-only>
</ds-space>
<ds-space v-if="user.followedByCount - user.followedBy.length" margin="small">
<ds-text size="small" color="softer">
{{ $t('profile.network.and') }} {{ user.followedByCount - user.followedBy.length }}
{{ $t('profile.network.more') }}
{{
$t('profile.network.andMore', {
number: user.followedByCount - user.followedBy.length,
})
}}
</ds-text>
</ds-space>
</template>
@ -160,33 +166,33 @@
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'post' }">
<a @click="handleTab('post')">
<ds-space margin="small">
<no-ssr placeholder="Loading...">
<client-only placeholder="Loading...">
<ds-number :label="$t('common.post', null, user.contributionsCount)">
<hc-count-to slot="count" :end-val="user.contributionsCount" />
</ds-number>
</no-ssr>
</client-only>
</ds-space>
</a>
</li>
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'comment' }">
<a @click="handleTab('comment')">
<ds-space margin="small">
<no-ssr placeholder="Loading...">
<client-only placeholder="Loading...">
<ds-number :label="$t('profile.commented')">
<hc-count-to slot="count" :end-val="user.commentedCount" />
</ds-number>
</no-ssr>
</client-only>
</ds-space>
</a>
</li>
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'shout' }">
<a @click="handleTab('shout')">
<ds-space margin="small">
<no-ssr placeholder="Loading...">
<client-only placeholder="Loading...">
<ds-number :label="$t('profile.shouted')">
<hc-count-to slot="count" :end-val="user.shoutedCount" />
</ds-number>
</no-ssr>
</client-only>
</ds-space>
</a>
</li>
@ -195,16 +201,22 @@
</ds-card>
</ds-grid-item>
<ds-grid-item :row-span="2" column-span="fullWidth" class="create-button">
<ds-button
v-if="myProfile"
v-tooltip="{ content: 'Create a new Post', placement: 'left', delay: { show: 500 } }"
:path="{ name: 'post-create' }"
class="profile-post-add-button"
icon="plus"
size="large"
primary
/>
<ds-grid-item :row-span="2" column-span="fullWidth">
<ds-space centered>
<ds-button
v-if="myProfile"
v-tooltip="{
content: 'Create a new Post',
placement: 'left',
delay: { show: 500 },
}"
:path="{ name: 'post-create' }"
class="profile-post-add-button"
icon="plus"
size="large"
primary
/>
</ds-space>
</ds-grid-item>
<template v-if="activePosts.length">
@ -232,8 +244,10 @@
<div
v-if="hasMore"
v-infinite-scroll="showMoreContributions"
infinite-scroll-disabled="$apollo.loading"
infinite-scroll-distance="10"
:infinite-scroll-disabled="$apollo.loading"
:infinite-scroll-distance="10"
:infinite-scroll-throttle-delay="800"
:infinite-scroll-immediate-check="true"
>
<hc-load-more :loading="$apollo.loading" @click="showMoreContributions" />
</div>
@ -429,10 +443,6 @@ export default {
.pointer {
cursor: pointer;
}
.create-button {
text-align: center;
margin: auto;
}
.Tab {
border-collapse: collapse;
padding-bottom: 5px;

View File

@ -13,7 +13,7 @@ Vue.component('nuxt-link', {
},
template: '<a href="#" @click.prevent="log()"><slot>NuxtLink</slot></a>',
})
Vue.component('no-ssr', {
Vue.component('client-only', {
render() {
return this.$slots.default
},

View File

@ -1383,12 +1383,12 @@
webpack-node-externals "^1.7.2"
webpackbar "^4.0.0"
"@nuxtjs/apollo@^4.0.0-rc11":
version "4.0.0-rc11"
resolved "https://registry.yarnpkg.com/@nuxtjs/apollo/-/apollo-4.0.0-rc11.tgz#1dce2c4cbcced3d2cbc218c04c2211b1baa8594a"
integrity sha512-I9fq11LGwOxGa4aVkX0q+wxz/zA4B3JE+v7GSsJYK01nUT9kQAfzxlQnb6KFZanpEIl13hfqlk3Jz7D1xo67hw==
"@nuxtjs/apollo@^4.0.0-rc12":
version "4.0.0-rc12"
resolved "https://registry.yarnpkg.com/@nuxtjs/apollo/-/apollo-4.0.0-rc12.tgz#4008c31a76b689b513292b39b9e4a6a04ef12edf"
integrity sha512-qHlDsvJoWYJAGt7ucWcFgPpFG89STyby8OoX07+mYCRV52tVaCdrLO95dFZx7k705hSYPzU8gBsMvJte3lEjjQ==
dependencies:
isomorphic-fetch "^2.2.1"
cross-fetch "^3.0.4"
universal-cookie "^4.0.2"
vue-apollo "^3.0.0-rc.2"
vue-cli-plugin-apollo "^0.21.0"
@ -3597,17 +3597,17 @@ babel-core@~7.0.0-bridge.0:
resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece"
integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==
babel-eslint@~10.0.2:
version "10.0.2"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.2.tgz#182d5ac204579ff0881684b040560fdcc1558456"
integrity sha512-UdsurWPtgiPgpJ06ryUnuaSXC2s0WoSZnQmEpbAH65XZSdwowgN5MvyP7e88nW07FYXv72erVtpBkxyDVKhH1Q==
babel-eslint@~10.0.3:
version "10.0.3"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.3.tgz#81a2c669be0f205e19462fed2482d33e4687a88a"
integrity sha512-z3U7eMY6r/3f3/JB9mTsLjyxrv0Yb1zb8PCWCLpguxfCzBIZUwy23R1t/XKewP+8mEN2Ck8Dtr4q20z6ce6SoA==
dependencies:
"@babel/code-frame" "^7.0.0"
"@babel/parser" "^7.0.0"
"@babel/traverse" "^7.0.0"
"@babel/types" "^7.0.0"
eslint-scope "3.7.1"
eslint-visitor-keys "^1.0.0"
resolve "^1.12.0"
babel-helper-evaluate-path@^0.5.0:
version "0.5.0"
@ -5377,6 +5377,14 @@ cross-env@~5.2.0:
cross-spawn "^6.0.5"
is-windows "^1.0.0"
cross-fetch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.4.tgz#7bef7020207e684a7638ef5f2f698e24d9eb283c"
integrity sha512-MSHgpjQqgbT/94D4CyADeNoYh52zMkCX4pcJvPP5WqPsLFMKjr2TCMg381ox5qI0ii2dPwaLx/00477knXqXVw==
dependencies:
node-fetch "2.6.0"
whatwg-fetch "3.0.0"
cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@ -5703,10 +5711,10 @@ data-urls@^1.0.0:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
date-fns@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0"
integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w==
date-fns@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.1.tgz#c5f30e31d3294918e6b6a82753a4e719120e203d"
integrity sha512-C14oTzTZy8DH1Eq8N78owrCWvf3+cnJw88BTK/N3DYWVxDJuJzPaNdplzYxDYuuXXGvqBcO4Vy5SOrwAooXSWw==
date-fns@^1.27.2:
version "1.30.1"
@ -6351,16 +6359,15 @@ eslint-import-resolver-node@^0.3.2:
debug "^2.6.9"
resolve "^1.5.0"
eslint-loader@~2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/eslint-loader/-/eslint-loader-2.2.1.tgz#28b9c12da54057af0845e2a6112701a2f6bf8337"
integrity sha512-RLgV9hoCVsMLvOxCuNjdqOrUqIj9oJg8hF44vzJaYqsAHuY9G2YAeN3joQ9nxP0p5Th9iFSIpKo+SD8KISxXRg==
eslint-loader@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/eslint-loader/-/eslint-loader-3.0.0.tgz#fb70bc2d552a674f43f07f5e6575083e565e790d"
integrity sha512-rdxyQ0i9VlhwVlR6oEzrIft8WNKYSD2/cOAJ1YVH/F76gAta7Zv1Dr5xJOUyx0fAsHB5cKNz9hwlUVLMFsQlPA==
dependencies:
loader-fs-cache "^1.0.0"
loader-utils "^1.0.2"
object-assign "^4.0.1"
object-hash "^1.1.4"
rimraf "^2.6.1"
loader-fs-cache "^1.0.2"
loader-utils "^1.2.3"
object-hash "^1.3.1"
schema-utils "^2.1.0"
eslint-module-utils@^2.4.0:
version "2.4.0"
@ -6438,14 +6445,6 @@ eslint-plugin-vue@~5.2.3:
dependencies:
vue-eslint-parser "^5.0.0"
eslint-scope@3.7.1:
version "3.7.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8"
integrity sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=
dependencies:
esrecurse "^4.1.0"
estraverse "^4.1.1"
eslint-scope@^4.0.0, eslint-scope@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848"
@ -6455,14 +6454,16 @@ eslint-scope@^4.0.0, eslint-scope@^4.0.3:
estraverse "^4.1.1"
eslint-utils@^1.3.0, eslint-utils@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512"
integrity sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==
version "1.4.2"
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab"
integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q==
dependencies:
eslint-visitor-keys "^1.0.0"
eslint-visitor-keys@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==
version "1.1.0"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
eslint@~5.16.0:
version "5.16.0"
@ -7624,10 +7625,10 @@ graphql-upload@^8.0.2:
http-errors "^1.7.2"
object-path "^0.11.4"
"graphql@14.0.2 - 14.2.0 || ^14.3.1", graphql@^14.4.0, graphql@~14.5.0:
version "14.5.0"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.0.tgz#4801e6460942c9c591944617f6dd224a9e531520"
integrity sha512-wnGcTD181L2xPnIwHHjx/moV4ulxA2Kms9zcUY+B/SIrK+2N+iOC6WNgnR2zVTmg1Z8P+CZq5KXibTnatg3WUw==
"graphql@14.0.2 - 14.2.0 || ^14.3.1", graphql@^14.4.0, graphql@~14.5.3:
version "14.5.3"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.3.tgz#e025851cc413e153220f4edbbb25d49f55104fa0"
integrity sha512-W8A8nt9BsMg0ZK2qA3DJIVU6muWhxZRYLTmc+5XGwzWzVdUdPVlAAg5hTBjiTISEnzsKL/onasu6vl3kgGTbYg==
dependencies:
iterall "^1.2.2"
@ -8780,7 +8781,7 @@ isobject@^4.0.0:
resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0"
integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==
isomorphic-fetch@^2.1.1, isomorphic-fetch@^2.2.1:
isomorphic-fetch@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9"
integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=
@ -9644,7 +9645,7 @@ load-json-file@^5.2.0:
strip-bom "^3.0.0"
type-fest "^0.3.0"
loader-fs-cache@^1.0.0:
loader-fs-cache@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/loader-fs-cache/-/loader-fs-cache-1.0.2.tgz#54cedf6b727e1779fd8f01205f05f6e88706f086"
integrity sha512-70IzT/0/L+M20jUlEqZhZyArTU6VKLRTYRDAYN26g4jfzpJqjipLL3/hgYpySqI9PwsVRHHFja0LfEmsx9X2Cw==
@ -10233,9 +10234,9 @@ mississippi@^3.0.0:
through2 "^2.0.0"
mixin-deep@^1.2.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==
version "1.3.2"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
dependencies:
for-in "^1.0.2"
is-extendable "^1.0.1"
@ -10368,6 +10369,11 @@ node-cache@^4.1.1:
clone "2.x"
lodash "4.x"
node-fetch@2.6.0, node-fetch@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-fetch@^1.0.1:
version "1.7.3"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
@ -10381,11 +10387,6 @@ node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0:
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.5.0.tgz#8028c49fc1191bba56a07adc6e2a954644a48501"
integrity sha512-YuZKluhWGJwCcUu4RlZstdAxr8bFfOVHakc1mplwHkk8J+tqM1Y5yraYvIUpeX8aY7+crCwiELJq7Vl0o0LWXw==
node-fetch@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-gyp@^3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c"
@ -10760,7 +10761,7 @@ object-copy@^0.1.0:
define-property "^0.2.5"
kind-of "^3.0.3"
object-hash@^1.1.4:
object-hash@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-1.3.1.tgz#fde452098a951cb145f039bb7d455449ddc126df"
integrity sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA==
@ -13182,20 +13183,13 @@ resolve@1.1.7:
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=
resolve@^1.1.6:
resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.2.0, resolve@^1.3.2, resolve@^1.5.0, resolve@^1.8.1:
version "1.12.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6"
integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==
dependencies:
path-parse "^1.0.6"
resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.2.0, resolve@^1.3.2, resolve@^1.5.0, resolve@^1.8.1:
version "1.11.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.0.tgz#4014870ba296176b86343d50b60f3b50609ce232"
integrity sha512-WL2pBDjqT6pGUNSUzMw00o4T7If+z4H2x3Gz893WoUQ5KW8Vr9txp00ykiP16VBaZF5+j/OcXJHZ9+PCvdiDKw==
dependencies:
path-parse "^1.0.6"
responselike@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
@ -13379,7 +13373,7 @@ schema-utils@^1.0.0:
ajv-errors "^1.0.0"
ajv-keywords "^3.1.0"
schema-utils@^2.0.0:
schema-utils@^2.0.0, schema-utils@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.1.0.tgz#940363b6b1ec407800a22951bdcc23363c039393"
integrity sha512-g6SViEZAfGNrToD82ZPUjq52KUPDYc+fN5+g6Euo5mLokl/9Yx14z0Cu4RR1m55HtBXejO0sBt+qw79axN+Fiw==
@ -15543,7 +15537,7 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3:
dependencies:
iconv-lite "0.4.24"
whatwg-fetch@>=0.10.0:
whatwg-fetch@3.0.0, whatwg-fetch@>=0.10.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==

View File

@ -1847,10 +1847,10 @@ cucumber@^4.2.1:
util-arity "^1.0.2"
verror "^1.9.0"
cypress-cucumber-preprocessor@^1.14.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.14.1.tgz#47a6ddd28ca1b0a4ec24707b20ca5c0d7ffc7875"
integrity sha512-72RN3sUeq0r4aNiFfztQD6Uvi58fifkfc2R4pCyX6oTEZLfHfcudkNQLd98ByCyiWXNXPHlGGihE6+EPCDK59A==
cypress-cucumber-preprocessor@^1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.15.0.tgz#bc2bbad5799dcc07798a0f8f1d374b99e0d3bbab"
integrity sha512-6exZ4cPruFcx55INZtxGnUwoGuzcAq+8T1k7NaCC2g36mdweltGjtjIGuHy4lk+VQaW6zmLV6zDtuxrEQsFC/w==
dependencies:
"@cypress/browserify-preprocessor" "^1.1.2"
chai "^4.1.2"
@ -3480,9 +3480,9 @@ minizlib@^1.1.1:
minipass "^2.2.1"
mixin-deep@^1.2.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==
version "1.3.2"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
dependencies:
for-in "^1.0.2"
is-extendable "^1.0.1"