Merge branch 'master' of github.com:Human-Connection/Human-Connection into 1395-hashtags-imported-with-not-allowed-chars

This commit is contained in:
Matt Rider 2019-08-27 16:45:35 +02:00
commit 6892df5c7f
48 changed files with 1123 additions and 615 deletions

View File

@ -61,7 +61,7 @@
"dotenv": "~8.1.0", "dotenv": "~8.1.0",
"express": "^4.17.1", "express": "^4.17.1",
"faker": "Marak/faker.js#master", "faker": "Marak/faker.js#master",
"graphql": "^14.5.0", "graphql": "^14.5.3",
"graphql-custom-directives": "~0.2.14", "graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1", "graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.5", "graphql-middleware": "~3.0.5",

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,7 +12,8 @@ import user from './userMiddleware'
import includedFields from './includedFieldsMiddleware' import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware' import orderBy from './orderByMiddleware'
import validation from './validation/validationMiddleware' 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 email from './email/emailMiddleware'
import sentry from './sentryMiddleware' import sentry from './sentryMiddleware'
@ -25,13 +26,16 @@ export default schema => {
validation, validation,
sluggify, sluggify,
excerpt, excerpt,
handleContentData, notifications,
hashtags,
xss, xss,
softDelete, softDelete,
user, user,
includedFields, includedFields,
orderBy, orderBy,
email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }), email: email({
isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT,
}),
} }
let order = [ let order = [
@ -43,7 +47,8 @@ export default schema => {
'sluggify', 'sluggify',
'excerpt', 'excerpt',
'email', 'email',
'handleContentData', 'notifications',
'hashtags',
'xss', 'xss',
'softDelete', 'softDelete',
'user', '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

@ -1,9 +1,26 @@
import uuid from 'uuid/v4' import uuid from 'uuid/v4'
module.exports = { module.exports = {
id: { type: 'uuid', primary: true, default: uuid }, id: {
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, type: 'uuid',
read: { type: 'boolean', default: false }, 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: { user: {
type: 'relationship', type: 'relationship',
relationship: 'NOTIFIED', relationship: 'NOTIFIED',

View File

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

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 { type Notification {
id: ID! id: ID!
read: Boolean read: Boolean
reason: ReasonNotification
createdAt: String
user: User @relation(name: "NOTIFIED", direction: "OUT") user: User @relation(name: "NOTIFIED", direction: "OUT")
post: Post @relation(name: "NOTIFIED", direction: "IN") post: Post @relation(name: "NOTIFIED", direction: "IN")
comment: Comment @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, lastResponse: null,
neodeInstance, neodeInstance,
async authenticateAs({ email, password }) { async authenticateAs({ email, password }) {
const headers = await authenticatedHeaders({ email, password }, seedServerHost) const headers = await authenticatedHeaders(
{
email,
password,
},
seedServerHost,
)
this.lastResponse = headers this.lastResponse = headers
this.graphQLClient = new GraphQLClient(seedServerHost, { headers }) this.graphQLClient = new GraphQLClient(seedServerHost, {
headers,
})
return this return this
}, },
async create(node, args = {}) { async create(node, args = {}) {
const { factory, mutation, variables } = this.factories[node](args) const { factory, mutation, variables } = this.factories[node](args)
if (factory) { if (factory) {
this.lastResponse = await factory({ args, neodeInstance }) this.lastResponse = await factory({
args,
neodeInstance,
})
return this.lastResponse return this.lastResponse
} else { } else {
this.lastResponse = await this.graphQLClient.request(mutation, variables) this.lastResponse = await this.graphQLClient.request(mutation, variables)
@ -121,11 +132,15 @@ export default function Factory(options = {}) {
}, },
async invite({ email }) { async invite({ email }) {
const mutation = ` mutation($email: String!) { invite( email: $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 return this
}, },
async cleanDatabase() { async cleanDatabase() {
this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver }) this.lastResponse = await cleanDatabase({
driver: this.neo4jDriver,
})
return this return this
}, },
async emote({ to, data }) { async emote({ to, data }) {

View File

@ -4193,10 +4193,10 @@ graphql-upload@^8.0.2:
http-errors "^1.7.2" http-errors "^1.7.2"
object-path "^0.11.4" object-path "^0.11.4"
graphql@^14.2.1, graphql@^14.5.0: graphql@^14.2.1, graphql@^14.5.3:
version "14.5.0" version "14.5.3"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.0.tgz#4801e6460942c9c591944617f6dd224a9e531520" resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.3.tgz#e025851cc413e153220f4edbbb25d49f55104fa0"
integrity sha512-wnGcTD181L2xPnIwHHjx/moV4ulxA2Kms9zcUY+B/SIrK+2N+iOC6WNgnR2zVTmg1Z8P+CZq5KXibTnatg3WUw== integrity sha512-W8A8nt9BsMg0ZK2qA3DJIVU6muWhxZRYLTmc+5XGwzWzVdUdPVlAAg5hTBjiTISEnzsKL/onasu6vl3kgGTbYg==
dependencies: dependencies:
iterall "^1.2.2" iterall "^1.2.2"

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ localVue.use(Vuex)
localVue.use(Styleguide) localVue.use(Styleguide)
localVue.use(Filters) 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['nuxt-link'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>' config.stubs['v-popover'] = '<span><slot /></span>'

View File

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

View File

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

View File

@ -2,7 +2,7 @@
<ds-form v-model="form" @submit="handleSubmit"> <ds-form v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }"> <template slot-scope="{ errors }">
<ds-card> <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" /> <hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<ds-space /> <ds-space />
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }"> <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(Styleguide)
localVue.use(Filters) localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>' config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>' config.stubs['v-popover'] = '<span><slot /></span>'
describe('PostCard', () => { describe('PostCard', () => {

View File

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

View File

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

View File

@ -8,16 +8,17 @@ const localVue = createLocalVue()
localVue.use(Styleguide) localVue.use(Styleguide)
localVue.use(Filters) localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>' config.stubs['client-only'] = '<span><slot /></span>'
describe('Notification', () => { describe('Notification', () => {
let stubs let stubs
let mocks let mocks
let propsData let propsData
let wrapper
beforeEach(() => { beforeEach(() => {
propsData = {} propsData = {}
mocks = { mocks = {
$t: jest.fn(), $t: key => key,
} }
stubs = { stubs = {
NuxtLink: RouterLinkStub, NuxtLink: RouterLinkStub,
@ -33,37 +34,159 @@ describe('Notification', () => {
}) })
} }
describe('given a notification', () => { describe('given a notification about a comment on a post', () => {
beforeEach(() => { beforeEach(() => {
propsData.notification = { propsData.notification = {
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: { post: {
title: "It's a title", title: "It's a post title",
id: 'post-1', id: 'post-1',
slug: 'its-a-title', slug: 'its-a-title',
contentExcerpt: '<a href="/profile/u3" target="_blank">@jenny-rostock</a> is the best', 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', () => { 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', () => { 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"', () => { it('has no class "read"', () => {
expect(Wrapper().classes()).not.toContain('read') wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
}) })
describe('that is read', () => { describe('that is read', () => {
beforeEach(() => { beforeEach(() => {
propsData.notification.read = true propsData.notification.read = true
wrapper = Wrapper()
}) })
it('has class "read"', () => { 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> <template>
<ds-space :class="[{ read: notification.read }, notification]" margin-bottom="x-small"> <ds-space :class="[{ read: notification.read }, notification]" margin-bottom="x-small">
<no-ssr> <client-only>
<ds-space margin-bottom="x-small"> <ds-space margin-bottom="x-small">
<hc-user <hc-user
v-if="resourceType == 'Post'" v-if="resourceType == 'Post'"
@ -10,10 +10,10 @@
/> />
<hc-user v-else :user="comment.author" :date-time="comment.createdAt" :trunc="35" /> <hc-user v-else :user="comment.author" :date-time="comment.createdAt" :trunc="35" />
</ds-space> </ds-space>
<ds-text color="soft"> <ds-text class="reason-text-for-test" color="soft">
{{ $t('notifications.menu.mentioned', { resource: resourceType }) }} {{ $t(`notifications.menu.${notification.reason}`) }}
</ds-text> </ds-text>
</no-ssr> </client-only>
<ds-space margin-bottom="x-small" /> <ds-space margin-bottom="x-small" />
<nuxt-link <nuxt-link
class="notification-mention-post" class="notification-mention-post"

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@
"all": "Alle" "all": "Alle"
}, },
"general": { "general": {
"header": "Filtern nach..." "header": "Filtern nach"
}, },
"followers": { "followers": {
"label": "Benutzern, denen ich folge" "label": "Benutzern, denen ich folge"
@ -96,7 +96,7 @@
} }
}, },
"editor": { "editor": {
"placeholder": "Schreib etwas Inspirierendes...", "placeholder": "Schreib etwas Inspirierendes",
"mention": { "mention": {
"noUsersFound": "Keine Benutzer gefunden" "noUsersFound": "Keine Benutzer gefunden"
}, },
@ -132,7 +132,9 @@
}, },
"notifications": { "notifications": {
"menu": { "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": { "search": {
@ -304,7 +306,7 @@
}, },
"comment": { "comment": {
"content": { "content": {
"unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar" "unavailable-placeholder": "dieser Kommentar ist nicht mehr verfügbar"
}, },
"menu": { "menu": {
"edit": "Kommentar bearbeiten", "edit": "Kommentar bearbeiten",

View File

@ -14,7 +14,7 @@
"all": "All" "all": "All"
}, },
"general": { "general": {
"header": "Filter by..." "header": "Filter by"
}, },
"followers": { "followers": {
"label": "Users I follow" "label": "Users I follow"
@ -96,7 +96,7 @@
} }
}, },
"editor": { "editor": {
"placeholder": "Leave your inspirational thoughts...", "placeholder": "Leave your inspirational thoughts",
"mention": { "mention": {
"noUsersFound": "No users found" "noUsersFound": "No users found"
}, },
@ -132,7 +132,9 @@
}, },
"notifications": { "notifications": {
"menu": { "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": { "search": {
@ -304,7 +306,7 @@
}, },
"comment": { "comment": {
"content": { "content": {
"unavailable-placeholder": "...this comment is not available anymore" "unavailable-placeholder": "this comment is not available anymore"
}, },
"menu": { "menu": {
"edit": "Edit Comment", "edit": "Edit Comment",

View File

@ -61,7 +61,7 @@
"apollo-client": "~2.6.4", "apollo-client": "~2.6.4",
"cookie-universal-nuxt": "~2.0.17", "cookie-universal-nuxt": "~2.0.17",
"cross-env": "~5.2.0", "cross-env": "~5.2.0",
"date-fns": "2.0.0", "date-fns": "2.0.1",
"express": "~4.17.1", "express": "~4.17.1",
"graphql": "~14.5.3", "graphql": "~14.5.3",
"isemail": "^3.2.0", "isemail": "^3.2.0",

View File

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

View File

@ -15,7 +15,7 @@ localVue.use(Filters)
localVue.use(VTooltip) localVue.use(VTooltip)
localVue.use(InfiniteScroll) 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['router-link'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>' config.stubs['nuxt-link'] = '<span><slot /></span>'

View File

@ -36,7 +36,7 @@
</ds-grid-item> </ds-grid-item>
</template> </template>
</masonry-grid> </masonry-grid>
<no-ssr> <client-only>
<ds-button <ds-button
v-tooltip="{ content: 'Create a new Post', placement: 'left', delay: { show: 500 } }" v-tooltip="{ content: 'Create a new Post', placement: 'left', delay: { show: 500 } }"
:path="{ name: 'post-create' }" :path="{ name: 'post-create' }"
@ -45,7 +45,7 @@
size="x-large" size="x-large"
primary primary
/> />
</no-ssr> </client-only>
<div <div
v-if="hasMore" v-if="hasMore"
v-infinite-scroll="showMoreContributions" v-infinite-scroll="showMoreContributions"

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ localVue.use(Filters)
localVue.use(InfiniteScroll) localVue.use(InfiniteScroll)
localVue.filter('date', d => d) 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['v-popover'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>' config.stubs['nuxt-link'] = '<span><slot /></span>'

View File

@ -15,7 +15,7 @@
</hc-upload> </hc-upload>
<hc-avatar v-else :user="user" class="profile-avatar" size="x-large" /> <hc-avatar v-else :user="user" class="profile-avatar" size="x-large" />
<!-- Menu --> <!-- Menu -->
<no-ssr> <client-only>
<content-menu <content-menu
placement="bottom-end" placement="bottom-end"
resource-type="user" resource-type="user"
@ -25,7 +25,7 @@
@block="block" @block="block"
@unblock="unblock" @unblock="unblock"
/> />
</no-ssr> </client-only>
<ds-space margin="small"> <ds-space margin="small">
<ds-heading tag="h3" align="center" no-margin>{{ userName }}</ds-heading> <ds-heading tag="h3" align="center" no-margin>{{ userName }}</ds-heading>
<ds-text v-if="user.location" align="center" color="soft" size="small"> <ds-text v-if="user.location" align="center" color="soft" size="small">
@ -41,18 +41,18 @@
</ds-space> </ds-space>
<ds-flex> <ds-flex>
<ds-flex-item> <ds-flex-item>
<no-ssr> <client-only>
<ds-number :label="$t('profile.followers')"> <ds-number :label="$t('profile.followers')">
<hc-count-to slot="count" :end-val="user.followedByCount" /> <hc-count-to slot="count" :end-val="user.followedByCount" />
</ds-number> </ds-number>
</no-ssr> </client-only>
</ds-flex-item> </ds-flex-item>
<ds-flex-item> <ds-flex-item>
<no-ssr> <client-only>
<ds-number :label="$t('profile.following')"> <ds-number :label="$t('profile.following')">
<hc-count-to slot="count" :end-val="user.followingCount" /> <hc-count-to slot="count" :end-val="user.followingCount" />
</ds-number> </ds-number>
</no-ssr> </client-only>
</ds-flex-item> </ds-flex-item>
</ds-flex> </ds-flex>
<ds-space margin="small"> <ds-space margin="small">
@ -89,9 +89,9 @@
<template v-if="user.following && user.following.length"> <template v-if="user.following && user.following.length">
<ds-space v-for="follow in uniq(user.following)" :key="follow.id" margin="x-small"> <ds-space v-for="follow in uniq(user.following)" :key="follow.id" margin="x-small">
<!-- TODO: find better solution for rendering errors --> <!-- TODO: find better solution for rendering errors -->
<no-ssr> <client-only>
<user :user="follow" :trunc="15" /> <user :user="follow" :trunc="15" />
</no-ssr> </client-only>
</ds-space> </ds-space>
<ds-space v-if="user.followingCount - user.following.length" margin="small"> <ds-space v-if="user.followingCount - user.following.length" margin="small">
<ds-text size="small" color="softer"> <ds-text size="small" color="softer">
@ -119,9 +119,9 @@
<template v-if="user.followedBy && user.followedBy.length"> <template v-if="user.followedBy && user.followedBy.length">
<ds-space v-for="follow in uniq(user.followedBy)" :key="follow.id" margin="x-small"> <ds-space v-for="follow in uniq(user.followedBy)" :key="follow.id" margin="x-small">
<!-- TODO: find better solution for rendering errors --> <!-- TODO: find better solution for rendering errors -->
<no-ssr> <client-only>
<user :user="follow" :trunc="15" /> <user :user="follow" :trunc="15" />
</no-ssr> </client-only>
</ds-space> </ds-space>
<ds-space v-if="user.followedByCount - user.followedBy.length" margin="small"> <ds-space v-if="user.followedByCount - user.followedBy.length" margin="small">
<ds-text size="small" color="softer"> <ds-text size="small" color="softer">
@ -166,33 +166,33 @@
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'post' }"> <li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'post' }">
<a @click="handleTab('post')"> <a @click="handleTab('post')">
<ds-space margin="small"> <ds-space margin="small">
<no-ssr placeholder="Loading..."> <client-only placeholder="Loading...">
<ds-number :label="$t('common.post', null, user.contributionsCount)"> <ds-number :label="$t('common.post', null, user.contributionsCount)">
<hc-count-to slot="count" :end-val="user.contributionsCount" /> <hc-count-to slot="count" :end-val="user.contributionsCount" />
</ds-number> </ds-number>
</no-ssr> </client-only>
</ds-space> </ds-space>
</a> </a>
</li> </li>
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'comment' }"> <li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'comment' }">
<a @click="handleTab('comment')"> <a @click="handleTab('comment')">
<ds-space margin="small"> <ds-space margin="small">
<no-ssr placeholder="Loading..."> <client-only placeholder="Loading...">
<ds-number :label="$t('profile.commented')"> <ds-number :label="$t('profile.commented')">
<hc-count-to slot="count" :end-val="user.commentedCount" /> <hc-count-to slot="count" :end-val="user.commentedCount" />
</ds-number> </ds-number>
</no-ssr> </client-only>
</ds-space> </ds-space>
</a> </a>
</li> </li>
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'shout' }"> <li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'shout' }">
<a @click="handleTab('shout')"> <a @click="handleTab('shout')">
<ds-space margin="small"> <ds-space margin="small">
<no-ssr placeholder="Loading..."> <client-only placeholder="Loading...">
<ds-number :label="$t('profile.shouted')"> <ds-number :label="$t('profile.shouted')">
<hc-count-to slot="count" :end-val="user.shoutedCount" /> <hc-count-to slot="count" :end-val="user.shoutedCount" />
</ds-number> </ds-number>
</no-ssr> </client-only>
</ds-space> </ds-space>
</a> </a>
</li> </li>

View File

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

View File

@ -5711,10 +5711,10 @@ data-urls@^1.0.0:
whatwg-mimetype "^2.2.0" whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0" whatwg-url "^7.0.0"
date-fns@2.0.0: date-fns@2.0.1:
version "2.0.0" version "2.0.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.1.tgz#c5f30e31d3294918e6b6a82753a4e719120e203d"
integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w== integrity sha512-C14oTzTZy8DH1Eq8N78owrCWvf3+cnJw88BTK/N3DYWVxDJuJzPaNdplzYxDYuuXXGvqBcO4Vy5SOrwAooXSWw==
date-fns@^1.27.2: date-fns@^1.27.2:
version "1.30.1" version "1.30.1"