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:
Wolfgang Huß 2019-07-09 16:47:17 +02:00 committed by GitHub
commit 458229eb77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1316 additions and 345 deletions

View File

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

View File

@ -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),
},
],
})
})
})
})
})
})

View File

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

View File

@ -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([])
})
})
})
})

View File

@ -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([])
})
})
})

View File

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

View File

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

View File

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

View File

@ -137,7 +137,7 @@ const permissions = shield(
'*': deny,
findPosts: allow,
Category: allow,
Tag: isAdmin,
Tag: allow,
Report: isModerator,
Notification: isAdmin,
statistics: allow,

View File

@ -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...')

View File

@ -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', () => {

View File

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

View File

@ -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,
}))
}

View File

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

View 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
},

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