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

This commit is contained in:
Wolfgang Huß 2019-08-29 09:29:36 +02:00
commit fe2d21c5c2
63 changed files with 1737 additions and 1254 deletions

View File

@ -61,7 +61,7 @@
"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",
@ -116,7 +116,7 @@
"babel-jest": "~24.9.0",
"chai": "~4.2.0",
"cucumber": "~5.1.0",
"eslint": "~6.2.1",
"eslint": "~6.2.2",
"eslint-config-prettier": "~6.1.0",
"eslint-config-standard": "~14.0.1",
"eslint-plugin-import": "~2.18.2",

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 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'
@ -25,13 +26,16 @@ export default schema => {
validation,
sluggify,
excerpt,
handleContentData,
notifications,
hashtags,
xss,
softDelete,
user,
includedFields,
orderBy,
email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }),
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

@ -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

@ -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

@ -1407,7 +1407,7 @@ acorn-globals@^4.1.0:
acorn "^6.0.1"
acorn-walk "^6.0.1"
acorn-jsx@^5.0.0:
acorn-jsx@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.2.tgz#84b68ea44b373c4f8686023a551f61a21b7c4a4f"
integrity sha512-tiNTrP1MP0QrChmD2DdupCr6HWSFeKVw5d/dHTu4Y7rkAkRhU/Dt7dphAfIUyxtHpl/eBVip5uTNSpQJHylpAw==
@ -3395,10 +3395,10 @@ eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^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@~6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.2.1.tgz#66c2e4fe8b6356b9f01e828adc3ad04030122df1"
integrity sha512-ES7BzEzr0Q6m5TK9i+/iTpKjclXitOdDK4vT07OqbkBT2/VcN/gO9EL1C4HlK3TAOXYv2ItcmbVR9jO1MR0fJg==
eslint@~6.2.2:
version "6.2.2"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.2.2.tgz#03298280e7750d81fcd31431f3d333e43d93f24f"
integrity sha512-mf0elOkxHbdyGX1IJEUsNBzCDdyoUgljF3rRlgfyYh0pwGnreLc0jjD6ZuleOibjmnUWZLY2eXwSooeOgGJ2jw==
dependencies:
"@babel/code-frame" "^7.0.0"
ajv "^6.10.0"
@ -3409,7 +3409,7 @@ eslint@~6.2.1:
eslint-scope "^5.0.0"
eslint-utils "^1.4.2"
eslint-visitor-keys "^1.1.0"
espree "^6.1.0"
espree "^6.1.1"
esquery "^1.0.1"
esutils "^2.0.2"
file-entry-cache "^5.0.1"
@ -3438,13 +3438,13 @@ eslint@~6.2.1:
text-table "^0.2.0"
v8-compile-cache "^2.0.3"
espree@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.0.tgz#a1e8aa65bf29a331d70351ed814a80e7534e0884"
integrity sha512-boA7CHRLlVWUSg3iL5Kmlt/xT3Q+sXnKoRYYzj1YeM10A76TEJBbotV5pKbnK42hEUIr121zTv+QLRM5LsCPXQ==
espree@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.1.tgz#7f80e5f7257fc47db450022d723e356daeb1e5de"
integrity sha512-EYbr8XZUhWbYCqQRW0duU5LxzL5bETN6AjKBGy1302qqzPaCH10QbRg3Wvco79Z8x9WbiE8HYB4e75xl6qUYvQ==
dependencies:
acorn "^7.0.0"
acorn-jsx "^5.0.0"
acorn-jsx "^5.0.2"
eslint-visitor-keys "^1.1.0"
esprima@^3.1.3:
@ -4193,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"

View File

@ -1,6 +1,16 @@
# End-to-End Testing
## Configure cypress
## Setup with docker
Are you running everything through docker? You're so lucky you don't have to
setup anything!
Just:
```
docker-compose up
```
## Setup without docker
First, you have to tell cypress how to connect to your local neo4j database
among other things. You can copy our template configuration and change the new
@ -11,16 +21,15 @@ Make sure you are at the root level of the project. Then:
# in the top level folder Human-Connection/
$ cp cypress.env.template.json cypress.env.json
```
## Run Tests
To run the tests, do this:
To start the services that are required for cypress testing, run this:
```bash
# in the top level folder Human-Connection/
$ yarn cypress:setup
```
## Run cypress
After verifying that there are no errors with the servers starting, open another tab in your terminal and run the following command:
```bash
@ -29,13 +38,12 @@ $ yarn cypress:run
![Console output after running cypress test](../.gitbook/assets/grafik%20%281%29.png)
After the test runs, you will also get some video footage of the test run which you can then analyse in more detail.
## Open Interactive Test Console
### Open Interactive Test Console
If you are like me, you might want to see some visual output. The interactive cypress environment also helps at debugging your tests, you can even time travel between individual steps and see the exact state of the app.
To use this feature, you will still run the `yarn cypress:setup` above, but instead of `yarn cypress:run` open another tab in your terminal and run the following command:
To use this feature, instead of `yarn cypress:run` you would run the following command:
```bash
$ yarn cypress:open

View File

@ -7,20 +7,21 @@ metadata:
kubernetes.io/ingress.class: "nginx"
certmanager.k8s.io/issuer: "letsencrypt-staging"
certmanager.k8s.io/acme-challenge-type: http01
nginx.ingress.kubernetes.io/proxy-body-size: 6m
spec:
tls:
- hosts:
# - nitro-mailserver.human-connection.org
- nitro-staging.human-connection.org
secretName: tls
- hosts:
# - nitro-mailserver.human-connection.org
- nitro-staging.human-connection.org
secretName: tls
rules:
- host: nitro-staging.human-connection.org
http:
paths:
- path: /
backend:
serviceName: nitro-web
servicePort: 3000
- host: nitro-staging.human-connection.org
http:
paths:
- path: /
backend:
serviceName: nitro-web
servicePort: 3000
# - host: nitro-mailserver.human-connection.org
# http:
# paths:

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

@ -12,6 +12,6 @@
# On Windows this resolves to C:\Users\dornhoeschen\AppData\Local\Temp\mongo-export (MinGW)
EXPORT_PATH='/tmp/mongo-export/'
EXPORT_MONGOEXPORT_BIN='mongoexport'
MONGO_EXPORT_SPLIT_SIZE=100
MONGO_EXPORT_SPLIT_SIZE=4000
# On Windows use something like this
# EXPORT_MONGOEXPORT_BIN='C:\Program Files\MongoDB\Server\3.6\bin\mongoexport.exe'

View File

@ -16,6 +16,30 @@ services:
- webapp_node_modules:/nitro-web/node_modules
command: yarn run dev
user: root
factories:
image: humanconnection/nitro-backend:builder
build:
context: backend
target: builder
ports:
- 4001:4001
networks:
- hc-network
volumes:
- ./backend:/nitro-backend
- factories_node_modules:/nitro-backend/node_modules
- uploads:/nitro-backend/public/uploads
depends_on:
- neo4j
environment:
- NEO4J_URI=bolt://neo4j:7687
- GRAPHQL_PORT=4000
- GRAPHQL_URI=http://localhost:4000
- CLIENT_URI=http://localhost:3000
- JWT_SECRET=b/&&7b78BF&fv/Vd
- MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ
- PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78
command: yarn run test:before:seeder
backend:
image: humanconnection/nitro-backend:builder
build:
@ -42,5 +66,6 @@ services:
volumes:
webapp_node_modules:
backend_node_modules:
factories_node_modules:
neo4j-data:
uploads:

View File

@ -23,7 +23,7 @@
"codecov": "^3.5.0",
"cross-env": "^5.2.0",
"cypress": "^3.4.1",
"cypress-cucumber-preprocessor": "^1.15.0",
"cypress-cucumber-preprocessor": "^1.15.1",
"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">
@ -62,12 +62,13 @@
</template>
<script>
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex'
import HcUser from '~/components/User'
import ContentMenu from '~/components/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer'
import HcEditCommentForm from '~/components/EditCommentForm/EditCommentForm'
import CommentMutations from '~/graphql/CommentMutations'
import PostQuery from '~/graphql/PostQuery'
export default {
data: function() {
@ -142,16 +143,23 @@ export default {
},
async deleteCommentCallback() {
try {
var gqlMutation = gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
await this.$apollo.mutate({
mutation: gqlMutation,
mutation: CommentMutations(this.$i18n).DeleteComment,
variables: { id: this.comment.id },
update: async store => {
const data = await store.readQuery({
query: PostQuery(this.$i18n),
variables: { id: this.post.id },
})
const index = data.Post[0].comments.findIndex(
deletedComment => deletedComment.id === this.comment.id,
)
if (index !== -1) {
data.Post[0].comments.splice(index, 1)
}
await store.writeQuery({ query: PostQuery(this.$i18n), data })
},
})
this.$toast.success(this.$t(`delete.comment.success`))
this.$emit('deleteComment')

View File

@ -80,13 +80,13 @@ export default {
postId: this.post.id,
content: this.form.content,
},
update: (store, { data: { CreateComment } }) => {
const data = store.readQuery({
update: async (store, { data: { CreateComment } }) => {
const data = await store.readQuery({
query: PostQuery(this.$i18n),
variables: { id: this.post.id },
})
data.Post[0].comments.push(CreateComment)
store.writeQuery({ query: PostQuery(this.$i18n), data })
await store.writeQuery({ query: PostQuery(this.$i18n), data })
},
})
.then(res => {

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,8 +22,8 @@
@input="updateEditorContent"
/>
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
</no-ssr>
<ds-space margin-bottom="xxx-large" />
</client-only>
<ds-space margin-bottom="small" />
<hc-categories-select
model="categoryIds"
@updateCategories="updateCategories"

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

@ -0,0 +1,95 @@
<script>
import tippy from 'tippy.js'
export default {
props: {
content: Object,
node: Object,
},
methods: {
displayContextMenu(target, content, type) {
const placement = type === 'link' ? 'right' : 'top-start'
const trigger = type === 'link' ? 'click' : 'mouseenter'
const showOnInit = type !== 'link'
if (this.menu) {
return
}
this.menu = tippy(target, {
arrow: true,
arrowType: 'round',
content: content,
duration: [400, 200],
inertia: true,
interactive: true,
placement,
showOnInit,
theme: 'human-connection',
trigger,
onMount(instance) {
const input = instance.popper.querySelector('input')
if (input) {
input.focus({ preventScroll: true })
}
},
})
// we have to update tippy whenever the DOM is updated
if (MutationObserver) {
this.observer = new MutationObserver(() => {
this.menu.popperInstance.scheduleUpdate()
})
this.observer.observe(content, {
childList: true,
subtree: true,
characterData: true,
})
}
},
hideContextMenu() {
if (this.menu) {
this.menu.destroy()
this.menu = null
}
if (this.observer) {
this.observer.disconnect()
}
},
},
render() {
return null
},
}
</script>
<style lang="scss">
.tippy-tooltip.human-connection-theme {
background-color: $color-primary;
padding: 0;
font-size: 1rem;
text-align: inherit;
color: $color-neutral-100;
.tippy-backdrop {
display: none;
}
.tippy-roundarrow {
fill: $color-primary;
}
.tippy-popper[x-placement^='top'] & .tippy-arrow {
border-top-color: $color-primary;
}
.tippy-popper[x-placement^='bottom'] & .tippy-arrow {
border-bottom-color: $color-primary;
}
.tippy-popper[x-placement^='left'] & .tippy-arrow {
border-left-color: $color-primary;
}
.tippy-popper[x-placement^='right'] & .tippy-arrow {
border-right-color: $color-primary;
}
}
</style>

View File

@ -1,203 +1,52 @@
<template>
<div class="editor">
<!-- Mention and Hashtag Suggestions Menu -->
<div v-show="showSuggestions" ref="suggestions" class="suggestion-list">
<!-- "filteredItems" array is not empty -->
<template v-if="hasResults">
<div
v-for="(item, index) in filteredItems"
:key="item.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedItemIndex === index }"
@click="selectItem(item)"
>
<div v-if="isMention">@{{ item.slug }}</div>
<div v-if="isHashtag">#{{ item.id }}</div>
</div>
<div v-if="isHashtag">
<!-- if query is not empty and is find fully in the suggestions array ... -->
<div v-if="query && !filteredItems.find(el => el.id === query)">
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addHashtag') }}</div>
<div class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</div>
</div>
<!-- otherwise if sanitized query is empty advice the user to add a char -->
<div v-else-if="!query">
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addLetter') }}</div>
</div>
</div>
</template>
<!-- if "!hasResults" -->
<div v-else>
<div v-if="isMention" class="suggestion-list__item is-empty">
{{ $t('editor.mention.noUsersFound') }}
</div>
<div v-if="isHashtag">
<div v-if="query === ''" class="suggestion-list__item is-empty">
{{ $t('editor.hashtag.noHashtagsFound') }}
</div>
<!-- if "query" is not empty -->
<div v-else>
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addHashtag') }}</div>
<div class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</div>
</div>
</div>
</div>
</div>
<editor-menu-bubble :editor="editor">
<div
ref="menu"
slot-scope="{ commands, getMarkAttrs, isActive, menu }"
class="menububble tooltip"
x-placement="top"
:class="{ 'is-active': menu.isActive || linkMenuIsActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<div class="tooltip-wrapper">
<template v-if="linkMenuIsActive">
<ds-input
ref="linkInput"
v-model="linkUrl"
class="editor-menu-link-input"
placeholder="http://"
@blur.native.capture="hideMenu(menu.isActive)"
@keydown.native.esc.prevent="hideMenu(menu.isActive)"
@keydown.native.enter.prevent="setLinkUrl(commands.link, linkUrl)"
/>
</template>
<template v-else>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.bold()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="commands.bold"
>
<ds-icon name="bold" />
</ds-button>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.italic()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="commands.italic"
>
<ds-icon name="italic" />
</ds-button>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.link()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="showLinkMenu(getMarkAttrs('link'))"
>
<ds-icon name="link" />
</ds-button>
</template>
</div>
<div class="tooltip-arrow" />
</div>
</editor-menu-bubble>
<editor-floating-menu :editor="editor">
<div
slot-scope="{ commands, isActive, menu }"
class="editor__floating-menu"
:class="{ 'is-active': menu.isActive }"
:style="`top: ${menu.top}px`"
>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.paragraph()"
@click.prevent="commands.paragraph()"
>
<ds-icon name="paragraph" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.heading({ level: 3 })"
@click.prevent="commands.heading({ level: 3 })"
>
H3
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.heading({ level: 4 })"
@click.prevent="commands.heading({ level: 4 })"
>
H4
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.bullet_list()"
@click.prevent="commands.bullet_list()"
>
<ds-icon name="list-ul" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.ordered_list()"
@click.prevent="commands.ordered_list()"
>
<ds-icon name="list-ol" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.blockquote()"
@click.prevent="commands.blockquote"
>
<ds-icon name="quote-right" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.horizontal_rule()"
@click.prevent="commands.horizontal_rule"
>
<ds-icon name="minus" />
</ds-button>
</div>
</editor-floating-menu>
<editor-content ref="editor" :editor="editor" />
<menu-bar :editor="editor" :toggleLinkInput="toggleLinkInput" />
<editor-content ref="editor" :editor="editor" class="ds-input editor-content" />
<context-menu ref="contextMenu" />
<suggestion-list
ref="suggestions"
:suggestion-type="suggestionType"
:filtered-items="filteredItems"
:navigated-item-index="navigatedItemIndex"
:query="query"
:select-item="selectItem"
/>
<link-input
v-show="isLinkInputActive"
ref="linkInput"
:toggle-link-input="toggleLinkInput"
:set-link-url="setLinkUrl"
/>
</div>
</template>
<script>
import defaultExtensions from './defaultExtensions.js'
import { mapGetters } from 'vuex'
import { Editor, EditorContent } from 'tiptap'
import { History } from 'tiptap-extensions'
import linkify from 'linkify-it'
import stringHash from 'string-hash'
import Fuse from 'fuse.js'
import tippy from 'tippy.js'
import { Editor, EditorContent, EditorFloatingMenu, EditorMenuBubble } from 'tiptap'
import * as key from '../../constants/keycodes'
import { HASHTAG, MENTION } from '../../constants/editor'
import defaultExtensions from './defaultExtensions.js'
import EventHandler from './plugins/eventHandler.js'
import { History } from 'tiptap-extensions'
import Hashtag from './nodes/Hashtag.js'
import Mention from './nodes/Mention.js'
import { mapGetters } from 'vuex'
import MenuBar from './MenuBar'
import ContextMenu from './ContextMenu'
import SuggestionList from './SuggestionList'
import LinkInput from './LinkInput'
let throttleInputEvent
export default {
components: {
ContextMenu,
EditorContent,
EditorFloatingMenu,
EditorMenuBubble,
LinkInput,
MenuBar,
SuggestionList,
},
props: {
users: { type: Array, default: () => null }, // If 'null', than the Mention extention is not assigned.
@ -206,189 +55,11 @@ export default {
doc: { type: Object, default: () => {} },
},
data() {
// Set array of optional extensions by analysing the props.
let optionalExtensions = []
// Don't change the following line. The functionallity is in danger!
if (this.users) {
optionalExtensions.push(
new Mention({
// a list of all suggested items
items: () => {
return this.users
},
// is called when a suggestion starts
onEnter: ({ items, query, range, command, virtualNode }) => {
this.suggestionType = this.mentionSuggestionType
this.query = query
this.filteredItems = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMentionOrHashtag = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = query
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
this.suggestionType = this.nullSuggestionType
// reset all saved values
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
if (!query) {
return items
}
const fuse = new Fuse(items, {
threshold: 0.2,
keys: ['slug'],
})
return fuse.search(query)
},
}),
)
}
// Don't change the following line. The functionallity is in danger!
if (this.hashtags) {
optionalExtensions.push(
new Hashtag({
// a list of all suggested items
items: () => {
return this.hashtags
},
// is called when a suggestion starts
onEnter: ({ items, query, range, command, virtualNode }) => {
this.suggestionType = this.hashtagSuggestionType
this.query = this.sanitizedQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMentionOrHashtag = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = this.sanitizedQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
this.suggestionType = this.nullSuggestionType
// reset all saved values
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
// pressing space
if (event.keyCode === 32) {
this.spaceHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
query = this.sanitizedQuery(query)
if (!query) {
return items
}
return items.filter(item =>
JSON.stringify(item)
.toLowerCase()
.includes(query.toLowerCase()),
)
},
}),
)
}
return {
lastValueHash: null,
editor: new Editor({
content: this.value || '',
doc: this.doc,
extensions: [
...defaultExtensions(this),
new EventHandler(),
new History(),
...optionalExtensions,
],
onUpdate: e => {
clearTimeout(throttleInputEvent)
throttleInputEvent = setTimeout(() => this.onUpdate(e), 300)
},
}),
linkUrl: null,
linkMenuIsActive: false,
nullSuggestionType: '',
mentionSuggestionType: 'mention',
hashtagSuggestionType: 'hashtag',
suggestionType: this.nullSuggestionType,
editor: null,
isLinkInputActive: false,
suggestionType: '',
query: null,
suggestionRange: null,
filteredItems: [],
@ -399,17 +70,39 @@ export default {
},
computed: {
...mapGetters({ placeholder: 'editor/placeholder' }),
hasResults() {
return this.filteredItems.length
},
showSuggestions() {
return this.query || this.hasResults
},
isMention() {
return this.suggestionType === this.mentionSuggestionType
},
isHashtag() {
return this.suggestionType === this.hashtagSuggestionType
optionalExtensions() {
const extensions = []
// Don't change the following line. The functionallity is in danger!
if (this.users) {
extensions.push(
new Mention({
items: () => {
return this.users
},
onEnter: props => this.openSuggestionList(props, MENTION),
onChange: this.updateSuggestionList,
onExit: this.closeSuggestionList,
onKeyDown: this.navigateSuggestionList,
onFilter: this.filterSuggestionList,
}),
)
}
// Don't change the following line. The functionallity is in danger!
if (this.hashtags) {
extensions.push(
new Hashtag({
items: () => {
return this.hashtags
},
onEnter: props => this.openSuggestionList(props, HASHTAG),
onChange: this.updateSuggestionList,
onExit: this.closeSuggestionList,
onKeyDown: this.navigateSuggestionList,
onFilter: this.filterSuggestionList,
}),
)
}
return extensions
},
},
watch: {
@ -421,52 +114,110 @@ export default {
return
}
this.lastValueHash = contentHash
this.editor.setContent(content)
this.$nextTick(() => this.editor.setContent(content))
},
},
placeholder: {
immediate: true,
handler: function(val) {
if (!val) {
if (!val || !this.editor) {
return
}
this.editor.extensions.options.placeholder.emptyNodeText = val
},
},
},
created() {
this.editor = new Editor({
content: this.value || '',
doc: this.doc,
extensions: [
...defaultExtensions(this),
new EventHandler(),
new History(),
...this.optionalExtensions,
],
onUpdate: e => {
clearTimeout(throttleInputEvent)
throttleInputEvent = setTimeout(() => this.onUpdate(e), 300)
},
})
},
beforeDestroy() {
this.editor.destroy()
},
methods: {
sanitizedQuery(query) {
// remove all not allowed chars
query = query.replace(/[^a-zA-Z0-9]/gm, '')
// if the query is only made of digits, make it empty
return query.replace(/[0-9]/gm, '') === '' ? '' : query
openSuggestionList({ items, query, range, command, virtualNode }, suggestionType) {
this.suggestionType = suggestionType
this.query = this.sanitizeQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.$refs.contextMenu.displayContextMenu(virtualNode, this.$refs.suggestions.$el)
this.insertMentionOrHashtag = command
},
// navigate to the previous item
// if it's the first item, navigate to the last one
upHandler() {
this.navigatedItemIndex =
(this.navigatedItemIndex + this.filteredItems.length - 1) % this.filteredItems.length
updateSuggestionList({ items, query, range, virtualNode }) {
this.query = this.sanitizeQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.$refs.contextMenu.displayContextMenu(virtualNode, this.$refs.suggestions.$el)
},
// navigate to the next item
// if it's the last item, navigate to the first one
downHandler() {
this.navigatedItemIndex = (this.navigatedItemIndex + 1) % this.filteredItems.length
closeSuggestionList() {
this.suggestionType = ''
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.$refs.contextMenu.hideContextMenu()
},
// Handles pressing of enter.
enterHandler() {
navigateSuggestionList({ event }) {
const item = this.filteredItems[this.navigatedItemIndex]
if (item) {
this.selectItem(item)
switch (event.keyCode) {
case key.ARROW_UP:
this.navigatedItemIndex =
(this.navigatedItemIndex + this.filteredItems.length - 1) % this.filteredItems.length
return true
case key.ARROW_DOWN:
this.navigatedItemIndex = (this.navigatedItemIndex + 1) % this.filteredItems.length
return true
case key.RETURN:
if (item) {
this.selectItem(item)
}
return true
case key.SPACE:
if (this.suggestionType === HASHTAG && this.query !== '') {
this.selectItem({ id: this.query })
}
return true
default:
return false
}
},
// For hashtags handles pressing of space.
spaceHandler() {
if (this.suggestionType === this.hashtagSuggestionType && this.query !== '') {
this.selectItem({ id: this.query })
filterSuggestionList(items, query) {
query = this.sanitizeQuery(query)
if (!query) {
return items
}
return items.filter(item => {
const itemString = item.slug || item.id
return itemString.toLowerCase().includes(query.toLowerCase())
})
},
sanitizeQuery(query) {
if (this.suggestionType === HASHTAG) {
// remove all not allowed chars
query = query.replace(/[^a-zA-Z0-9]/gm, '')
// if the query is only made of digits, make it empty
return query.replace(/[0-9]/gm, '') === '' ? '' : query
}
return query
},
// we have to replace our suggestion text with a mention
// so it's important to pass also the position of your suggestion text
@ -487,45 +238,6 @@ export default {
})
this.editor.focus()
},
// renders a popup with suggestions
// tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
renderPopup(node) {
if (this.popup) {
return
}
this.popup = tippy(node, {
content: this.$refs.suggestions,
trigger: 'mouseenter',
interactive: true,
theme: 'dark',
placement: 'top-start',
inertia: true,
duration: [400, 200],
showOnInit: true,
arrow: true,
arrowType: 'round',
})
// we have to update tippy whenever the DOM is updated
if (MutationObserver) {
this.observer = new MutationObserver(() => {
this.popup.popperInstance.scheduleUpdate()
})
this.observer.observe(this.$refs.suggestions, {
childList: true,
subtree: true,
characterData: true,
})
}
},
destroyPopup() {
if (this.popup) {
this.popup.destroy()
this.popup = null
}
if (this.observer) {
this.observer.disconnect()
}
},
onUpdate(e) {
const content = e.getHTML()
const contentHash = stringHash(content)
@ -534,36 +246,28 @@ export default {
this.$emit('input', content)
}
},
showLinkMenu(attrs) {
this.linkUrl = attrs.href
this.linkMenuIsActive = true
this.$nextTick(() => {
try {
const $el = this.$refs.linkInput.$el.getElementsByTagName('input')[0]
$el.focus()
$el.select()
} catch (err) {}
})
toggleLinkInput(attrs, element) {
if (!this.isLinkInputActive && attrs && element) {
this.$refs.linkInput.linkUrl = attrs.href
this.isLinkInputActive = true
this.$refs.contextMenu.displayContextMenu(element, this.$refs.linkInput.$el, 'link')
} else {
this.$refs.contextMenu.hideContextMenu()
this.isLinkInputActive = false
this.editor.focus()
}
},
hideLinkMenu() {
this.linkUrl = null
this.linkMenuIsActive = false
this.editor.focus()
},
hideMenu(isActive) {
isActive = false
this.hideLinkMenu()
},
setLinkUrl(command, url) {
const links = linkify().match(url)
if (links) {
setLinkUrl(url) {
const normalizedLinks = url ? linkify().match(url) : null
const command = this.editor.commands.link
if (normalizedLinks) {
// add valid link
command({
href: links.pop().url,
href: normalizedLinks.pop().url,
})
this.hideLinkMenu()
this.toggleLinkInput()
this.editor.focus()
} else if (!url) {
} else {
// remove link
command({ href: null })
}
@ -576,70 +280,6 @@ export default {
</script>
<style lang="scss">
.suggestion-list {
padding: 0.2rem;
border: 2px solid rgba($color-neutral-0, 0.1);
font-size: 0.8rem;
font-weight: bold;
&__no-results {
padding: 0.2rem 0.5rem;
}
&__item {
border-radius: 5px;
padding: 0.2rem 0.5rem;
margin-bottom: 0.2rem;
cursor: pointer;
&:last-child {
margin-bottom: 0;
}
&.is-selected,
&:hover {
background-color: rgba($color-neutral-100, 0.2);
}
&.is-empty {
opacity: 0.5;
}
}
}
.tippy-tooltip.dark-theme {
background-color: $color-neutral-0;
padding: 0;
font-size: 1rem;
text-align: inherit;
color: $color-neutral-100;
border-radius: 5px;
.tippy-backdrop {
display: none;
}
.tippy-roundarrow {
fill: $color-neutral-0;
}
.tippy-popper[x-placement^='top'] & .tippy-arrow {
border-top-color: $color-neutral-0;
}
.tippy-popper[x-placement^='bottom'] & .tippy-arrow {
border-bottom-color: $color-neutral-0;
}
.tippy-popper[x-placement^='left'] & .tippy-arrow {
border-left-color: $color-neutral-0;
}
.tippy-popper[x-placement^='right'] & .tippy-arrow {
border-right-color: $color-neutral-0;
}
}
.ProseMirror {
padding: $space-base;
margin: -$space-base;
min-height: $space-large;
}
.ProseMirror:focus {
outline: none;
}
.editor p.is-empty:first-child::before {
content: attr(data-empty-text);
float: left;
@ -659,74 +299,40 @@ li > p {
}
.editor {
.mention-suggestion {
color: $color-primary;
}
display: flex;
flex-direction: column;
.hashtag {
color: $color-primary;
}
.hashtag-suggestion {
color: $color-primary;
}
&__floating-menu {
position: absolute;
margin-top: -0.25rem;
margin-left: $space-xx-small;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s, visibility 0.2s;
background-color: #fff;
&.is-active {
opacity: 1;
visibility: visible;
}
.mention-suggestion {
color: $color-primary;
}
.menububble {
position: absolute;
// margin-top: -0.5rem;
visibility: hidden;
opacity: 0;
transition: opacity 200ms, visibility 200ms;
// transition-delay: 50ms;
transform: translate(-50%, -10%);
}
background-color: $background-color-inverse-soft;
// color: $text-color-inverse;
border-radius: $border-radius-base;
padding: $space-xx-small;
box-shadow: $box-shadow-large;
.editor-content {
flex-grow: 1;
margin-top: $space-small;
height: auto;
.ds-button {
color: $text-color-inverse;
&:focus-within {
border-color: $color-primary;
background-color: $color-neutral-100;
}
}
&.ds-button-hover,
&:hover {
color: $text-color-base;
}
}
.ProseMirror {
min-height: 100px;
&.is-active {
opacity: 1;
visibility: visible;
}
&:focus {
outline: none;
}
.tooltip-arrow {
left: calc(50% - 10px);
}
input,
button {
border: none;
border-radius: 2px;
}
.ds-input {
height: auto;
}
input {
padding: $space-xx-small $space-x-small;
}
p {
margin: 0 0 $space-x-small;
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<div>
<ds-input
id="linkInputId"
v-model="linkUrl"
class="editor-menu-link-input"
placeholder="https://"
@blur.native.capture="toggleLinkInput()"
@keydown.native.esc.prevent="toggleLinkInput()"
@keydown.native.enter.prevent="enterLink()"
/>
</div>
</template>
<script>
export default {
props: {
toggleLinkInput: Function,
setLinkUrl: Function,
},
data() {
return {
linkUrl: null,
}
},
methods: {
enterLink() {
this.setLinkUrl(this.linkUrl)
this.linkUrl = null
},
},
}
</script>

View File

@ -0,0 +1,74 @@
<template>
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, getMarkAttrs }">
<div>
<menu-bar-button :isActive="isActive.bold()" :onClick="commands.bold" icon="bold" />
<menu-bar-button :isActive="isActive.italic()" :onClick="commands.italic" icon="italic" />
<menu-bar-button
ref="linkButton"
:isActive="isActive.link()"
:onClick="event => toggleLinkInput(getMarkAttrs('link'), event.currentTarget)"
icon="link"
/>
<menu-bar-button
:isActive="isActive.paragraph()"
:onClick="commands.paragraph"
icon="paragraph"
/>
<menu-bar-button
:isActive="isActive.heading({ level: 3 })"
:onClick="() => commands.heading({ level: 3 })"
label="H3"
/>
<menu-bar-button
:isActive="isActive.heading({ level: 4 })"
:onClick="() => commands.heading({ level: 4 })"
label="H4"
/>
<menu-bar-button
:isActive="isActive.bullet_list()"
:onClick="commands.bullet_list"
icon="list-ul"
/>
<menu-bar-button
:isActive="isActive.ordered_list()"
:onClick="commands.ordered_list"
icon="list-ol"
/>
<menu-bar-button
:isActive="isActive.blockquote()"
:onClick="commands.blockquote"
icon="quote-right"
/>
<menu-bar-button
:isActive="isActive.horizontal_rule()"
:onClick="commands.horizontal_rule"
icon="minus"
/>
</div>
</editor-menu-bar>
</template>
<script>
import { EditorMenuBar } from 'tiptap'
import MenuBarButton from './MenuBarButton'
export default {
components: {
EditorMenuBar,
MenuBarButton,
},
props: {
editor: Object,
toggleLinkInput: Function,
},
}
</script>

View File

@ -0,0 +1,16 @@
<template>
<ds-button size="small" :ghost="!isActive" @click.prevent="onClick" :icon="icon">
<span v-if="label">{{ label }}</span>
</ds-button>
</template>
<script>
export default {
props: {
isActive: Boolean,
icon: String,
label: String,
onClick: Function,
},
}
</script>

View File

@ -0,0 +1,96 @@
<template>
<ul v-show="showSuggestions" class="suggestion-list">
<li
v-for="(item, index) in filteredItems"
:key="item.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedItemIndex === index }"
@click="selectItem(item)"
>
{{ createItemLabel(item) }}
</li>
<template v-if="isHashtag">
<li v-if="!query" class="suggestion-list__item hint">
{{ $t('editor.hashtag.addLetter') }}
</li>
<template v-else-if="!filteredItems.find(el => el.id === query)">
<li class="suggestion-list__item hint">{{ $t('editor.hashtag.addHashtag') }}</li>
<li class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</li>
</template>
</template>
<template v-else-if="isMention">
<li v-if="!hasResults" class="suggestion-list__item hint">
{{ $t('editor.mention.noUsersFound') }}
</li>
</template>
</ul>
</template>
<script>
import { HASHTAG, MENTION } from '../../constants/editor'
export default {
props: {
suggestionType: String,
filteredItems: Array,
query: String,
navigatedItemIndex: Number,
selectItem: Function,
},
computed: {
hasResults() {
return this.filteredItems.length > 0
},
isMention() {
return this.suggestionType === MENTION
},
isHashtag() {
return this.suggestionType === HASHTAG
},
showSuggestions() {
return this.query || this.hasResults
},
},
methods: {
createItemLabel(item) {
if (this.isMention) {
return `@${item.slug}`
} else {
return `#${item.id}`
}
},
},
}
</script>
<style lang="scss">
.suggestion-list {
list-style-type: none;
padding: 0.2rem;
border-radius: 5px;
border: 2px solid $color-primary;
font-size: 0.8rem;
font-weight: bold;
}
.suggestion-list__item {
border-radius: 5px;
padding: 0.2rem 0.5rem;
margin-bottom: 0.2rem;
cursor: pointer;
&:last-child {
margin-bottom: 0;
}
&.is-selected,
&:hover {
background-color: rgba($color-neutral-100, 0.3);
}
&.hint {
opacity: 0.7;
pointer-events: none;
}
}
</style>

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

@ -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

@ -0,0 +1,2 @@
export const HASHTAG = 'hashtag'
export const MENTION = 'mention'

View File

@ -0,0 +1,4 @@
export const ARROW_UP = 38
export const ARROW_DOWN = 40
export const RETURN = 13
export const SPACE = 32

View File

@ -51,5 +51,12 @@ export default i18n => {
}
}
`,
DeleteComment: gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`,
}
}

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"
},
@ -132,7 +132,9 @@
},
"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": {
@ -304,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"
},
@ -132,7 +132,9 @@
},
"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": {
@ -304,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

@ -61,7 +61,7 @@
"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.3",
"isemail": "^3.2.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

@ -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,7 +45,7 @@
size="x-large"
primary
/>
</no-ssr>
</client-only>
<div
v-if="hasMore"
v-infinite-scroll="showMoreContributions"

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,9 +89,9 @@
<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">
@ -119,9 +119,9 @@
<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">
@ -166,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>

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

@ -5711,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"
@ -10234,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"

View File

@ -1847,10 +1847,10 @@ cucumber@^4.2.1:
util-arity "^1.0.2"
verror "^1.9.0"
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==
cypress-cucumber-preprocessor@^1.15.1:
version "1.15.1"
resolved "https://registry.yarnpkg.com/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.15.1.tgz#9a53d4931e9fcc502f0fd89c8373a4869f9a0e79"
integrity sha512-rZeZskdOXljFKSiSiSUg0oQ4+3/kY8g1y5mNS8POrFM8Ch3rldw6ZJy4soMfqHKitNyilaUl/pRsgjH6cFI95w==
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"