mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2026-01-20 20:01:22 +00:00
Merge pull request #848 from Human-Connection/779-tags-of-contribution-in-text
Tags in the Text of a Contribution like Mentions
This commit is contained in:
commit
458229eb77
@ -0,0 +1,69 @@
|
||||
import extractMentionedUsers from './notifications/extractMentionedUsers'
|
||||
import extractHashtags from './hashtags/extractHashtags'
|
||||
|
||||
const notify = async (postId, idsOfMentionedUsers, context) => {
|
||||
const session = context.driver.session()
|
||||
const createdAt = new Date().toISOString()
|
||||
const cypher = `
|
||||
match(u:User) where u.id in $idsOfMentionedUsers
|
||||
match(p:Post) where p.id = $postId
|
||||
create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt})
|
||||
merge (n)-[:NOTIFIED]->(u)
|
||||
merge (p)-[:NOTIFIED]->(n)
|
||||
`
|
||||
await session.run(cypher, {
|
||||
idsOfMentionedUsers,
|
||||
createdAt,
|
||||
postId,
|
||||
})
|
||||
session.close()
|
||||
}
|
||||
|
||||
const updateHashtagsOfPost = async (postId, hashtags, context) => {
|
||||
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, name: 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 handleContentData = async (resolve, root, args, context, resolveInfo) => {
|
||||
// extract user ids before xss-middleware removes classes via the following "resolve" call
|
||||
const idsOfMentionedUsers = extractMentionedUsers(args.content)
|
||||
// extract tag (hashtag) ids before xss-middleware removes classes via the following "resolve" call
|
||||
const hashtags = extractHashtags(args.content)
|
||||
|
||||
// removes classes from the content
|
||||
const post = await resolve(root, args, context, resolveInfo)
|
||||
|
||||
await notify(post.id, idsOfMentionedUsers, context)
|
||||
await updateHashtagsOfPost(post.id, hashtags, context)
|
||||
|
||||
return post
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreatePost: handleContentData,
|
||||
UpdatePost: handleContentData,
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import gql from 'graphql-tag'
|
||||
import { host, login } from '../../jest/helpers'
|
||||
import Factory from '../../seed/factories'
|
||||
|
||||
const factory = Factory()
|
||||
let client
|
||||
|
||||
beforeEach(async () => {
|
||||
await factory.create('User', {
|
||||
id: 'you',
|
||||
name: 'Al Capone',
|
||||
slug: 'al-capone',
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await factory.cleanDatabase()
|
||||
})
|
||||
|
||||
describe('currentUser { notifications }', () => {
|
||||
const query = gql`
|
||||
query($read: Boolean) {
|
||||
currentUser {
|
||||
notifications(read: $read, orderBy: createdAt_desc) {
|
||||
read
|
||||
post {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('authenticated', () => {
|
||||
let headers
|
||||
beforeEach(async () => {
|
||||
headers = await login({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
describe('given another user', () => {
|
||||
let authorClient
|
||||
let authorParams
|
||||
let authorHeaders
|
||||
|
||||
beforeEach(async () => {
|
||||
authorParams = {
|
||||
email: 'author@example.org',
|
||||
password: '1234',
|
||||
id: 'author',
|
||||
}
|
||||
await factory.create('User', authorParams)
|
||||
authorHeaders = await login(authorParams)
|
||||
})
|
||||
|
||||
describe('who mentions me in a post', () => {
|
||||
let post
|
||||
const title = 'Mentioning Al Capone'
|
||||
const content =
|
||||
'Hey <a class="mention" href="/profile/you/al-capone">@al-capone</a> how do you do?'
|
||||
|
||||
beforeEach(async () => {
|
||||
const createPostMutation = gql`
|
||||
mutation($title: String!, $content: String!) {
|
||||
CreatePost(title: $title, content: $content) {
|
||||
id
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
authorClient = new GraphQLClient(host, {
|
||||
headers: authorHeaders,
|
||||
})
|
||||
const { CreatePost } = await authorClient.request(createPostMutation, {
|
||||
title,
|
||||
content,
|
||||
})
|
||||
post = CreatePost
|
||||
})
|
||||
|
||||
it('sends you a notification', async () => {
|
||||
const expectedContent =
|
||||
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
|
||||
const expected = {
|
||||
currentUser: {
|
||||
notifications: [
|
||||
{
|
||||
read: false,
|
||||
post: {
|
||||
content: expectedContent,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
client.request(query, {
|
||||
read: false,
|
||||
}),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
describe('who mentions me again', () => {
|
||||
beforeEach(async () => {
|
||||
const updatedContent = `${post.content} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
|
||||
// The response `post.content` contains a link but the XSSmiddleware
|
||||
// should have the `mention` CSS class removed. I discovered this
|
||||
// during development and thought: A feature not a bug! This way we
|
||||
// can encode a re-mentioning of users when you edit your post or
|
||||
// comment.
|
||||
const updatePostMutation = gql`
|
||||
mutation($id: ID!, $title: String!, $content: String!) {
|
||||
UpdatePost(id: $id, content: $content, title: $title) {
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
authorClient = new GraphQLClient(host, {
|
||||
headers: authorHeaders,
|
||||
})
|
||||
await authorClient.request(updatePostMutation, {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
content: updatedContent,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates exactly one more notification', async () => {
|
||||
const expectedContent =
|
||||
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do? One more mention to <a href="/profile/you" target="_blank">@al-capone</a>'
|
||||
const expected = {
|
||||
currentUser: {
|
||||
notifications: [
|
||||
{
|
||||
read: false,
|
||||
post: {
|
||||
content: expectedContent,
|
||||
},
|
||||
},
|
||||
{
|
||||
read: false,
|
||||
post: {
|
||||
content: expectedContent,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
client.request(query, {
|
||||
read: false,
|
||||
}),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hashtags', () => {
|
||||
const postId = 'p135'
|
||||
const postTitle = '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
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const postWithHastagsVariables = {
|
||||
id: postId,
|
||||
}
|
||||
const createPostMutation = gql`
|
||||
mutation($postId: ID, $postTitle: String!, $postContent: String!) {
|
||||
CreatePost(id: $postId, title: $postTitle, content: $postContent) {
|
||||
id
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('authenticated', () => {
|
||||
let headers
|
||||
beforeEach(async () => {
|
||||
headers = await login({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
describe('create a Post with Hashtags', () => {
|
||||
beforeEach(async () => {
|
||||
await client.request(createPostMutation, {
|
||||
postId,
|
||||
postTitle,
|
||||
postContent,
|
||||
})
|
||||
})
|
||||
|
||||
it('both Hashtags are created with the "id" set to thier "name"', async () => {
|
||||
const expected = [
|
||||
{
|
||||
id: 'Democracy',
|
||||
name: 'Democracy',
|
||||
},
|
||||
{
|
||||
id: 'Liberty',
|
||||
name: 'Liberty',
|
||||
},
|
||||
]
|
||||
await expect(
|
||||
client.request(postWithHastagsQuery, postWithHastagsVariables),
|
||||
).resolves.toEqual({
|
||||
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 updatedPostContent =
|
||||
'<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>'
|
||||
const updatePostMutation = gql`
|
||||
mutation($postId: ID!, $postTitle: String!, $updatedPostContent: String!) {
|
||||
UpdatePost(id: $postId, title: $postTitle, content: $updatedPostContent) {
|
||||
id
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
it('only one previous Hashtag and the new Hashtag exists', async () => {
|
||||
await client.request(updatePostMutation, {
|
||||
postId,
|
||||
postTitle,
|
||||
updatedPostContent,
|
||||
})
|
||||
|
||||
const expected = [
|
||||
{
|
||||
id: 'Elections',
|
||||
name: 'Elections',
|
||||
},
|
||||
{
|
||||
id: 'Liberty',
|
||||
name: 'Liberty',
|
||||
},
|
||||
]
|
||||
await expect(
|
||||
client.request(postWithHastagsQuery, postWithHastagsVariables),
|
||||
).resolves.toEqual({
|
||||
Post: [
|
||||
{
|
||||
tags: expect.arrayContaining(expected),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,28 @@
|
||||
import cheerio from 'cheerio'
|
||||
// formats of a Hashtag:
|
||||
// https://en.wikipedia.org/w/index.php?title=Hashtag&oldid=905141980#Style
|
||||
// here:
|
||||
// 0. Search for whole string.
|
||||
// 1. Hashtag has only 'a-z', 'A-Z', and '0-9'.
|
||||
// 2. If it starts with a digit '0-9' than 'a-z', or 'A-Z' has to follow.
|
||||
const ID_REGEX = /^\/search\/hashtag\/(([a-zA-Z]+[a-zA-Z0-9]*)|([0-9]+[a-zA-Z]+[a-zA-Z0-9]*))$/g
|
||||
|
||||
export default function(content) {
|
||||
if (!content) return []
|
||||
const $ = cheerio.load(content)
|
||||
// We can not search for class '.hashtag', because the classes are removed at the 'xss' middleware.
|
||||
// But we have to know, which Hashtags are removed from the content es well, so we search for the 'a' html-tag.
|
||||
const urls = $('a')
|
||||
.map((_, el) => {
|
||||
return $(el).attr('href')
|
||||
})
|
||||
.get()
|
||||
const hashtags = []
|
||||
urls.forEach(url => {
|
||||
let match
|
||||
while ((match = ID_REGEX.exec(url)) != null) {
|
||||
hashtags.push(match[1])
|
||||
}
|
||||
})
|
||||
return hashtags
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import extractHashtags from './extractHashtags'
|
||||
|
||||
describe('extractHashtags', () => {
|
||||
describe('content undefined', () => {
|
||||
it('returns empty array', () => {
|
||||
expect(extractHashtags()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('searches through links', () => {
|
||||
it('finds links with and without ".hashtag" class and extracts Hashtag names', () => {
|
||||
const content =
|
||||
'<p><a class="hashtag" href="/search/hashtag/Elections">#Elections</a><a href="/search/hashtag/Democracy">#Democracy</a></p>'
|
||||
expect(extractHashtags(content)).toEqual(['Elections', 'Democracy'])
|
||||
})
|
||||
|
||||
it('ignores mentions', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractHashtags(content)).toEqual([])
|
||||
})
|
||||
|
||||
describe('handles links', () => {
|
||||
it('ignores links with domains', () => {
|
||||
const content =
|
||||
'<p><a class="hashtag" href="http://localhost:3000/search/hashtag/Elections">#Elections</a><a href="/search/hashtag/Democracy">#Democracy</a></p>'
|
||||
expect(extractHashtags(content)).toEqual(['Democracy'])
|
||||
})
|
||||
|
||||
it('ignores Hashtag links with not allowed character combinations', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="/search/hashtag/AbcDefXyz0123456789!*(),2" class="hashtag" target="_blank">#AbcDefXyz0123456789!*(),2</a>, <a href="/search/hashtag/0123456789" class="hashtag" target="_blank">#0123456789</a>, <a href="/search/hashtag/0123456789a" class="hashtag" target="_blank">#0123456789a</a> and <a href="/search/hashtag/AbcDefXyz0123456789" target="_blank">#AbcDefXyz0123456789</a>.</p>'
|
||||
expect(extractHashtags(content)).toEqual(['0123456789a', 'AbcDefXyz0123456789'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('does not crash if', () => {
|
||||
it('`href` contains no Hashtag name', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="/search/hashtag/" target="_blank">#Democracy</a> and <a href="/search/hashtag" target="_blank">#liberty</a>.</p>'
|
||||
expect(extractHashtags(content)).toEqual([])
|
||||
})
|
||||
|
||||
it('`href` contains Hashtag as page anchor', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="https://www.example.org/#anchor" target="_blank">#anchor</a>.</p>'
|
||||
expect(extractHashtags(content)).toEqual([])
|
||||
})
|
||||
|
||||
it('`href` is empty or invalid', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="" class="hashtag" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractHashtags(content)).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,9 @@
|
||||
import extractIds from '.'
|
||||
import extractMentionedUsers from './extractMentionedUsers'
|
||||
|
||||
describe('extractIds', () => {
|
||||
describe('extractMentionedUsers', () => {
|
||||
describe('content undefined', () => {
|
||||
it('returns empty array', () => {
|
||||
expect(extractIds()).toEqual([])
|
||||
expect(extractMentionedUsers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -11,33 +11,33 @@ describe('extractIds', () => {
|
||||
it('ignores links without .mention class', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual([])
|
||||
expect(extractMentionedUsers(content)).toEqual([])
|
||||
})
|
||||
|
||||
describe('given a link with .mention class', () => {
|
||||
it('extracts ids', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="/profile/u2" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual(['u2', 'u3'])
|
||||
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
|
||||
})
|
||||
|
||||
describe('handles links', () => {
|
||||
it('with slug and id', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual(['u2', 'u3'])
|
||||
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
|
||||
})
|
||||
|
||||
it('with domains', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="http://localhost:3000/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual(['u2', 'u3'])
|
||||
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
|
||||
})
|
||||
|
||||
it('special characters', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="http://localhost:3000/profile/u!*(),2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u.~-3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual(['u!*(),2', 'u.~-3'])
|
||||
expect(extractMentionedUsers(content)).toEqual(['u!*(),2', 'u.~-3'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -45,13 +45,13 @@ describe('extractIds', () => {
|
||||
it('`href` contains no user id', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="/profile" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual([])
|
||||
expect(extractMentionedUsers(content)).toEqual([])
|
||||
})
|
||||
|
||||
it('`href` is empty or invalid', () => {
|
||||
const content =
|
||||
'<p>Something inspirational about <a href="" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" class="mention" target="_blank">@jenny-rostock</a>.</p>'
|
||||
expect(extractIds(content)).toEqual([])
|
||||
expect(extractMentionedUsers(content)).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -10,7 +10,7 @@ import user from './userMiddleware'
|
||||
import includedFields from './includedFieldsMiddleware'
|
||||
import orderBy from './orderByMiddleware'
|
||||
import validation from './validation/validationMiddleware'
|
||||
import notifications from './notifications'
|
||||
import handleContentData from './handleHtmlContent/handleContentData'
|
||||
import email from './email/emailMiddleware'
|
||||
|
||||
export default schema => {
|
||||
@ -21,7 +21,7 @@ export default schema => {
|
||||
validation: validation,
|
||||
sluggify: sluggify,
|
||||
excerpt: excerpt,
|
||||
notifications: notifications,
|
||||
handleContentData: handleContentData,
|
||||
xss: xss,
|
||||
softDelete: softDelete,
|
||||
user: user,
|
||||
@ -38,7 +38,7 @@ export default schema => {
|
||||
'sluggify',
|
||||
'excerpt',
|
||||
'email',
|
||||
'notifications',
|
||||
'handleContentData',
|
||||
'xss',
|
||||
'softDelete',
|
||||
'user',
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import extractIds from './extractIds'
|
||||
|
||||
const notify = async (resolve, root, args, context, resolveInfo) => {
|
||||
// extract user ids before xss-middleware removes link classes
|
||||
const ids = extractIds(args.content)
|
||||
|
||||
const post = await resolve(root, args, context, resolveInfo)
|
||||
|
||||
const session = context.driver.session()
|
||||
const { id: postId } = post
|
||||
const createdAt = new Date().toISOString()
|
||||
const cypher = `
|
||||
match(u:User) where u.id in $ids
|
||||
match(p:Post) where p.id = $postId
|
||||
create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt})
|
||||
merge (n)-[:NOTIFIED]->(u)
|
||||
merge (p)-[:NOTIFIED]->(n)
|
||||
`
|
||||
await session.run(cypher, { ids, createdAt, postId })
|
||||
session.close()
|
||||
|
||||
return post
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreatePost: notify,
|
||||
UpdatePost: notify,
|
||||
},
|
||||
}
|
||||
@ -1,130 +0,0 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import { host, login } from '../../jest/helpers'
|
||||
import Factory from '../../seed/factories'
|
||||
|
||||
const factory = Factory()
|
||||
let client
|
||||
|
||||
beforeEach(async () => {
|
||||
await factory.create('User', {
|
||||
id: 'you',
|
||||
name: 'Al Capone',
|
||||
slug: 'al-capone',
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await factory.cleanDatabase()
|
||||
})
|
||||
|
||||
describe('currentUser { notifications }', () => {
|
||||
const query = `query($read: Boolean) {
|
||||
currentUser {
|
||||
notifications(read: $read, orderBy: createdAt_desc) {
|
||||
read
|
||||
post {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
describe('authenticated', () => {
|
||||
let headers
|
||||
beforeEach(async () => {
|
||||
headers = await login({ email: 'test@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
describe('given another user', () => {
|
||||
let authorClient
|
||||
let authorParams
|
||||
let authorHeaders
|
||||
|
||||
beforeEach(async () => {
|
||||
authorParams = {
|
||||
email: 'author@example.org',
|
||||
password: '1234',
|
||||
id: 'author',
|
||||
}
|
||||
await factory.create('User', authorParams)
|
||||
authorHeaders = await login(authorParams)
|
||||
})
|
||||
|
||||
describe('who mentions me in a post', () => {
|
||||
let post
|
||||
const title = 'Mentioning Al Capone'
|
||||
const content =
|
||||
'Hey <a class="mention" href="/profile/you/al-capone">@al-capone</a> how do you do?'
|
||||
|
||||
beforeEach(async () => {
|
||||
const createPostMutation = `
|
||||
mutation($title: String!, $content: String!) {
|
||||
CreatePost(title: $title, content: $content) {
|
||||
id
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
authorClient = new GraphQLClient(host, { headers: authorHeaders })
|
||||
const { CreatePost } = await authorClient.request(createPostMutation, { title, content })
|
||||
post = CreatePost
|
||||
})
|
||||
|
||||
it('sends you a notification', async () => {
|
||||
const expectedContent =
|
||||
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
|
||||
const expected = {
|
||||
currentUser: {
|
||||
notifications: [{ read: false, post: { content: expectedContent } }],
|
||||
},
|
||||
}
|
||||
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
describe('who mentions me again', () => {
|
||||
beforeEach(async () => {
|
||||
const updatedContent = `${post.content} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
|
||||
const updatedTitle = 'this post has been updated'
|
||||
// The response `post.content` contains a link but the XSSmiddleware
|
||||
// should have the `mention` CSS class removed. I discovered this
|
||||
// during development and thought: A feature not a bug! This way we
|
||||
// can encode a re-mentioning of users when you edit your post or
|
||||
// comment.
|
||||
const updatePostMutation = `
|
||||
mutation($id: ID!, $title: String!, $content: String!) {
|
||||
UpdatePost(id: $id, title: $title, content: $content) {
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
authorClient = new GraphQLClient(host, { headers: authorHeaders })
|
||||
await authorClient.request(updatePostMutation, {
|
||||
id: post.id,
|
||||
content: updatedContent,
|
||||
title: updatedTitle,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates exactly one more notification', async () => {
|
||||
const expectedContent =
|
||||
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do? One more mention to <a href="/profile/you" target="_blank">@al-capone</a>'
|
||||
const expected = {
|
||||
currentUser: {
|
||||
notifications: [
|
||||
{ read: false, post: { content: expectedContent } },
|
||||
{ read: false, post: { content: expectedContent } },
|
||||
],
|
||||
},
|
||||
}
|
||||
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -137,7 +137,7 @@ const permissions = shield(
|
||||
'*': deny,
|
||||
findPosts: allow,
|
||||
Category: allow,
|
||||
Tag: isAdmin,
|
||||
Tag: allow,
|
||||
Report: isModerator,
|
||||
Notification: isAdmin,
|
||||
statistics: allow,
|
||||
|
||||
@ -69,47 +69,144 @@ import Factory from './factories'
|
||||
role: 'user',
|
||||
email: 'user@example.org',
|
||||
}),
|
||||
f.create('User', { id: 'u4', name: 'Tick', role: 'user', email: 'tick@example.org' }),
|
||||
f.create('User', { id: 'u5', name: 'Trick', role: 'user', email: 'trick@example.org' }),
|
||||
f.create('User', { id: 'u6', name: 'Track', role: 'user', email: 'track@example.org' }),
|
||||
f.create('User', { id: 'u7', name: 'Dagobert', role: 'user', email: 'dagobert@example.org' }),
|
||||
f.create('User', {
|
||||
id: 'u4',
|
||||
name: 'Tick',
|
||||
role: 'user',
|
||||
email: 'tick@example.org',
|
||||
}),
|
||||
f.create('User', {
|
||||
id: 'u5',
|
||||
name: 'Trick',
|
||||
role: 'user',
|
||||
email: 'trick@example.org',
|
||||
}),
|
||||
f.create('User', {
|
||||
id: 'u6',
|
||||
name: 'Track',
|
||||
role: 'user',
|
||||
email: 'track@example.org',
|
||||
}),
|
||||
f.create('User', {
|
||||
id: 'u7',
|
||||
name: 'Dagobert',
|
||||
role: 'user',
|
||||
email: 'dagobert@example.org',
|
||||
}),
|
||||
])
|
||||
|
||||
const [asAdmin, asModerator, asUser, asTick, asTrick, asTrack] = await Promise.all([
|
||||
Factory().authenticateAs({ email: 'admin@example.org', password: '1234' }),
|
||||
Factory().authenticateAs({ email: 'moderator@example.org', password: '1234' }),
|
||||
Factory().authenticateAs({ email: 'user@example.org', password: '1234' }),
|
||||
Factory().authenticateAs({ email: 'tick@example.org', password: '1234' }),
|
||||
Factory().authenticateAs({ email: 'trick@example.org', password: '1234' }),
|
||||
Factory().authenticateAs({ email: 'track@example.org', password: '1234' }),
|
||||
Factory().authenticateAs({
|
||||
email: 'admin@example.org',
|
||||
password: '1234',
|
||||
}),
|
||||
Factory().authenticateAs({
|
||||
email: 'moderator@example.org',
|
||||
password: '1234',
|
||||
}),
|
||||
Factory().authenticateAs({
|
||||
email: 'user@example.org',
|
||||
password: '1234',
|
||||
}),
|
||||
Factory().authenticateAs({
|
||||
email: 'tick@example.org',
|
||||
password: '1234',
|
||||
}),
|
||||
Factory().authenticateAs({
|
||||
email: 'trick@example.org',
|
||||
password: '1234',
|
||||
}),
|
||||
Factory().authenticateAs({
|
||||
email: 'track@example.org',
|
||||
password: '1234',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
f.relate('User', 'Badges', { from: 'b6', to: 'u1' }),
|
||||
f.relate('User', 'Badges', { from: 'b5', to: 'u2' }),
|
||||
f.relate('User', 'Badges', { from: 'b4', to: 'u3' }),
|
||||
f.relate('User', 'Badges', { from: 'b3', to: 'u4' }),
|
||||
f.relate('User', 'Badges', { from: 'b2', to: 'u5' }),
|
||||
f.relate('User', 'Badges', { from: 'b1', to: 'u6' }),
|
||||
f.relate('User', 'Friends', { from: 'u1', to: 'u2' }),
|
||||
f.relate('User', 'Friends', { from: 'u1', to: 'u3' }),
|
||||
f.relate('User', 'Friends', { from: 'u2', to: 'u3' }),
|
||||
f.relate('User', 'Blacklisted', { from: 'u7', to: 'u4' }),
|
||||
f.relate('User', 'Blacklisted', { from: 'u7', to: 'u5' }),
|
||||
f.relate('User', 'Blacklisted', { from: 'u7', to: 'u6' }),
|
||||
f.relate('User', 'Badges', {
|
||||
from: 'b6',
|
||||
to: 'u1',
|
||||
}),
|
||||
f.relate('User', 'Badges', {
|
||||
from: 'b5',
|
||||
to: 'u2',
|
||||
}),
|
||||
f.relate('User', 'Badges', {
|
||||
from: 'b4',
|
||||
to: 'u3',
|
||||
}),
|
||||
f.relate('User', 'Badges', {
|
||||
from: 'b3',
|
||||
to: 'u4',
|
||||
}),
|
||||
f.relate('User', 'Badges', {
|
||||
from: 'b2',
|
||||
to: 'u5',
|
||||
}),
|
||||
f.relate('User', 'Badges', {
|
||||
from: 'b1',
|
||||
to: 'u6',
|
||||
}),
|
||||
f.relate('User', 'Friends', {
|
||||
from: 'u1',
|
||||
to: 'u2',
|
||||
}),
|
||||
f.relate('User', 'Friends', {
|
||||
from: 'u1',
|
||||
to: 'u3',
|
||||
}),
|
||||
f.relate('User', 'Friends', {
|
||||
from: 'u2',
|
||||
to: 'u3',
|
||||
}),
|
||||
f.relate('User', 'Blacklisted', {
|
||||
from: 'u7',
|
||||
to: 'u4',
|
||||
}),
|
||||
f.relate('User', 'Blacklisted', {
|
||||
from: 'u7',
|
||||
to: 'u5',
|
||||
}),
|
||||
f.relate('User', 'Blacklisted', {
|
||||
from: 'u7',
|
||||
to: 'u6',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
asAdmin.follow({ id: 'u3', type: 'User' }),
|
||||
asModerator.follow({ id: 'u4', type: 'User' }),
|
||||
asUser.follow({ id: 'u4', type: 'User' }),
|
||||
asTick.follow({ id: 'u6', type: 'User' }),
|
||||
asTrick.follow({ id: 'u4', type: 'User' }),
|
||||
asTrack.follow({ id: 'u3', type: 'User' }),
|
||||
asAdmin.follow({
|
||||
id: 'u3',
|
||||
type: 'User',
|
||||
}),
|
||||
asModerator.follow({
|
||||
id: 'u4',
|
||||
type: 'User',
|
||||
}),
|
||||
asUser.follow({
|
||||
id: 'u4',
|
||||
type: 'User',
|
||||
}),
|
||||
asTick.follow({
|
||||
id: 'u6',
|
||||
type: 'User',
|
||||
}),
|
||||
asTrick.follow({
|
||||
id: 'u4',
|
||||
type: 'User',
|
||||
}),
|
||||
asTrack.follow({
|
||||
id: 'u3',
|
||||
type: 'User',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
f.create('Category', { id: 'cat1', name: 'Just For Fun', slug: 'justforfun', icon: 'smile' }),
|
||||
f.create('Category', {
|
||||
id: 'cat1',
|
||||
name: 'Just For Fun',
|
||||
slug: 'justforfun',
|
||||
icon: 'smile',
|
||||
}),
|
||||
f.create('Category', {
|
||||
id: 'cat2',
|
||||
name: 'Happyness & Values',
|
||||
@ -203,10 +300,22 @@ import Factory from './factories'
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
f.create('Tag', { id: 't1', name: 'Umwelt' }),
|
||||
f.create('Tag', { id: 't2', name: 'Naturschutz' }),
|
||||
f.create('Tag', { id: 't3', name: 'Demokratie' }),
|
||||
f.create('Tag', { id: 't4', name: 'Freiheit' }),
|
||||
f.create('Tag', {
|
||||
id: 'Umwelt',
|
||||
name: 'Umwelt',
|
||||
}),
|
||||
f.create('Tag', {
|
||||
id: 'Naturschutz',
|
||||
name: 'Naturschutz',
|
||||
}),
|
||||
f.create('Tag', {
|
||||
id: 'Demokratie',
|
||||
name: 'Demokratie',
|
||||
}),
|
||||
f.create('Tag', {
|
||||
id: 'Freiheit',
|
||||
name: 'Freiheit',
|
||||
}),
|
||||
])
|
||||
|
||||
const mention1 = 'Hey <a class="mention" href="/profile/u3">@jenny-rostock</a>, what\'s up?'
|
||||
@ -214,108 +323,347 @@ import Factory from './factories'
|
||||
'Hey <a class="mention" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!'
|
||||
|
||||
await Promise.all([
|
||||
asAdmin.create('Post', { id: 'p0', image: faker.image.unsplash.food() }),
|
||||
asModerator.create('Post', { id: 'p1', image: faker.image.unsplash.technology() }),
|
||||
asUser.create('Post', { id: 'p2' }),
|
||||
asTick.create('Post', { id: 'p3' }),
|
||||
asTrick.create('Post', { id: 'p4' }),
|
||||
asTrack.create('Post', { id: 'p5' }),
|
||||
asAdmin.create('Post', { id: 'p6', image: faker.image.unsplash.buildings() }),
|
||||
asModerator.create('Post', { id: 'p7', content: `${mention1} ${faker.lorem.paragraph()}` }),
|
||||
asUser.create('Post', { id: 'p8', image: faker.image.unsplash.nature() }),
|
||||
asTick.create('Post', { id: 'p9' }),
|
||||
asTrick.create('Post', { id: 'p10' }),
|
||||
asTrack.create('Post', { id: 'p11', image: faker.image.unsplash.people() }),
|
||||
asAdmin.create('Post', { id: 'p12', content: `${mention2} ${faker.lorem.paragraph()}` }),
|
||||
asModerator.create('Post', { id: 'p13' }),
|
||||
asUser.create('Post', { id: 'p14', image: faker.image.unsplash.objects() }),
|
||||
asTick.create('Post', { id: 'p15' }),
|
||||
asAdmin.create('Post', {
|
||||
id: 'p0',
|
||||
image: faker.image.unsplash.food(),
|
||||
}),
|
||||
asModerator.create('Post', {
|
||||
id: 'p1',
|
||||
image: faker.image.unsplash.technology(),
|
||||
}),
|
||||
asUser.create('Post', {
|
||||
id: 'p2',
|
||||
}),
|
||||
asTick.create('Post', {
|
||||
id: 'p3',
|
||||
}),
|
||||
asTrick.create('Post', {
|
||||
id: 'p4',
|
||||
}),
|
||||
asTrack.create('Post', {
|
||||
id: 'p5',
|
||||
}),
|
||||
asAdmin.create('Post', {
|
||||
id: 'p6',
|
||||
image: faker.image.unsplash.buildings(),
|
||||
}),
|
||||
asModerator.create('Post', {
|
||||
id: 'p7',
|
||||
content: `${mention1} ${faker.lorem.paragraph()}`,
|
||||
}),
|
||||
asUser.create('Post', {
|
||||
id: 'p8',
|
||||
image: faker.image.unsplash.nature(),
|
||||
}),
|
||||
asTick.create('Post', {
|
||||
id: 'p9',
|
||||
}),
|
||||
asTrick.create('Post', {
|
||||
id: 'p10',
|
||||
}),
|
||||
asTrack.create('Post', {
|
||||
id: 'p11',
|
||||
image: faker.image.unsplash.people(),
|
||||
}),
|
||||
asAdmin.create('Post', {
|
||||
id: 'p12',
|
||||
content: `${mention2} ${faker.lorem.paragraph()}`,
|
||||
}),
|
||||
asModerator.create('Post', {
|
||||
id: 'p13',
|
||||
}),
|
||||
asUser.create('Post', {
|
||||
id: 'p14',
|
||||
image: faker.image.unsplash.objects(),
|
||||
}),
|
||||
asTick.create('Post', {
|
||||
id: 'p15',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
f.relate('Post', 'Categories', { from: 'p0', to: 'cat16' }),
|
||||
f.relate('Post', 'Categories', { from: 'p1', to: 'cat1' }),
|
||||
f.relate('Post', 'Categories', { from: 'p2', to: 'cat2' }),
|
||||
f.relate('Post', 'Categories', { from: 'p3', to: 'cat3' }),
|
||||
f.relate('Post', 'Categories', { from: 'p4', to: 'cat4' }),
|
||||
f.relate('Post', 'Categories', { from: 'p5', to: 'cat5' }),
|
||||
f.relate('Post', 'Categories', { from: 'p6', to: 'cat6' }),
|
||||
f.relate('Post', 'Categories', { from: 'p7', to: 'cat7' }),
|
||||
f.relate('Post', 'Categories', { from: 'p8', to: 'cat8' }),
|
||||
f.relate('Post', 'Categories', { from: 'p9', to: 'cat9' }),
|
||||
f.relate('Post', 'Categories', { from: 'p10', to: 'cat10' }),
|
||||
f.relate('Post', 'Categories', { from: 'p11', to: 'cat11' }),
|
||||
f.relate('Post', 'Categories', { from: 'p12', to: 'cat12' }),
|
||||
f.relate('Post', 'Categories', { from: 'p13', to: 'cat13' }),
|
||||
f.relate('Post', 'Categories', { from: 'p14', to: 'cat14' }),
|
||||
f.relate('Post', 'Categories', { from: 'p15', to: 'cat15' }),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p0',
|
||||
to: 'cat16',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p1',
|
||||
to: 'cat1',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p2',
|
||||
to: 'cat2',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p3',
|
||||
to: 'cat3',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p4',
|
||||
to: 'cat4',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p5',
|
||||
to: 'cat5',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p6',
|
||||
to: 'cat6',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p7',
|
||||
to: 'cat7',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p8',
|
||||
to: 'cat8',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p9',
|
||||
to: 'cat9',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p10',
|
||||
to: 'cat10',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p11',
|
||||
to: 'cat11',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p12',
|
||||
to: 'cat12',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p13',
|
||||
to: 'cat13',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p14',
|
||||
to: 'cat14',
|
||||
}),
|
||||
f.relate('Post', 'Categories', {
|
||||
from: 'p15',
|
||||
to: 'cat15',
|
||||
}),
|
||||
|
||||
f.relate('Post', 'Tags', { from: 'p0', to: 't4' }),
|
||||
f.relate('Post', 'Tags', { from: 'p1', to: 't1' }),
|
||||
f.relate('Post', 'Tags', { from: 'p2', to: 't2' }),
|
||||
f.relate('Post', 'Tags', { from: 'p3', to: 't3' }),
|
||||
f.relate('Post', 'Tags', { from: 'p4', to: 't4' }),
|
||||
f.relate('Post', 'Tags', { from: 'p5', to: 't1' }),
|
||||
f.relate('Post', 'Tags', { from: 'p6', to: 't2' }),
|
||||
f.relate('Post', 'Tags', { from: 'p7', to: 't3' }),
|
||||
f.relate('Post', 'Tags', { from: 'p8', to: 't4' }),
|
||||
f.relate('Post', 'Tags', { from: 'p9', to: 't1' }),
|
||||
f.relate('Post', 'Tags', { from: 'p10', to: 't2' }),
|
||||
f.relate('Post', 'Tags', { from: 'p11', to: 't3' }),
|
||||
f.relate('Post', 'Tags', { from: 'p12', to: 't4' }),
|
||||
f.relate('Post', 'Tags', { from: 'p13', to: 't1' }),
|
||||
f.relate('Post', 'Tags', { from: 'p14', to: 't2' }),
|
||||
f.relate('Post', 'Tags', { from: 'p15', to: 't3' }),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p0',
|
||||
to: 'Freiheit',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p1',
|
||||
to: 'Umwelt',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p2',
|
||||
to: 'Naturschutz',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p3',
|
||||
to: 'Demokratie',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p4',
|
||||
to: 'Freiheit',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p5',
|
||||
to: 'Umwelt',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p6',
|
||||
to: 'Naturschutz',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p7',
|
||||
to: 'Demokratie',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p8',
|
||||
to: 'Freiheit',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p9',
|
||||
to: 'Umwelt',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p10',
|
||||
to: 'Naturschutz',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p11',
|
||||
to: 'Demokratie',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p12',
|
||||
to: 'Freiheit',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p13',
|
||||
to: 'Umwelt',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p14',
|
||||
to: 'Naturschutz',
|
||||
}),
|
||||
f.relate('Post', 'Tags', {
|
||||
from: 'p15',
|
||||
to: 'Demokratie',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
asAdmin.shout({ id: 'p2', type: 'Post' }),
|
||||
asAdmin.shout({ id: 'p6', type: 'Post' }),
|
||||
asModerator.shout({ id: 'p0', type: 'Post' }),
|
||||
asModerator.shout({ id: 'p6', type: 'Post' }),
|
||||
asUser.shout({ id: 'p6', type: 'Post' }),
|
||||
asUser.shout({ id: 'p7', type: 'Post' }),
|
||||
asTick.shout({ id: 'p8', type: 'Post' }),
|
||||
asTick.shout({ id: 'p9', type: 'Post' }),
|
||||
asTrack.shout({ id: 'p10', type: 'Post' }),
|
||||
asAdmin.shout({
|
||||
id: 'p2',
|
||||
type: 'Post',
|
||||
}),
|
||||
asAdmin.shout({
|
||||
id: 'p6',
|
||||
type: 'Post',
|
||||
}),
|
||||
asModerator.shout({
|
||||
id: 'p0',
|
||||
type: 'Post',
|
||||
}),
|
||||
asModerator.shout({
|
||||
id: 'p6',
|
||||
type: 'Post',
|
||||
}),
|
||||
asUser.shout({
|
||||
id: 'p6',
|
||||
type: 'Post',
|
||||
}),
|
||||
asUser.shout({
|
||||
id: 'p7',
|
||||
type: 'Post',
|
||||
}),
|
||||
asTick.shout({
|
||||
id: 'p8',
|
||||
type: 'Post',
|
||||
}),
|
||||
asTick.shout({
|
||||
id: 'p9',
|
||||
type: 'Post',
|
||||
}),
|
||||
asTrack.shout({
|
||||
id: 'p10',
|
||||
type: 'Post',
|
||||
}),
|
||||
])
|
||||
await Promise.all([
|
||||
asAdmin.shout({ id: 'p2', type: 'Post' }),
|
||||
asAdmin.shout({ id: 'p6', type: 'Post' }),
|
||||
asModerator.shout({ id: 'p0', type: 'Post' }),
|
||||
asModerator.shout({ id: 'p6', type: 'Post' }),
|
||||
asUser.shout({ id: 'p6', type: 'Post' }),
|
||||
asUser.shout({ id: 'p7', type: 'Post' }),
|
||||
asTick.shout({ id: 'p8', type: 'Post' }),
|
||||
asTick.shout({ id: 'p9', type: 'Post' }),
|
||||
asTrack.shout({ id: 'p10', type: 'Post' }),
|
||||
asAdmin.shout({
|
||||
id: 'p2',
|
||||
type: 'Post',
|
||||
}),
|
||||
asAdmin.shout({
|
||||
id: 'p6',
|
||||
type: 'Post',
|
||||
}),
|
||||
asModerator.shout({
|
||||
id: 'p0',
|
||||
type: 'Post',
|
||||
}),
|
||||
asModerator.shout({
|
||||
id: 'p6',
|
||||
type: 'Post',
|
||||
}),
|
||||
asUser.shout({
|
||||
id: 'p6',
|
||||
type: 'Post',
|
||||
}),
|
||||
asUser.shout({
|
||||
id: 'p7',
|
||||
type: 'Post',
|
||||
}),
|
||||
asTick.shout({
|
||||
id: 'p8',
|
||||
type: 'Post',
|
||||
}),
|
||||
asTick.shout({
|
||||
id: 'p9',
|
||||
type: 'Post',
|
||||
}),
|
||||
asTrack.shout({
|
||||
id: 'p10',
|
||||
type: 'Post',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
asUser.create('Comment', { id: 'c1', postId: 'p1' }),
|
||||
asTick.create('Comment', { id: 'c2', postId: 'p1' }),
|
||||
asTrack.create('Comment', { id: 'c3', postId: 'p3' }),
|
||||
asTrick.create('Comment', { id: 'c4', postId: 'p2' }),
|
||||
asModerator.create('Comment', { id: 'c5', postId: 'p3' }),
|
||||
asAdmin.create('Comment', { id: 'c6', postId: 'p4' }),
|
||||
asUser.create('Comment', { id: 'c7', postId: 'p2' }),
|
||||
asTick.create('Comment', { id: 'c8', postId: 'p15' }),
|
||||
asTrick.create('Comment', { id: 'c9', postId: 'p15' }),
|
||||
asTrack.create('Comment', { id: 'c10', postId: 'p15' }),
|
||||
asUser.create('Comment', { id: 'c11', postId: 'p15' }),
|
||||
asUser.create('Comment', { id: 'c12', postId: 'p15' }),
|
||||
asUser.create('Comment', {
|
||||
id: 'c1',
|
||||
postId: 'p1',
|
||||
}),
|
||||
asTick.create('Comment', {
|
||||
id: 'c2',
|
||||
postId: 'p1',
|
||||
}),
|
||||
asTrack.create('Comment', {
|
||||
id: 'c3',
|
||||
postId: 'p3',
|
||||
}),
|
||||
asTrick.create('Comment', {
|
||||
id: 'c4',
|
||||
postId: 'p2',
|
||||
}),
|
||||
asModerator.create('Comment', {
|
||||
id: 'c5',
|
||||
postId: 'p3',
|
||||
}),
|
||||
asAdmin.create('Comment', {
|
||||
id: 'c6',
|
||||
postId: 'p4',
|
||||
}),
|
||||
asUser.create('Comment', {
|
||||
id: 'c7',
|
||||
postId: 'p2',
|
||||
}),
|
||||
asTick.create('Comment', {
|
||||
id: 'c8',
|
||||
postId: 'p15',
|
||||
}),
|
||||
asTrick.create('Comment', {
|
||||
id: 'c9',
|
||||
postId: 'p15',
|
||||
}),
|
||||
asTrack.create('Comment', {
|
||||
id: 'c10',
|
||||
postId: 'p15',
|
||||
}),
|
||||
asUser.create('Comment', {
|
||||
id: 'c11',
|
||||
postId: 'p15',
|
||||
}),
|
||||
asUser.create('Comment', {
|
||||
id: 'c12',
|
||||
postId: 'p15',
|
||||
}),
|
||||
])
|
||||
|
||||
const disableMutation = 'mutation($id: ID!) { disable(id: $id) }'
|
||||
await Promise.all([
|
||||
asModerator.mutate(disableMutation, { id: 'p11' }),
|
||||
asModerator.mutate(disableMutation, { id: 'c5' }),
|
||||
asModerator.mutate(disableMutation, {
|
||||
id: 'p11',
|
||||
}),
|
||||
asModerator.mutate(disableMutation, {
|
||||
id: 'c5',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
asTick.create('Report', { description: "I don't like this comment", id: 'c1' }),
|
||||
asTrick.create('Report', { description: "I don't like this post", id: 'p1' }),
|
||||
asTrack.create('Report', { description: "I don't like this user", id: 'u1' }),
|
||||
asTick.create('Report', {
|
||||
description: "I don't like this comment",
|
||||
id: 'c1',
|
||||
}),
|
||||
asTrick.create('Report', {
|
||||
description: "I don't like this post",
|
||||
id: 'p1',
|
||||
}),
|
||||
asTrack.create('Report', {
|
||||
description: "I don't like this user",
|
||||
id: 'u1',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
@ -342,10 +690,22 @@ import Factory from './factories'
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
f.relate('Organization', 'CreatedBy', { from: 'u1', to: 'o1' }),
|
||||
f.relate('Organization', 'CreatedBy', { from: 'u1', to: 'o2' }),
|
||||
f.relate('Organization', 'OwnedBy', { from: 'u2', to: 'o2' }),
|
||||
f.relate('Organization', 'OwnedBy', { from: 'u2', to: 'o3' }),
|
||||
f.relate('Organization', 'CreatedBy', {
|
||||
from: 'u1',
|
||||
to: 'o1',
|
||||
}),
|
||||
f.relate('Organization', 'CreatedBy', {
|
||||
from: 'u1',
|
||||
to: 'o2',
|
||||
}),
|
||||
f.relate('Organization', 'OwnedBy', {
|
||||
from: 'u2',
|
||||
to: 'o2',
|
||||
}),
|
||||
f.relate('Organization', 'OwnedBy', {
|
||||
from: 'u2',
|
||||
to: 'o3',
|
||||
}),
|
||||
])
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log('Seeded Data...')
|
||||
|
||||
@ -47,7 +47,9 @@ describe('ContributionForm.vue', () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockRejectedValue({ message: 'Not Authorised!' }),
|
||||
.mockRejectedValue({
|
||||
message: 'Not Authorised!',
|
||||
}),
|
||||
},
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
@ -74,12 +76,26 @@ describe('ContributionForm.vue', () => {
|
||||
getters,
|
||||
})
|
||||
const Wrapper = () => {
|
||||
return mount(ContributionForm, { mocks, localVue, store, propsData })
|
||||
return mount(ContributionForm, {
|
||||
mocks,
|
||||
localVue,
|
||||
store,
|
||||
propsData,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.setData({ form: { languageOptions: [{ label: 'Deutsch', value: 'de' }] } })
|
||||
wrapper.setData({
|
||||
form: {
|
||||
languageOptions: [
|
||||
{
|
||||
label: 'Deutsch',
|
||||
value: 'de',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatePost', () => {
|
||||
|
||||
@ -11,7 +11,12 @@
|
||||
</hc-teaser-image>
|
||||
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
|
||||
<no-ssr>
|
||||
<hc-editor :users="users" :value="form.content" @input="updateEditorContent" />
|
||||
<hc-editor
|
||||
:users="users"
|
||||
:hashtags="hashtags"
|
||||
:value="form.content"
|
||||
@input="updateEditorContent"
|
||||
/>
|
||||
</no-ssr>
|
||||
<ds-space margin-bottom="xxx-large" />
|
||||
<hc-categories-select
|
||||
@ -32,18 +37,19 @@
|
||||
/>
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
<ds-space />
|
||||
<div slot="footer" style="text-align: right">
|
||||
<ds-button
|
||||
class="cancel-button"
|
||||
:disabled="loading || disabled"
|
||||
ghost
|
||||
class="cancel-button"
|
||||
@click.prevent="$router.back()"
|
||||
>
|
||||
{{ $t('actions.cancel') }}
|
||||
</ds-button>
|
||||
<ds-button
|
||||
icon="check"
|
||||
type="submit"
|
||||
icon="check"
|
||||
:loading="loading"
|
||||
:disabled="disabled || errors"
|
||||
primary
|
||||
@ -59,7 +65,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcEditor from '~/components/Editor'
|
||||
import HcEditor from '~/components/Editor/Editor'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import locales from '~/locales'
|
||||
import PostMutations from '~/graphql/PostMutations.js'
|
||||
@ -95,6 +101,7 @@ export default {
|
||||
disabled: false,
|
||||
slug: null,
|
||||
users: [],
|
||||
hashtags: [],
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -193,17 +200,34 @@ export default {
|
||||
apollo: {
|
||||
User: {
|
||||
query() {
|
||||
return gql(`{
|
||||
User(orderBy: slug_asc) {
|
||||
id
|
||||
slug
|
||||
return gql`
|
||||
{
|
||||
User(orderBy: slug_asc) {
|
||||
id
|
||||
slug
|
||||
}
|
||||
}
|
||||
}`)
|
||||
`
|
||||
},
|
||||
result(result) {
|
||||
this.users = result.data.User
|
||||
},
|
||||
},
|
||||
Tag: {
|
||||
query() {
|
||||
return gql`
|
||||
{
|
||||
Tag(orderBy: name_asc) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
result(result) {
|
||||
this.hashtags = result.data.Tag
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import Editor from './'
|
||||
import Editor from './Editor'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
@ -36,7 +36,9 @@ describe('Editor.vue', () => {
|
||||
propsData,
|
||||
localVue,
|
||||
sync: false,
|
||||
stubs: { transition: false },
|
||||
stubs: {
|
||||
transition: false,
|
||||
},
|
||||
store,
|
||||
}))
|
||||
}
|
||||
@ -1,18 +1,51 @@
|
||||
<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="(user, index) in filteredUsers"
|
||||
:key="user.id"
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.id"
|
||||
class="suggestion-list__item"
|
||||
:class="{ 'is-selected': navigatedUserIndex === index }"
|
||||
@click="selectUser(user)"
|
||||
:class="{ 'is-selected': navigatedItemIndex === index }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
@{{ user.slug }}
|
||||
<div v-if="isMention">@{{ item.slug }}</div>
|
||||
<div v-if="isHashtag">#{{ item.name }}</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.name === query)">
|
||||
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addHashtag') }}</div>
|
||||
<div class="suggestion-list__item" @click="selectItem({ name: 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>
|
||||
<div v-else class="suggestion-list__item is-empty">No users found</div>
|
||||
<!-- 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({ name: query })">
|
||||
#{{ query }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<editor-menu-bubble :editor="editor">
|
||||
@ -173,6 +206,7 @@ import {
|
||||
History,
|
||||
} from 'tiptap-extensions'
|
||||
import Mention from './nodes/Mention.js'
|
||||
import Hashtag from './nodes/Hashtag.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
let throttleInputEvent
|
||||
@ -185,6 +219,7 @@ export default {
|
||||
},
|
||||
props: {
|
||||
users: { type: Array, default: () => [] },
|
||||
hashtags: { type: Array, default: () => [] },
|
||||
value: { type: String, default: '' },
|
||||
doc: { type: Object, default: () => {} },
|
||||
},
|
||||
@ -215,34 +250,40 @@ export default {
|
||||
}),
|
||||
new History(),
|
||||
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.filteredUsers = items
|
||||
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.insertMention = command
|
||||
this.insertMentionOrHashtag = command
|
||||
},
|
||||
// is called when a suggestion has changed
|
||||
onChange: ({ items, query, range, virtualNode }) => {
|
||||
this.query = query
|
||||
this.filteredUsers = items
|
||||
this.filteredItems = items
|
||||
this.suggestionRange = range
|
||||
this.navigatedUserIndex = 0
|
||||
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.filteredUsers = []
|
||||
this.filteredItems = []
|
||||
this.suggestionRange = null
|
||||
this.navigatedUserIndex = 0
|
||||
this.navigatedItemIndex = 0
|
||||
this.destroyPopup()
|
||||
},
|
||||
// is called on every keyDown event while a suggestion is active
|
||||
@ -279,6 +320,83 @@ export default {
|
||||
return fuse.search(query)
|
||||
},
|
||||
}),
|
||||
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()),
|
||||
)
|
||||
},
|
||||
}),
|
||||
],
|
||||
onUpdate: e => {
|
||||
clearTimeout(throttleInputEvent)
|
||||
@ -287,22 +405,32 @@ export default {
|
||||
}),
|
||||
linkUrl: null,
|
||||
linkMenuIsActive: false,
|
||||
nullSuggestionType: '',
|
||||
mentionSuggestionType: 'mention',
|
||||
hashtagSuggestionType: 'hashtag',
|
||||
suggestionType: this.nullSuggestionType,
|
||||
query: null,
|
||||
suggestionRange: null,
|
||||
filteredUsers: [],
|
||||
navigatedUserIndex: 0,
|
||||
insertMention: () => {},
|
||||
filteredItems: [],
|
||||
navigatedItemIndex: 0,
|
||||
insertMentionOrHashtag: () => {},
|
||||
observer: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ placeholder: 'editor/placeholder' }),
|
||||
hasResults() {
|
||||
return this.filteredUsers.length
|
||||
return this.filteredItems.length
|
||||
},
|
||||
showSuggestions() {
|
||||
return this.query || this.hasResults
|
||||
},
|
||||
isMention() {
|
||||
return this.suggestionType === this.mentionSuggestionType
|
||||
},
|
||||
isHashtag() {
|
||||
return this.suggestionType === this.hashtagSuggestionType
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
@ -330,33 +458,54 @@ export default {
|
||||
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
|
||||
},
|
||||
// navigate to the previous item
|
||||
// if it's the first item, navigate to the last one
|
||||
upHandler() {
|
||||
this.navigatedUserIndex =
|
||||
(this.navigatedUserIndex + this.filteredUsers.length - 1) % this.filteredUsers.length
|
||||
this.navigatedItemIndex =
|
||||
(this.navigatedItemIndex + this.filteredItems.length - 1) % this.filteredItems.length
|
||||
},
|
||||
// navigate to the next item
|
||||
// if it's the last item, navigate to the first one
|
||||
downHandler() {
|
||||
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredUsers.length
|
||||
this.navigatedItemIndex = (this.navigatedItemIndex + 1) % this.filteredItems.length
|
||||
},
|
||||
// Handles pressing of enter.
|
||||
enterHandler() {
|
||||
const user = this.filteredUsers[this.navigatedUserIndex]
|
||||
if (user) {
|
||||
this.selectUser(user)
|
||||
const item = this.filteredItems[this.navigatedItemIndex]
|
||||
if (item) {
|
||||
this.selectItem(item)
|
||||
}
|
||||
},
|
||||
// For hashtags handles pressing of space.
|
||||
spaceHandler() {
|
||||
if (this.suggestionType === this.hashtagSuggestionType && this.query !== '') {
|
||||
this.selectItem({ name: this.query })
|
||||
}
|
||||
},
|
||||
// we have to replace our suggestion text with a mention
|
||||
// so it's important to pass also the position of your suggestion text
|
||||
selectUser(user) {
|
||||
this.insertMention({
|
||||
range: this.suggestionRange,
|
||||
attrs: {
|
||||
selectItem(item) {
|
||||
const typeAttrs = {
|
||||
mention: {
|
||||
// TODO: use router here
|
||||
url: `/profile/${user.id}`,
|
||||
label: user.slug,
|
||||
url: `/profile/${item.id}`,
|
||||
label: item.slug,
|
||||
},
|
||||
hashtag: {
|
||||
// TODO: Fill up with input hashtag in search field
|
||||
url: `/search/hashtag/${item.name}`,
|
||||
label: item.name,
|
||||
},
|
||||
}
|
||||
this.insertMentionOrHashtag({
|
||||
range: this.suggestionRange,
|
||||
attrs: typeAttrs[this.suggestionType],
|
||||
})
|
||||
this.editor.focus()
|
||||
},
|
||||
@ -535,6 +684,12 @@ li > p {
|
||||
.mention-suggestion {
|
||||
color: $color-primary;
|
||||
}
|
||||
.hashtag {
|
||||
color: $color-primary;
|
||||
}
|
||||
.hashtag-suggestion {
|
||||
color: $color-primary;
|
||||
}
|
||||
&__floating-menu {
|
||||
position: absolute;
|
||||
margin-top: -0.25rem;
|
||||
44
webapp/components/Editor/nodes/Hashtag.js
Normal file
44
webapp/components/Editor/nodes/Hashtag.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { Mention as TipTapMention } from 'tiptap-extensions'
|
||||
|
||||
export default class Hashtag extends TipTapMention {
|
||||
get name() {
|
||||
return 'hashtag'
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
return {
|
||||
matcher: {
|
||||
char: '#',
|
||||
allowSpaces: false,
|
||||
startOfLine: false,
|
||||
},
|
||||
mentionClass: 'hashtag',
|
||||
suggestionClass: 'hashtag-suggestion',
|
||||
}
|
||||
}
|
||||
|
||||
get schema() {
|
||||
const patchedSchema = super.schema
|
||||
|
||||
patchedSchema.attrs = {
|
||||
url: {},
|
||||
label: {},
|
||||
}
|
||||
patchedSchema.toDOM = node => {
|
||||
return [
|
||||
'a',
|
||||
{
|
||||
class: this.options.mentionClass,
|
||||
href: node.attrs.url,
|
||||
target: '_blank',
|
||||
// contenteditable: 'true',
|
||||
},
|
||||
`${this.options.matcher.char}${node.attrs.label}`,
|
||||
]
|
||||
}
|
||||
patchedSchema.parseDOM = [
|
||||
// this is not implemented
|
||||
]
|
||||
return patchedSchema
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
import { Mention as TipTapMention } from 'tiptap-extensions'
|
||||
|
||||
export default class Mention extends TipTapMention {
|
||||
get name() {
|
||||
return 'mention'
|
||||
}
|
||||
|
||||
get schema() {
|
||||
const patchedSchema = super.schema
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ds-card>
|
||||
<ds-card class="filter-menu-card">
|
||||
<ds-flex>
|
||||
<ds-flex-item class="filter-menu-title">
|
||||
<ds-heading size="h3">{{ $t('filter-menu.title') }}</ds-heading>
|
||||
@ -20,6 +20,28 @@
|
||||
</div>
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
<div v-if="hashtag">
|
||||
<ds-space margin-bottom="x-small" />
|
||||
<ds-flex>
|
||||
<ds-flex-item>
|
||||
<ds-heading size="h3">{{ $t('filter-menu.hashtag-search', { hashtag }) }}</ds-heading>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item>
|
||||
<div class="filter-menu-buttons">
|
||||
<ds-button
|
||||
v-tooltip="{
|
||||
content: this.$t('filter-menu.clearSearch'),
|
||||
placement: 'left',
|
||||
delay: { show: 500 },
|
||||
}"
|
||||
name="filter-by-followed-authors-only"
|
||||
icon="close"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</div>
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
</div>
|
||||
</ds-card>
|
||||
</template>
|
||||
|
||||
@ -27,6 +49,7 @@
|
||||
export default {
|
||||
props: {
|
||||
user: { type: Object, required: true },
|
||||
hashtag: { type: Object, default: null },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -50,11 +73,18 @@ export default {
|
||||
: { author: { followedBy_some: { id: this.user.id } } }
|
||||
this.$emit('changeFilterBubble', this.filter)
|
||||
},
|
||||
clearSearch() {
|
||||
this.$emit('clearSearch')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-menu-card {
|
||||
background-color: $background-color-soft;
|
||||
}
|
||||
|
||||
.filter-menu-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcEditor from '~/components/Editor'
|
||||
import HcEditor from '~/components/Editor/Editor'
|
||||
import PostCommentsQuery from '~/graphql/PostCommentsQuery.js'
|
||||
import CommentMutations from '~/graphql/CommentMutations.js'
|
||||
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
<div>
|
||||
<ds-flex>
|
||||
<ds-flex-item :width="{ base: '49px', md: '150px' }">
|
||||
<a v-router-link style="display: inline-flex" href="/">
|
||||
<nuxt-link to="/">
|
||||
<ds-logo />
|
||||
</a>
|
||||
</nuxt-link>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item>
|
||||
<div id="nav-search-box" v-on:click="unfolded" @blur.capture="foldedup">
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
{
|
||||
"filter-menu": {
|
||||
"title": "Deine Filterblase"
|
||||
"title": "Deine Filterblase",
|
||||
"hashtag-search": "Suche nach #{hashtag}",
|
||||
"clearSearch": "Suche löschen"
|
||||
},
|
||||
"login": {
|
||||
"copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.",
|
||||
@ -35,7 +37,15 @@
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"placeholder": "Schreib etwas Inspirierendes..."
|
||||
"placeholder": "Schreib etwas Inspirierendes...",
|
||||
"mention": {
|
||||
"noUsersFound": "Keine Benutzer gefunden"
|
||||
},
|
||||
"hashtag": {
|
||||
"noHashtagsFound": "Keine Hashtags gefunden",
|
||||
"addHashtag": "Neuer Hashtag",
|
||||
"addLetter": "Tippe einen Buchstaben"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"name": "Mein Profil",
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
{
|
||||
"filter-menu": {
|
||||
"title": "Your filter bubble"
|
||||
"title": "Your filter bubble",
|
||||
"hashtag-search": "Searching for #{hashtag}",
|
||||
"clearSearch": "Clear search"
|
||||
},
|
||||
"login": {
|
||||
"copy": "If you already have a human-connection account, login here.",
|
||||
@ -35,7 +37,15 @@
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"placeholder": "Leave your inspirational thoughts..."
|
||||
"placeholder": "Leave your inspirational thoughts...",
|
||||
"mention": {
|
||||
"noUsersFound": "No users found"
|
||||
},
|
||||
"hashtag": {
|
||||
"noHashtagsFound": "No hashtags found",
|
||||
"addHashtag": "New hashtag",
|
||||
"addLetter": "Type a letter"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"name": "My Profile",
|
||||
|
||||
@ -2,7 +2,12 @@
|
||||
<div>
|
||||
<ds-flex :width="{ base: '100%' }" gutter="base">
|
||||
<ds-flex-item>
|
||||
<filter-menu :user="currentUser" @changeFilterBubble="changeFilterBubble" />
|
||||
<filter-menu
|
||||
:user="currentUser"
|
||||
@changeFilterBubble="changeFilterBubble"
|
||||
:hashtag="hashtag"
|
||||
@clearSearch="clearSearch"
|
||||
/>
|
||||
</ds-flex-item>
|
||||
<hc-post-card
|
||||
v-for="(post, index) in uniq(Post)"
|
||||
@ -41,12 +46,19 @@ export default {
|
||||
HcLoadMore,
|
||||
},
|
||||
data() {
|
||||
const { hashtag = null } = this.$route.query
|
||||
return {
|
||||
// Initialize your apollo data
|
||||
Post: [],
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
filter: {},
|
||||
hashtag,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.hashtag) {
|
||||
this.changeFilterBubble({ tags_some: { name: this.hashtag } })
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -62,9 +74,21 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
changeFilterBubble(filter) {
|
||||
if (this.hashtag) {
|
||||
filter = {
|
||||
...filter,
|
||||
tags_some: { name: this.hashtag },
|
||||
}
|
||||
}
|
||||
this.filter = filter
|
||||
this.$apollo.queries.Post.refresh()
|
||||
},
|
||||
clearSearch() {
|
||||
this.$router.push({ path: '/' })
|
||||
this.hashtag = null
|
||||
delete this.filter.tags_some
|
||||
this.changeFilterBubble(this.filter)
|
||||
},
|
||||
uniq(items, field = 'id') {
|
||||
return uniqBy(items, field)
|
||||
},
|
||||
|
||||
12
webapp/pages/search/hashtag/_id.vue
Normal file
12
webapp/pages/search/hashtag/_id.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
mounted() {
|
||||
const { id: hashtag } = this.$route.params
|
||||
this.$router.push({ path: '/', query: { hashtag } })
|
||||
},
|
||||
}
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user