mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge branch 'master' of github.com:Human-Connection/Human-Connection into refine-social-media
This commit is contained in:
commit
5fb89fd45c
20
.codecov.yml
20
.codecov.yml
@ -95,7 +95,7 @@ coverage:
|
||||
# - master
|
||||
#flags:
|
||||
# - integration
|
||||
paths:
|
||||
paths:
|
||||
- backend/ # only include coverage in "backend/" folder
|
||||
webapp: # declare a new status context "frontend"
|
||||
against: parent
|
||||
@ -127,7 +127,7 @@ coverage:
|
||||
# - integration
|
||||
# paths:
|
||||
# - folder
|
||||
|
||||
|
||||
#changes:
|
||||
# default:
|
||||
# against: parent
|
||||
@ -150,20 +150,8 @@ coverage:
|
||||
|
||||
#ignore: # files and folders for processing
|
||||
# - tests/*
|
||||
|
||||
|
||||
#fixes:
|
||||
# - "old_path::new_path"
|
||||
|
||||
comment:
|
||||
# layout options are quite limited in v4.x - there have been way more options in v1.0
|
||||
layout: reach, diff, flags, files # mostly old options: header, diff, uncovered, reach, files, tree, changes, sunburst, flags
|
||||
behavior: new # default = posts once then update, posts new if delete
|
||||
# once = post once then updates
|
||||
# new = delete old, post new
|
||||
# spammy = post new
|
||||
require_changes: false # if true: only post the comment if coverage changes
|
||||
require_base: no # [yes :: must have a base report to post]
|
||||
require_head: no # [yes :: must have a head report to post]
|
||||
branches: null # branch names that can post comment
|
||||
flags: null
|
||||
paths: null
|
||||
comment: off
|
||||
|
||||
@ -11,7 +11,6 @@ addons:
|
||||
before_install:
|
||||
- yarn global add wait-on
|
||||
# Install Codecov
|
||||
- yarn global add codecov
|
||||
- yarn install
|
||||
- cp cypress.env.template.json cypress.env.json
|
||||
|
||||
@ -40,7 +39,7 @@ script:
|
||||
# Fullstack
|
||||
- yarn run cypress:run
|
||||
# Coverage
|
||||
- codecov
|
||||
- yarn run codecov
|
||||
|
||||
after_success:
|
||||
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
|
||||
|
||||
@ -34,7 +34,6 @@
|
||||
"!**/src/**/?(*.)+(spec|test).js?(x)"
|
||||
],
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"lcov"
|
||||
],
|
||||
"testMatch": [
|
||||
@ -48,7 +47,8 @@
|
||||
"apollo-client": "~2.6.3",
|
||||
"apollo-link-context": "~1.0.18",
|
||||
"apollo-link-http": "~1.5.15",
|
||||
"apollo-server": "~2.6.6",
|
||||
"apollo-server": "~2.6.9",
|
||||
"apollo-server-express": "^2.6.9",
|
||||
"bcryptjs": "~2.4.3",
|
||||
"cheerio": "~1.0.0-rc.3",
|
||||
"cors": "~2.8.5",
|
||||
@ -56,7 +56,7 @@
|
||||
"date-fns": "2.0.0-beta.1",
|
||||
"debug": "~4.1.1",
|
||||
"dotenv": "~8.0.0",
|
||||
"express": "~4.17.1",
|
||||
"express": "^4.17.1",
|
||||
"faker": "Marak/faker.js#master",
|
||||
"graphql": "~14.4.2",
|
||||
"graphql-custom-directives": "~0.2.14",
|
||||
@ -64,33 +64,32 @@
|
||||
"graphql-middleware": "~3.0.2",
|
||||
"graphql-shield": "~6.0.3",
|
||||
"graphql-tag": "~2.10.1",
|
||||
"graphql-yoga": "~1.18.0",
|
||||
"helmet": "~3.18.0",
|
||||
"jsonwebtoken": "~8.5.1",
|
||||
"linkifyjs": "~2.1.8",
|
||||
"lodash": "~4.17.11",
|
||||
"lodash": "~4.17.14",
|
||||
"merge-graphql-schemas": "^1.5.8",
|
||||
"neo4j-driver": "~1.7.4",
|
||||
"neo4j-graphql-js": "^2.6.3",
|
||||
"neode": "^0.2.16",
|
||||
"node-fetch": "~2.6.0",
|
||||
"nodemailer": "^6.2.1",
|
||||
"nodemailer": "^6.3.0",
|
||||
"npm-run-all": "~4.1.5",
|
||||
"request": "~2.88.0",
|
||||
"sanitize-html": "~1.20.1",
|
||||
"slug": "~1.1.0",
|
||||
"trunc-html": "~1.1.2",
|
||||
"uuid": "~3.3.2",
|
||||
"wait-on": "~3.2.0"
|
||||
"wait-on": "~3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "~7.5.0",
|
||||
"@babel/core": "~7.5.0",
|
||||
"@babel/core": "~7.5.4",
|
||||
"@babel/node": "~7.5.0",
|
||||
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
|
||||
"@babel/preset-env": "~7.5.2",
|
||||
"@babel/preset-env": "~7.5.4",
|
||||
"@babel/register": "~7.4.4",
|
||||
"apollo-server-testing": "~2.6.7",
|
||||
"apollo-server-testing": "~2.7.0",
|
||||
"babel-core": "~7.0.0-0",
|
||||
"babel-eslint": "~10.0.2",
|
||||
"babel-jest": "~24.8.0",
|
||||
@ -100,7 +99,7 @@
|
||||
"eslint-config-prettier": "~6.0.0",
|
||||
"eslint-config-standard": "~12.0.0",
|
||||
"eslint-plugin-import": "~2.18.0",
|
||||
"eslint-plugin-jest": "~22.7.2",
|
||||
"eslint-plugin-jest": "~22.9.0",
|
||||
"eslint-plugin-node": "~9.1.0",
|
||||
"eslint-plugin-prettier": "~3.1.0",
|
||||
"eslint-plugin-promise": "~4.2.1",
|
||||
|
||||
@ -1,88 +1,9 @@
|
||||
import Neode from 'neode'
|
||||
import uuid from 'uuid/v4'
|
||||
import models from '../models'
|
||||
|
||||
export default function setupNeode(options) {
|
||||
const { uri, username, password } = options
|
||||
const neodeInstance = new Neode(uri, username, password)
|
||||
neodeInstance.model('InvitationCode', {
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
token: { type: 'string', primary: true, token: true },
|
||||
generatedBy: {
|
||||
type: 'relationship',
|
||||
relationship: 'GENERATED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
activated: {
|
||||
type: 'relationship',
|
||||
relationship: 'ACTIVATED',
|
||||
target: 'EmailAddress',
|
||||
direction: 'out',
|
||||
},
|
||||
})
|
||||
neodeInstance.model('EmailAddress', {
|
||||
email: { type: 'string', primary: true, lowercase: true, email: true },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
verifiedAt: { type: 'string', isoDate: true },
|
||||
nonce: { type: 'string', token: true },
|
||||
belongsTo: {
|
||||
type: 'relationship',
|
||||
relationship: 'BELONGS_TO',
|
||||
target: 'User',
|
||||
direction: 'out',
|
||||
},
|
||||
})
|
||||
neodeInstance.model('User', {
|
||||
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
|
||||
actorId: { type: 'string', allow: [null] },
|
||||
name: { type: 'string', min: 3 },
|
||||
email: { type: 'string', lowercase: true, email: true },
|
||||
slug: 'string',
|
||||
encryptedPassword: 'string',
|
||||
avatar: { type: 'string', allow: [null] },
|
||||
coverImg: { type: 'string', allow: [null] },
|
||||
deleted: { type: 'boolean', default: false },
|
||||
disabled: { type: 'boolean', default: false },
|
||||
role: 'string',
|
||||
publicKey: 'string',
|
||||
privateKey: 'string',
|
||||
wasInvited: 'boolean',
|
||||
wasSeeded: 'boolean',
|
||||
locationName: { type: 'string', allow: [null] },
|
||||
about: { type: 'string', allow: [null] },
|
||||
primaryEmail: {
|
||||
type: 'relationship',
|
||||
relationship: 'PRIMARY_EMAIL',
|
||||
target: 'EmailAddress',
|
||||
direction: 'out',
|
||||
},
|
||||
following: {
|
||||
type: 'relationship',
|
||||
relationship: 'FOLLOWS',
|
||||
target: 'User',
|
||||
direction: 'out',
|
||||
},
|
||||
followedBy: {
|
||||
type: 'relationship',
|
||||
relationship: 'FOLLOWS',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
friends: { type: 'relationship', relationship: 'FRIENDS', target: 'User', direction: 'both' },
|
||||
disabledBy: {
|
||||
type: 'relationship',
|
||||
relationship: 'DISABLED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
isoDate: true,
|
||||
required: true,
|
||||
default: () => new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
neodeInstance.with(models)
|
||||
return neodeInstance
|
||||
}
|
||||
|
||||
@ -1,18 +1,8 @@
|
||||
import createServer from './server'
|
||||
import ActivityPub from './activitypub/ActivityPub'
|
||||
import CONFIG from './config'
|
||||
|
||||
const serverConfig = {
|
||||
port: CONFIG.GRAPHQL_PORT,
|
||||
// cors: {
|
||||
// credentials: true,
|
||||
// origin: [CONFIG.CLIENT_URI] // your frontend url.
|
||||
// }
|
||||
}
|
||||
|
||||
const server = createServer()
|
||||
server.start(serverConfig, options => {
|
||||
const { app } = createServer()
|
||||
app.listen({ port: CONFIG.GRAPHQL_PORT }, () => {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`)
|
||||
ActivityPub.init(server)
|
||||
})
|
||||
|
||||
@ -15,3 +15,9 @@ export async function login(variables) {
|
||||
authorization: `Bearer ${response.login}`,
|
||||
}
|
||||
}
|
||||
|
||||
//* This is a fake ES2015 template string, just to benefit of syntax
|
||||
// highlighting of `gql` template strings in certain editors.
|
||||
export function gql(strings) {
|
||||
return strings.join('')
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ export const signupTemplate = options => {
|
||||
} = options
|
||||
const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI)
|
||||
actionUrl.searchParams.set('nonce', nonce)
|
||||
actionUrl.searchParams.set('email', email)
|
||||
|
||||
return {
|
||||
to: email,
|
||||
|
||||
@ -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,285 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import { host, login, gql } 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,6 @@
|
||||
import { applyMiddleware } from 'graphql-middleware'
|
||||
import CONFIG from './../config'
|
||||
|
||||
import activityPub from './activityPubMiddleware'
|
||||
import softDelete from './softDeleteMiddleware'
|
||||
import sluggify from './sluggifyMiddleware'
|
||||
@ -10,7 +12,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 +23,7 @@ export default schema => {
|
||||
validation: validation,
|
||||
sluggify: sluggify,
|
||||
excerpt: excerpt,
|
||||
notifications: notifications,
|
||||
handleContentData: handleContentData,
|
||||
xss: xss,
|
||||
softDelete: softDelete,
|
||||
user: user,
|
||||
@ -38,7 +40,7 @@ export default schema => {
|
||||
'sluggify',
|
||||
'excerpt',
|
||||
'email',
|
||||
'notifications',
|
||||
'handleContentData',
|
||||
'xss',
|
||||
'softDelete',
|
||||
'user',
|
||||
@ -56,5 +58,6 @@ export default schema => {
|
||||
console.log(`Warning: "${disabledMiddlewares}" middlewares have been disabled.`)
|
||||
}
|
||||
|
||||
return order.map(key => middlewares[key])
|
||||
const appliedMiddlewares = order.map(key => middlewares[key])
|
||||
return applyMiddleware(schema, ...appliedMiddlewares)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
@ -146,6 +146,7 @@ const permissions = shield(
|
||||
Comment: allow,
|
||||
User: or(noEmailFilter, isAdmin),
|
||||
isLoggedIn: allow,
|
||||
Badge: allow,
|
||||
},
|
||||
Mutation: {
|
||||
'*': deny,
|
||||
@ -160,9 +161,6 @@ const permissions = shield(
|
||||
UpdatePost: isAuthor,
|
||||
DeletePost: isAuthor,
|
||||
report: isAuthenticated,
|
||||
CreateBadge: isAdmin,
|
||||
UpdateBadge: isAdmin,
|
||||
DeleteBadge: isAdmin,
|
||||
CreateSocialMedia: isAuthenticated,
|
||||
UpdateSocialMedia: isAuthenticated,
|
||||
DeleteSocialMedia: isAuthenticated,
|
||||
@ -179,6 +177,7 @@ const permissions = shield(
|
||||
enable: isModerator,
|
||||
disable: isModerator,
|
||||
CreateComment: isAuthenticated,
|
||||
UpdateComment: isAuthor,
|
||||
DeleteComment: isAuthor,
|
||||
DeleteUser: isDeletingOwnAccount,
|
||||
requestPasswordReset: allow,
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { UserInputError } from 'apollo-server'
|
||||
|
||||
const validateUrl = async (resolve, root, args, context, info) => {
|
||||
const { url } = args
|
||||
const isValid = url.match(/^(?:https?:\/\/)(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
|
||||
if (isValid) {
|
||||
/* eslint-disable-next-line no-return-await */
|
||||
return await resolve(root, args, context, info)
|
||||
} else {
|
||||
throw new UserInputError('Input is not a URL')
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreateSocialMedia: validateUrl,
|
||||
},
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import Joi from '@hapi/joi'
|
||||
|
||||
const COMMENT_MIN_LENGTH = 1
|
||||
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
|
||||
|
||||
const validate = schema => {
|
||||
return async (resolve, root, args, context, info) => {
|
||||
const validation = schema.validate(args)
|
||||
@ -15,8 +18,47 @@ const socialMediaSchema = Joi.object().keys({
|
||||
.required(),
|
||||
})
|
||||
|
||||
const validateCommentCreation = async (resolve, root, args, context, info) => {
|
||||
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
const { postId } = args
|
||||
|
||||
if (!args.content || content.length < COMMENT_MIN_LENGTH) {
|
||||
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
|
||||
}
|
||||
const session = context.driver.session()
|
||||
const postQueryRes = await session.run(
|
||||
`
|
||||
MATCH (post:Post {id: $postId})
|
||||
RETURN post`,
|
||||
{
|
||||
postId,
|
||||
},
|
||||
)
|
||||
const [post] = postQueryRes.records.map(record => {
|
||||
return record.get('post')
|
||||
})
|
||||
|
||||
if (!post) {
|
||||
throw new UserInputError(NO_POST_ERR_MESSAGE)
|
||||
} else {
|
||||
return resolve(root, args, context, info)
|
||||
}
|
||||
}
|
||||
|
||||
const validateUpdateComment = async (resolve, root, args, context, info) => {
|
||||
const COMMENT_MIN_LENGTH = 1
|
||||
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
if (!args.content || content.length < COMMENT_MIN_LENGTH) {
|
||||
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
|
||||
}
|
||||
|
||||
return resolve(root, args, context, info)
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreateSocialMedia: validate(socialMediaSchema),
|
||||
CreateComment: validateCommentCreation,
|
||||
UpdateComment: validateUpdateComment,
|
||||
},
|
||||
}
|
||||
|
||||
7
backend/src/models/Badge.js
Normal file
7
backend/src/models/Badge.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
id: { type: 'string', primary: true, lowercase: true },
|
||||
status: { type: 'string', valid: ['permanent', 'temporary'] },
|
||||
type: { type: 'string', valid: ['role', 'crowdfunding'] },
|
||||
icon: { type: 'string', required: true },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
}
|
||||
13
backend/src/models/EmailAddress.js
Normal file
13
backend/src/models/EmailAddress.js
Normal file
@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
email: { type: 'string', primary: true, lowercase: true, email: true },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
verifiedAt: { type: 'string', isoDate: true },
|
||||
nonce: { type: 'string', token: true },
|
||||
belongsTo: {
|
||||
type: 'relationship',
|
||||
relationship: 'BELONGS_TO',
|
||||
target: 'User',
|
||||
direction: 'out',
|
||||
eager: true,
|
||||
},
|
||||
}
|
||||
16
backend/src/models/InvitationCode.js
Normal file
16
backend/src/models/InvitationCode.js
Normal file
@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
token: { type: 'string', primary: true, token: true },
|
||||
generatedBy: {
|
||||
type: 'relationship',
|
||||
relationship: 'GENERATED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
activated: {
|
||||
type: 'relationship',
|
||||
relationship: 'ACTIVATED',
|
||||
target: 'EmailAddress',
|
||||
direction: 'out',
|
||||
},
|
||||
}
|
||||
59
backend/src/models/User.js
Normal file
59
backend/src/models/User.js
Normal file
@ -0,0 +1,59 @@
|
||||
import uuid from 'uuid/v4'
|
||||
|
||||
module.exports = {
|
||||
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
|
||||
actorId: { type: 'string', allow: [null] },
|
||||
name: { type: 'string', min: 3 },
|
||||
slug: 'string',
|
||||
encryptedPassword: 'string',
|
||||
avatar: { type: 'string', allow: [null] },
|
||||
coverImg: { type: 'string', allow: [null] },
|
||||
deleted: { type: 'boolean', default: false },
|
||||
disabled: { type: 'boolean', default: false },
|
||||
role: { type: 'string', default: 'user' },
|
||||
publicKey: 'string',
|
||||
privateKey: 'string',
|
||||
wasInvited: 'boolean',
|
||||
wasSeeded: 'boolean',
|
||||
locationName: { type: 'string', allow: [null] },
|
||||
about: { type: 'string', allow: [null] },
|
||||
primaryEmail: {
|
||||
type: 'relationship',
|
||||
relationship: 'PRIMARY_EMAIL',
|
||||
target: 'EmailAddress',
|
||||
direction: 'out',
|
||||
},
|
||||
following: {
|
||||
type: 'relationship',
|
||||
relationship: 'FOLLOWS',
|
||||
target: 'User',
|
||||
direction: 'out',
|
||||
},
|
||||
followedBy: {
|
||||
type: 'relationship',
|
||||
relationship: 'FOLLOWS',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
friends: { type: 'relationship', relationship: 'FRIENDS', target: 'User', direction: 'both' },
|
||||
disabledBy: {
|
||||
type: 'relationship',
|
||||
relationship: 'DISABLED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
rewarded: {
|
||||
type: 'relationship',
|
||||
relationship: 'REWARDED',
|
||||
target: 'Badge',
|
||||
direction: 'in',
|
||||
},
|
||||
invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
isoDate: true,
|
||||
required: true,
|
||||
default: () => new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
20
backend/src/models/User.spec.js
Normal file
20
backend/src/models/User.spec.js
Normal file
@ -0,0 +1,20 @@
|
||||
import Factory from '../seed/factories'
|
||||
import { neode } from '../bootstrap/neo4j'
|
||||
|
||||
const factory = Factory()
|
||||
const instance = neode()
|
||||
|
||||
afterEach(async () => {
|
||||
await factory.cleanDatabase()
|
||||
})
|
||||
|
||||
describe('role', () => {
|
||||
it('defaults to `user`', async () => {
|
||||
const user = await instance.create('User', { name: 'John' })
|
||||
await expect(user.toJson()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
8
backend/src/models/index.js
Normal file
8
backend/src/models/index.js
Normal file
@ -0,0 +1,8 @@
|
||||
// NOTE: We cannot use `fs` here to clean up the code. Cypress breaks on any npm
|
||||
// module that is not browser-compatible. Node's `fs` module is server-side only
|
||||
export default {
|
||||
Badge: require('./Badge.js'),
|
||||
User: require('./User.js'),
|
||||
InvitationCode: require('./InvitationCode.js'),
|
||||
EmailAddress: require('./EmailAddress.js'),
|
||||
}
|
||||
@ -12,11 +12,25 @@ export default applyScalars(
|
||||
resolvers,
|
||||
config: {
|
||||
query: {
|
||||
exclude: ['InvitationCode', 'EmailAddress', 'Notfication', 'Statistics', 'LoggedInUser'],
|
||||
exclude: [
|
||||
'Badge',
|
||||
'InvitationCode',
|
||||
'EmailAddress',
|
||||
'Notfication',
|
||||
'Statistics',
|
||||
'LoggedInUser',
|
||||
],
|
||||
// add 'User' here as soon as possible
|
||||
},
|
||||
mutation: {
|
||||
exclude: ['InvitationCode', 'EmailAddress', 'Notfication', 'Statistics', 'LoggedInUser'],
|
||||
exclude: [
|
||||
'Badge',
|
||||
'InvitationCode',
|
||||
'EmailAddress',
|
||||
'Notfication',
|
||||
'Statistics',
|
||||
'LoggedInUser',
|
||||
],
|
||||
// add 'User' here as soon as possible
|
||||
},
|
||||
debug: CONFIG.DEBUG,
|
||||
|
||||
9
backend/src/schema/resolvers/badges.js
Normal file
9
backend/src/schema/resolvers/badges.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
Badge: async (object, args, context, resolveInfo) => {
|
||||
return neo4jgraphql(object, args, context, resolveInfo, false)
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -1,200 +0,0 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import Factory from '../../seed/factories'
|
||||
import { host, login } from '../../jest/helpers'
|
||||
|
||||
const factory = Factory()
|
||||
let client
|
||||
|
||||
describe('badges', () => {
|
||||
beforeEach(async () => {
|
||||
await factory.create('User', {
|
||||
email: 'user@example.org',
|
||||
role: 'user',
|
||||
password: '1234',
|
||||
})
|
||||
await factory.create('User', {
|
||||
id: 'u2',
|
||||
role: 'moderator',
|
||||
email: 'moderator@example.org',
|
||||
})
|
||||
await factory.create('User', {
|
||||
id: 'u3',
|
||||
role: 'admin',
|
||||
email: 'admin@example.org',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await factory.cleanDatabase()
|
||||
})
|
||||
|
||||
describe('CreateBadge', () => {
|
||||
const variables = {
|
||||
id: 'b1',
|
||||
key: 'indiegogo_en_racoon',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
icon: '/img/badges/indiegogo_en_racoon.svg',
|
||||
}
|
||||
|
||||
const mutation = `
|
||||
mutation(
|
||||
$id: ID
|
||||
$key: String!
|
||||
$type: BadgeType!
|
||||
$status: BadgeStatus!
|
||||
$icon: String!
|
||||
) {
|
||||
CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) {
|
||||
id,
|
||||
key,
|
||||
type,
|
||||
status,
|
||||
icon
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated admin', () => {
|
||||
beforeEach(async () => {
|
||||
const headers = await login({ email: 'admin@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
it('creates a badge', async () => {
|
||||
const expected = {
|
||||
CreateBadge: {
|
||||
icon: '/img/badges/indiegogo_en_racoon.svg',
|
||||
id: 'b1',
|
||||
key: 'indiegogo_en_racoon',
|
||||
status: 'permanent',
|
||||
type: 'crowdfunding',
|
||||
},
|
||||
}
|
||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated moderator', () => {
|
||||
beforeEach(async () => {
|
||||
const headers = await login({ email: 'moderator@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('UpdateBadge', () => {
|
||||
beforeEach(async () => {
|
||||
await factory.authenticateAs({ email: 'admin@example.org', password: '1234' })
|
||||
await factory.create('Badge', { id: 'b1' })
|
||||
})
|
||||
const variables = {
|
||||
id: 'b1',
|
||||
key: 'whatever',
|
||||
}
|
||||
|
||||
const mutation = `
|
||||
mutation($id: ID!, $key: String!) {
|
||||
UpdateBadge(id: $id, key: $key) {
|
||||
id
|
||||
key
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated moderator', () => {
|
||||
beforeEach(async () => {
|
||||
const headers = await login({ email: 'moderator@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated admin', () => {
|
||||
beforeEach(async () => {
|
||||
const headers = await login({ email: 'admin@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
it('updates a badge', async () => {
|
||||
const expected = {
|
||||
UpdateBadge: {
|
||||
id: 'b1',
|
||||
key: 'whatever',
|
||||
},
|
||||
}
|
||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeleteBadge', () => {
|
||||
beforeEach(async () => {
|
||||
await factory.authenticateAs({ email: 'admin@example.org', password: '1234' })
|
||||
await factory.create('Badge', { id: 'b1' })
|
||||
})
|
||||
const variables = {
|
||||
id: 'b1',
|
||||
}
|
||||
|
||||
const mutation = `
|
||||
mutation($id: ID!) {
|
||||
DeleteBadge(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated moderator', () => {
|
||||
beforeEach(async () => {
|
||||
const headers = await login({ email: 'moderator@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated admin', () => {
|
||||
beforeEach(async () => {
|
||||
const headers = await login({ email: 'admin@example.org', password: '1234' })
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
it('deletes a badge', async () => {
|
||||
const expected = {
|
||||
DeleteBadge: {
|
||||
id: 'b1',
|
||||
},
|
||||
}
|
||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,40 +1,15 @@
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
import { UserInputError } from 'apollo-server'
|
||||
|
||||
const COMMENT_MIN_LENGTH = 1
|
||||
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreateComment: async (object, params, context, resolveInfo) => {
|
||||
const content = params.content.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
const { postId } = params
|
||||
// Adding relationship from comment to post by passing in the postId,
|
||||
// but we do not want to create the comment with postId as an attribute
|
||||
// because we use relationships for this. So, we are deleting it from params
|
||||
// before comment creation.
|
||||
delete params.postId
|
||||
|
||||
if (!params.content || content.length < COMMENT_MIN_LENGTH) {
|
||||
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
|
||||
}
|
||||
|
||||
const session = context.driver.session()
|
||||
const postQueryRes = await session.run(
|
||||
`
|
||||
MATCH (post:Post {id: $postId})
|
||||
RETURN post`,
|
||||
{
|
||||
postId,
|
||||
},
|
||||
)
|
||||
const [post] = postQueryRes.records.map(record => {
|
||||
return record.get('post')
|
||||
})
|
||||
|
||||
if (!post) {
|
||||
throw new UserInputError(NO_POST_ERR_MESSAGE)
|
||||
}
|
||||
const commentWithoutRelationships = await neo4jgraphql(
|
||||
object,
|
||||
params,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import gql from 'graphql-tag'
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import Factory from '../../seed/factories'
|
||||
import { host, login } from '../../jest/helpers'
|
||||
import { host, login, gql } from '../../jest/helpers'
|
||||
|
||||
const factory = Factory()
|
||||
let client
|
||||
@ -10,7 +9,28 @@ let createPostVariables
|
||||
let createCommentVariablesSansPostId
|
||||
let createCommentVariablesWithNonExistentPost
|
||||
let userParams
|
||||
let authorParams
|
||||
let headers
|
||||
|
||||
const createPostMutation = gql`
|
||||
mutation($id: ID!, $title: String!, $content: String!) {
|
||||
CreatePost(id: $id, title: $title, content: $content) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const createCommentMutation = gql`
|
||||
mutation($id: ID, $postId: ID!, $content: String!) {
|
||||
CreateComment(id: $id, postId: $postId, content: $content) {
|
||||
id
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
createPostVariables = {
|
||||
id: 'p1',
|
||||
title: 'post to comment on',
|
||||
content: 'please comment on me',
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
userParams = {
|
||||
@ -26,21 +46,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('CreateComment', () => {
|
||||
const createCommentMutation = gql`
|
||||
mutation($postId: ID!, $content: String!) {
|
||||
CreateComment(postId: $postId, content: $content) {
|
||||
id
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
const createPostMutation = gql`
|
||||
mutation($id: ID!, $title: String!, $content: String!) {
|
||||
CreatePost(id: $id, title: $title, content: $content) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
createCommentVariables = {
|
||||
@ -55,7 +60,6 @@ describe('CreateComment', () => {
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
let headers
|
||||
beforeEach(async () => {
|
||||
headers = await login(userParams)
|
||||
client = new GraphQLClient(host, {
|
||||
@ -65,11 +69,6 @@ describe('CreateComment', () => {
|
||||
postId: 'p1',
|
||||
content: "I'm authorised to comment",
|
||||
}
|
||||
createPostVariables = {
|
||||
id: 'p1',
|
||||
title: 'post to comment on',
|
||||
content: 'please comment on me',
|
||||
}
|
||||
await client.request(createPostMutation, createPostVariables)
|
||||
})
|
||||
|
||||
@ -188,19 +187,8 @@ describe('CreateComment', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeleteComment', () => {
|
||||
const deleteCommentMutation = gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteComment(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
let deleteCommentVariables = {
|
||||
id: 'c1',
|
||||
}
|
||||
|
||||
describe('ManageComments', () => {
|
||||
let authorParams
|
||||
beforeEach(async () => {
|
||||
authorParams = {
|
||||
email: 'author@example.org',
|
||||
@ -214,51 +202,178 @@ describe('DeleteComment', () => {
|
||||
content: 'Post to be commented',
|
||||
})
|
||||
await asAuthor.create('Comment', {
|
||||
id: 'c1',
|
||||
id: 'c456',
|
||||
postId: 'p1',
|
||||
content: 'Comment to be deleted',
|
||||
})
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated but not the author', () => {
|
||||
beforeEach(async () => {
|
||||
let headers
|
||||
headers = await login(userParams)
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated as author', () => {
|
||||
beforeEach(async () => {
|
||||
let headers
|
||||
headers = await login(authorParams)
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
it('deletes the comment', async () => {
|
||||
const expected = {
|
||||
DeleteComment: {
|
||||
id: 'c1',
|
||||
},
|
||||
describe('UpdateComment', () => {
|
||||
const updateCommentMutation = gql`
|
||||
mutation($content: String!, $id: ID!) {
|
||||
UpdateComment(content: $content, id: $id) {
|
||||
id
|
||||
content
|
||||
}
|
||||
}
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).resolves.toEqual(
|
||||
expected,
|
||||
)
|
||||
`
|
||||
|
||||
let updateCommentVariables = {
|
||||
id: 'c456',
|
||||
content: 'The comment is updated',
|
||||
}
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated but not the author', () => {
|
||||
beforeEach(async () => {
|
||||
headers = await login({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated as author', () => {
|
||||
beforeEach(async () => {
|
||||
headers = await login(authorParams)
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the comment', async () => {
|
||||
const expected = {
|
||||
UpdateComment: {
|
||||
id: 'c456',
|
||||
content: 'The comment is updated',
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
client.request(updateCommentMutation, updateCommentVariables),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('throw an error if an empty string is sent from the editor as content', async () => {
|
||||
updateCommentVariables = {
|
||||
id: 'c456',
|
||||
content: '<p></p>',
|
||||
}
|
||||
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Comment must be at least 1 character long!',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if a comment sent from the editor does not contain a single letter character', async () => {
|
||||
updateCommentVariables = {
|
||||
id: 'c456',
|
||||
content: '<p> </p>',
|
||||
}
|
||||
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Comment must be at least 1 character long!',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if commentId is sent as an empty string', async () => {
|
||||
updateCommentVariables = {
|
||||
id: '',
|
||||
content: '<p>Hello</p>',
|
||||
}
|
||||
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised!',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if the comment does not exist in the database', async () => {
|
||||
updateCommentVariables = {
|
||||
id: 'c1000',
|
||||
content: '<p>Hello</p>',
|
||||
}
|
||||
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised!',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeleteComment', () => {
|
||||
const deleteCommentMutation = gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteComment(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
let deleteCommentVariables = {
|
||||
id: 'c456',
|
||||
}
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated but not the author', () => {
|
||||
beforeEach(async () => {
|
||||
headers = await login({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated as author', () => {
|
||||
beforeEach(async () => {
|
||||
headers = await login(authorParams)
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the comment', async () => {
|
||||
const expected = {
|
||||
DeleteComment: {
|
||||
id: 'c456',
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
client.request(deleteCommentMutation, deleteCommentVariables),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,7 +5,7 @@ export async function createPasswordReset(options) {
|
||||
const { driver, code, email, issuedAt = new Date() } = options
|
||||
const session = driver.session()
|
||||
const cypher = `
|
||||
MATCH (u:User) WHERE u.email = $email
|
||||
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
|
||||
CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL})
|
||||
MERGE (u)-[:REQUESTED]->(pr)
|
||||
RETURN u
|
||||
@ -35,7 +35,7 @@ export default {
|
||||
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
|
||||
const cypher = `
|
||||
MATCH (pr:PasswordReset {code: $code})
|
||||
MATCH (u:User {email: $email})-[:REQUESTED]->(pr)
|
||||
MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
|
||||
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
|
||||
SET pr.usedAt = datetime()
|
||||
SET u.encryptedPassword = $encryptedNewPassword
|
||||
|
||||
@ -18,10 +18,11 @@ const createPostWithCategoriesMutation = `
|
||||
mutation($title: String!, $content: String!, $categoryIds: [ID]) {
|
||||
CreatePost(title: $title, content: $content, categoryIds: $categoryIds) {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
`
|
||||
const creatPostWithCategoriesVariables = {
|
||||
const createPostWithCategoriesVariables = {
|
||||
title: postTitle,
|
||||
content: postContent,
|
||||
categoryIds: ['cat9', 'cat4', 'cat15'],
|
||||
@ -35,6 +36,26 @@ const postQueryWithCategories = `
|
||||
}
|
||||
}
|
||||
`
|
||||
const createPostWithoutCategoriesVariables = {
|
||||
title: 'This is a post without categories',
|
||||
content: 'I should be able to filter it out',
|
||||
categoryIds: null,
|
||||
}
|
||||
const postQueryFilteredByCategory = `
|
||||
query Post($filter: _PostFilter) {
|
||||
Post(filter: $filter) {
|
||||
title
|
||||
id
|
||||
categories {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const postCategoriesFilterParam = { categories_some: { id_in: ['cat4'] } }
|
||||
const postQueryFilteredByCategoryVariables = {
|
||||
filter: postCategoriesFilterParam,
|
||||
}
|
||||
beforeEach(async () => {
|
||||
userParams = {
|
||||
name: 'TestUser',
|
||||
@ -133,7 +154,8 @@ describe('CreatePost', () => {
|
||||
})
|
||||
|
||||
describe('categories', () => {
|
||||
it('allows a user to set the categories of the post', async () => {
|
||||
let postWithCategories
|
||||
beforeEach(async () => {
|
||||
await Promise.all([
|
||||
factory.create('Category', {
|
||||
id: 'cat9',
|
||||
@ -151,18 +173,39 @@ describe('CreatePost', () => {
|
||||
icon: 'shopping-cart',
|
||||
}),
|
||||
])
|
||||
const expected = [{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]
|
||||
const postWithCategories = await client.request(
|
||||
postWithCategories = await client.request(
|
||||
createPostWithCategoriesMutation,
|
||||
creatPostWithCategoriesVariables,
|
||||
createPostWithCategoriesVariables,
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a user to set the categories of the post', async () => {
|
||||
const expected = [{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]
|
||||
const postQueryWithCategoriesVariables = {
|
||||
id: postWithCategories.CreatePost.id,
|
||||
}
|
||||
|
||||
await expect(
|
||||
client.request(postQueryWithCategories, postQueryWithCategoriesVariables),
|
||||
).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] })
|
||||
})
|
||||
|
||||
it('allows a user to filter for posts by category', async () => {
|
||||
await client.request(createPostWithCategoriesMutation, createPostWithoutCategoriesVariables)
|
||||
const categoryIds = [{ id: 'cat4' }, { id: 'cat15' }, { id: 'cat9' }]
|
||||
const expected = {
|
||||
Post: [
|
||||
{
|
||||
title: postTitle,
|
||||
id: postWithCategories.CreatePost.id,
|
||||
categories: expect.arrayContaining(categoryIds),
|
||||
},
|
||||
],
|
||||
}
|
||||
await expect(
|
||||
client.request(postQueryFilteredByCategory, postQueryFilteredByCategoryVariables),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -260,7 +303,7 @@ describe('UpdatePost', () => {
|
||||
])
|
||||
postWithCategories = await client.request(
|
||||
createPostWithCategoriesMutation,
|
||||
creatPostWithCategoriesVariables,
|
||||
createPostWithCategoriesVariables,
|
||||
)
|
||||
updatePostVariables = {
|
||||
id: postWithCategories.CreatePost.id,
|
||||
|
||||
@ -12,8 +12,8 @@ const instance = neode()
|
||||
*/
|
||||
const checkEmailDoesNotExist = async ({ email }) => {
|
||||
email = email.toLowerCase()
|
||||
const users = await instance.all('User', { email })
|
||||
if (users.length > 0) throw new UserInputError('User account with this email already exists.')
|
||||
const emails = await instance.all('EmailAddress', { email })
|
||||
if (emails.length > 0) throw new UserInputError('User account with this email already exists.')
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@ -166,11 +166,12 @@ describe('SignupByInvitation', () => {
|
||||
await expect(action()).rejects.toThrow('"email" must be a valid email')
|
||||
})
|
||||
|
||||
it('creates no EmailAddress node', async done => {
|
||||
it('creates no additional EmailAddress node', async done => {
|
||||
try {
|
||||
await action()
|
||||
} catch (e) {
|
||||
const emailAddresses = await instance.all('EmailAddress')
|
||||
let emailAddresses = await instance.all('EmailAddress')
|
||||
emailAddresses = await emailAddresses.toJson
|
||||
expect(emailAddresses).toHaveLength(0)
|
||||
done()
|
||||
}
|
||||
@ -191,16 +192,16 @@ describe('SignupByInvitation', () => {
|
||||
describe('creates a EmailAddress node', () => {
|
||||
it('with a `createdAt` attribute', async () => {
|
||||
await action()
|
||||
const emailAddresses = await instance.all('EmailAddress')
|
||||
const emailAddress = await emailAddresses.first().toJson()
|
||||
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||
emailAddress = await emailAddress.toJson()
|
||||
expect(emailAddress.createdAt).toBeTruthy()
|
||||
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
|
||||
})
|
||||
|
||||
it('with a cryptographic `nonce`', async () => {
|
||||
await action()
|
||||
const emailAddresses = await instance.all('EmailAddress')
|
||||
const emailAddress = await emailAddresses.first().toJson()
|
||||
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||
emailAddress = await emailAddress.toJson()
|
||||
expect(emailAddress.nonce).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
@ -220,6 +221,7 @@ describe('SignupByInvitation', () => {
|
||||
it('rejects because codes can be used only once', async done => {
|
||||
await action()
|
||||
try {
|
||||
variables.email = 'yetanotheremail@example.org'
|
||||
await action()
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/Invitation code already used/)
|
||||
@ -282,8 +284,8 @@ describe('Signup', () => {
|
||||
|
||||
it('creates a Signup with a cryptographic `nonce`', async () => {
|
||||
await action()
|
||||
const emailAddresses = await instance.all('EmailAddress')
|
||||
const emailAddress = await emailAddresses.first().toJson()
|
||||
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||
emailAddress = await emailAddress.toJson()
|
||||
expect(emailAddress.nonce).toEqual(expect.any(String))
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,47 +1,47 @@
|
||||
import { neode } from '../../bootstrap/neo4j'
|
||||
import { UserInputError } from 'apollo-server'
|
||||
|
||||
const instance = neode()
|
||||
|
||||
const getUserAndBadge = async ({ badgeKey, userId }) => {
|
||||
let user = await instance.first('User', 'id', userId)
|
||||
const badge = await instance.first('Badge', 'id', badgeKey)
|
||||
if (!user) throw new UserInputError("Couldn't find a user with that id")
|
||||
if (!badge) throw new UserInputError("Couldn't find a badge with that id")
|
||||
return { user, badge }
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
reward: async (_object, params, context, _resolveInfo) => {
|
||||
const { fromBadgeId, toUserId } = params
|
||||
const session = context.driver.session()
|
||||
|
||||
let transactionRes = await session.run(
|
||||
`MATCH (badge:Badge {id: $badgeId}), (rewardedUser:User {id: $rewardedUserId})
|
||||
MERGE (badge)-[:REWARDED]->(rewardedUser)
|
||||
RETURN rewardedUser {.id}`,
|
||||
{
|
||||
badgeId: fromBadgeId,
|
||||
rewardedUserId: toUserId,
|
||||
},
|
||||
)
|
||||
|
||||
const [rewardedUser] = transactionRes.records.map(record => {
|
||||
return record.get('rewardedUser')
|
||||
})
|
||||
|
||||
session.close()
|
||||
|
||||
return rewardedUser.id
|
||||
const { user, badge } = await getUserAndBadge(params)
|
||||
await user.relateTo(badge, 'rewarded')
|
||||
return user.toJson()
|
||||
},
|
||||
|
||||
unreward: async (_object, params, context, _resolveInfo) => {
|
||||
const { fromBadgeId, toUserId } = params
|
||||
const { badgeKey, userId } = params
|
||||
const { user } = await getUserAndBadge(params)
|
||||
const session = context.driver.session()
|
||||
|
||||
let transactionRes = await session.run(
|
||||
`MATCH (badge:Badge {id: $badgeId})-[reward:REWARDED]->(rewardedUser:User {id: $rewardedUserId})
|
||||
DELETE reward
|
||||
RETURN rewardedUser {.id}`,
|
||||
{
|
||||
badgeId: fromBadgeId,
|
||||
rewardedUserId: toUserId,
|
||||
},
|
||||
)
|
||||
const [rewardedUser] = transactionRes.records.map(record => {
|
||||
return record.get('rewardedUser')
|
||||
})
|
||||
session.close()
|
||||
|
||||
return rewardedUser.id
|
||||
try {
|
||||
// silly neode cannot remove relationships
|
||||
await session.run(
|
||||
`
|
||||
MATCH (badge:Badge {id: $badgeKey})-[reward:REWARDED]->(rewardedUser:User {id: $userId})
|
||||
DELETE reward
|
||||
RETURN rewardedUser
|
||||
`,
|
||||
{
|
||||
badgeKey,
|
||||
userId,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
throw err
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
return user.toJson()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import Factory from '../../seed/factories'
|
||||
import { host, login } from '../../jest/helpers'
|
||||
import { host, login, gql } from '../../jest/helpers'
|
||||
|
||||
const factory = Factory()
|
||||
let user
|
||||
let badge
|
||||
|
||||
describe('rewards', () => {
|
||||
const variables = {
|
||||
from: 'indiegogo_en_rhino',
|
||||
to: 'u1',
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await factory.create('User', {
|
||||
user = await factory.create('User', {
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
email: 'user@example.org',
|
||||
@ -22,9 +29,8 @@ describe('rewards', () => {
|
||||
role: 'admin',
|
||||
email: 'admin@example.org',
|
||||
})
|
||||
await factory.create('Badge', {
|
||||
id: 'b6',
|
||||
key: 'indiegogo_en_rhino',
|
||||
badge = await factory.create('Badge', {
|
||||
id: 'indiegogo_en_rhino',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
icon: '/img/badges/indiegogo_en_rhino.svg',
|
||||
@ -35,21 +41,19 @@ describe('rewards', () => {
|
||||
await factory.cleanDatabase()
|
||||
})
|
||||
|
||||
describe('RewardBadge', () => {
|
||||
const mutation = `
|
||||
mutation(
|
||||
$from: ID!
|
||||
$to: ID!
|
||||
) {
|
||||
reward(fromBadgeId: $from, toUserId: $to)
|
||||
describe('reward', () => {
|
||||
const mutation = gql`
|
||||
mutation($from: ID!, $to: ID!) {
|
||||
reward(badgeKey: $from, userId: $to) {
|
||||
id
|
||||
badges {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
const variables = {
|
||||
from: 'b6',
|
||||
to: 'u1',
|
||||
}
|
||||
let client
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
@ -65,74 +69,95 @@ describe('rewards', () => {
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
describe('badge for id does not exist', () => {
|
||||
it('rejects with a telling error message', async () => {
|
||||
await expect(
|
||||
client.request(mutation, {
|
||||
...variables,
|
||||
from: 'bullshit',
|
||||
}),
|
||||
).rejects.toThrow("Couldn't find a badge with that id")
|
||||
})
|
||||
})
|
||||
|
||||
describe('user for id does not exist', () => {
|
||||
it('rejects with a telling error message', async () => {
|
||||
await expect(
|
||||
client.request(mutation, {
|
||||
...variables,
|
||||
to: 'bullshit',
|
||||
}),
|
||||
).rejects.toThrow("Couldn't find a user with that id")
|
||||
})
|
||||
})
|
||||
|
||||
it('rewards a badge to user', async () => {
|
||||
const variables = {
|
||||
from: 'b6',
|
||||
to: 'u1',
|
||||
}
|
||||
const expected = {
|
||||
reward: 'u1',
|
||||
reward: {
|
||||
id: 'u1',
|
||||
badges: [{ id: 'indiegogo_en_rhino' }],
|
||||
},
|
||||
}
|
||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('rewards a second different badge to same user', async () => {
|
||||
await factory.create('Badge', {
|
||||
id: 'b1',
|
||||
key: 'indiegogo_en_racoon',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
id: 'indiegogo_en_racoon',
|
||||
icon: '/img/badges/indiegogo_en_racoon.svg',
|
||||
})
|
||||
const variables = {
|
||||
from: 'b1',
|
||||
to: 'u1',
|
||||
}
|
||||
const badges = [{ id: 'indiegogo_en_racoon' }, { id: 'indiegogo_en_rhino' }]
|
||||
const expected = {
|
||||
reward: 'u1',
|
||||
reward: {
|
||||
id: 'u1',
|
||||
badges: expect.arrayContaining(badges),
|
||||
},
|
||||
}
|
||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||
await client.request(mutation, variables)
|
||||
await expect(
|
||||
client.request(mutation, {
|
||||
...variables,
|
||||
from: 'indiegogo_en_racoon',
|
||||
}),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('rewards the same badge as well to another user', async () => {
|
||||
const variables1 = {
|
||||
from: 'b6',
|
||||
to: 'u1',
|
||||
}
|
||||
await client.request(mutation, variables1)
|
||||
|
||||
const variables2 = {
|
||||
from: 'b6',
|
||||
to: 'u2',
|
||||
}
|
||||
const expected = {
|
||||
reward: 'u2',
|
||||
reward: {
|
||||
id: 'u2',
|
||||
badges: [{ id: 'indiegogo_en_rhino' }],
|
||||
},
|
||||
}
|
||||
await expect(client.request(mutation, variables2)).resolves.toEqual(expected)
|
||||
await expect(
|
||||
client.request(mutation, {
|
||||
...variables,
|
||||
to: 'u2',
|
||||
}),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
it('returns the original reward if a reward is attempted a second time', async () => {
|
||||
const variables = {
|
||||
from: 'b6',
|
||||
to: 'u1',
|
||||
}
|
||||
|
||||
it('creates no duplicate reward relationships', async () => {
|
||||
await client.request(mutation, variables)
|
||||
await client.request(mutation, variables)
|
||||
|
||||
const query = `{
|
||||
User( id: "u1" ) {
|
||||
badgesCount
|
||||
const query = gql`
|
||||
{
|
||||
User(id: "u1") {
|
||||
badgesCount
|
||||
badges {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const expected = { User: [{ badgesCount: 1 }] }
|
||||
const expected = { User: [{ badgesCount: 1, badges: [{ id: 'indiegogo_en_rhino' }] }] }
|
||||
|
||||
await expect(client.request(query)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated moderator', () => {
|
||||
const variables = {
|
||||
from: 'b6',
|
||||
to: 'u1',
|
||||
}
|
||||
let client
|
||||
beforeEach(async () => {
|
||||
const headers = await login({ email: 'moderator@example.org', password: '1234' })
|
||||
@ -147,27 +172,41 @@ describe('rewards', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('RemoveReward', () => {
|
||||
describe('unreward', () => {
|
||||
beforeEach(async () => {
|
||||
await factory.relate('User', 'Badges', { from: 'b6', to: 'u1' })
|
||||
await user.relateTo(badge, 'rewarded')
|
||||
})
|
||||
const variables = {
|
||||
from: 'b6',
|
||||
to: 'u1',
|
||||
}
|
||||
const expected = {
|
||||
unreward: 'u1',
|
||||
}
|
||||
const expected = { unreward: { id: 'u1', badges: [] } }
|
||||
|
||||
const mutation = `
|
||||
mutation(
|
||||
$from: ID!
|
||||
$to: ID!
|
||||
) {
|
||||
unreward(fromBadgeId: $from, toUserId: $to)
|
||||
const mutation = gql`
|
||||
mutation($from: ID!, $to: ID!) {
|
||||
unreward(badgeKey: $from, userId: $to) {
|
||||
id
|
||||
badges {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('check test setup', () => {
|
||||
it('user has one badge', async () => {
|
||||
const query = gql`
|
||||
{
|
||||
User(id: "u1") {
|
||||
badgesCount
|
||||
badges {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const expected = { User: [{ badgesCount: 1, badges: [{ id: 'indiegogo_en_rhino' }] }] }
|
||||
const client = new GraphQLClient(host)
|
||||
await expect(client.request(query)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
let client
|
||||
|
||||
@ -188,12 +227,9 @@ describe('rewards', () => {
|
||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('fails to remove a not existing badge from user', async () => {
|
||||
it('does not crash when unrewarding multiple times', async () => {
|
||||
await client.request(mutation, variables)
|
||||
|
||||
await expect(client.request(mutation, variables)).rejects.toThrow(
|
||||
"Cannot read property 'id' of undefined",
|
||||
)
|
||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import gql from 'graphql-tag'
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import Factory from '../../seed/factories'
|
||||
import { host, login } from '../../jest/helpers'
|
||||
import { host, login, gql } from '../../jest/helpers'
|
||||
|
||||
const factory = Factory()
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@ import encode from '../../jwt/encode'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AuthenticationError } from 'apollo-server'
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
import { neode } from '../../bootstrap/neo4j'
|
||||
|
||||
const instance = neode()
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
@ -21,8 +24,8 @@ export default {
|
||||
// }
|
||||
const session = driver.session()
|
||||
const result = await session.run(
|
||||
'MATCH (user:User {email: $userEmail}) ' +
|
||||
'RETURN user {.id, .slug, .name, .avatar, .email, .encryptedPassword, .role, .disabled} as user LIMIT 1',
|
||||
'MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})' +
|
||||
'RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1',
|
||||
{
|
||||
userEmail: email,
|
||||
},
|
||||
@ -46,41 +49,24 @@ export default {
|
||||
}
|
||||
},
|
||||
changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => {
|
||||
const session = driver.session()
|
||||
let result = await session.run(
|
||||
`MATCH (user:User {email: $userEmail})
|
||||
RETURN user {.id, .email, .encryptedPassword}`,
|
||||
{
|
||||
userEmail: user.email,
|
||||
},
|
||||
)
|
||||
let currentUser = await instance.find('User', user.id)
|
||||
|
||||
const [currentUser] = result.records.map(function(record) {
|
||||
return record.get('user')
|
||||
})
|
||||
|
||||
if (!(await bcrypt.compareSync(oldPassword, currentUser.encryptedPassword))) {
|
||||
const encryptedPassword = currentUser.get('encryptedPassword')
|
||||
if (!(await bcrypt.compareSync(oldPassword, encryptedPassword))) {
|
||||
throw new AuthenticationError('Old password is not correct')
|
||||
}
|
||||
|
||||
if (await bcrypt.compareSync(newPassword, currentUser.encryptedPassword)) {
|
||||
if (await bcrypt.compareSync(newPassword, encryptedPassword)) {
|
||||
throw new AuthenticationError('Old password and new password should be different')
|
||||
} else {
|
||||
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
|
||||
session.run(
|
||||
`MATCH (user:User {email: $userEmail})
|
||||
SET user.encryptedPassword = $newEncryptedPassword
|
||||
RETURN user
|
||||
`,
|
||||
{
|
||||
userEmail: user.email,
|
||||
newEncryptedPassword,
|
||||
},
|
||||
)
|
||||
session.close()
|
||||
|
||||
return encode(currentUser)
|
||||
}
|
||||
|
||||
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
|
||||
await currentUser.update({
|
||||
encryptedPassword: newEncryptedPassword,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return encode(await currentUser.toJson())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -65,6 +65,13 @@ export const hasOne = obj => {
|
||||
export default {
|
||||
Query: {
|
||||
User: async (object, args, context, resolveInfo) => {
|
||||
const { email } = args
|
||||
if (email) {
|
||||
const e = await instance.first('EmailAddress', { email })
|
||||
let user = e.get('belongsTo')
|
||||
user = await user.toJson()
|
||||
return [user.node]
|
||||
}
|
||||
return neo4jgraphql(object, args, context, resolveInfo, false)
|
||||
},
|
||||
},
|
||||
@ -104,6 +111,14 @@ export default {
|
||||
},
|
||||
},
|
||||
User: {
|
||||
email: async (parent, params, context, resolveInfo) => {
|
||||
if (typeof parent.email !== 'undefined') return parent.email
|
||||
const { id } = parent
|
||||
const statement = `MATCH(u:User {id: {id}})-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e`
|
||||
const result = await instance.cypher(statement, { id })
|
||||
let [{ email }] = result.records.map(r => r.get('e').properties)
|
||||
return email
|
||||
},
|
||||
...undefinedToNull([
|
||||
'actorId',
|
||||
'avatar',
|
||||
@ -139,7 +154,7 @@ export default {
|
||||
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
|
||||
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
|
||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||
badges: '-[:REWARDED]->(related:Badge)',
|
||||
badges: '<-[:REWARDED]-(related:Badge)',
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import { login, host } from '../../jest/helpers'
|
||||
import Factory from '../../seed/factories'
|
||||
import gql from 'graphql-tag'
|
||||
import { host, login, gql } from '../../jest/helpers'
|
||||
|
||||
const factory = Factory()
|
||||
let client
|
||||
@ -147,7 +146,7 @@ describe('users', () => {
|
||||
}
|
||||
`
|
||||
beforeEach(async () => {
|
||||
asAuthor = await factory.create('User', {
|
||||
await factory.create('User', {
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
id: 'u343',
|
||||
@ -191,6 +190,7 @@ describe('users', () => {
|
||||
describe('attempting to delete my own account', () => {
|
||||
let expectedResponse
|
||||
beforeEach(async () => {
|
||||
asAuthor = Factory()
|
||||
await asAuthor.authenticateAs({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
enum BadgeStatus {
|
||||
permanent
|
||||
temporary
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
enum BadgeType {
|
||||
role
|
||||
crowdfunding
|
||||
}
|
||||
7
backend/src/schema/types/enum/Emotion.gql
Normal file
7
backend/src/schema/types/enum/Emotion.gql
Normal file
@ -0,0 +1,7 @@
|
||||
enum Emotion {
|
||||
surprised
|
||||
cry
|
||||
happy
|
||||
angry
|
||||
funny
|
||||
}
|
||||
@ -28,8 +28,6 @@ type Mutation {
|
||||
report(id: ID!, description: String): Report
|
||||
disable(id: ID!): ID
|
||||
enable(id: ID!): ID
|
||||
reward(fromBadgeId: ID!, toUserId: ID!): ID
|
||||
unreward(fromBadgeId: ID!, toUserId: ID!): ID
|
||||
# Shout the given Type and ID
|
||||
shout(id: ID!, type: ShoutTypeEnum): Boolean!
|
||||
# Unshout the given Type and ID
|
||||
|
||||
@ -1,324 +0,0 @@
|
||||
scalar Upload
|
||||
|
||||
type Query {
|
||||
isLoggedIn: Boolean!
|
||||
# Get the currently logged in User based on the given JWT Token
|
||||
currentUser: User
|
||||
# Get the latest Network Statistics
|
||||
statistics: Statistics!
|
||||
findPosts(filter: String!, limit: Int = 10): [Post]! @cypher(
|
||||
statement: """
|
||||
CALL db.index.fulltext.queryNodes('full_text_search', $filter)
|
||||
YIELD node as post, score
|
||||
MATCH (post)<-[:WROTE]-(user:User)
|
||||
WHERE score >= 0.2
|
||||
AND NOT user.deleted = true AND NOT user.disabled = true
|
||||
AND NOT post.deleted = true AND NOT post.disabled = true
|
||||
RETURN post
|
||||
LIMIT $limit
|
||||
"""
|
||||
)
|
||||
CommentByPost(postId: ID!): [Comment]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
# Get a JWT Token for the given Email and password
|
||||
login(email: String!, password: String!): String!
|
||||
signup(email: String!, password: String!): Boolean!
|
||||
changePassword(oldPassword:String!, newPassword: String!): String!
|
||||
report(id: ID!, description: String): Report
|
||||
disable(id: ID!): ID
|
||||
enable(id: ID!): ID
|
||||
reward(fromBadgeId: ID!, toUserId: ID!): ID
|
||||
unreward(fromBadgeId: ID!, toUserId: ID!): ID
|
||||
# Shout the given Type and ID
|
||||
shout(id: ID!, type: ShoutTypeEnum): Boolean!
|
||||
# Unshout the given Type and ID
|
||||
unshout(id: ID!, type: ShoutTypeEnum): Boolean!
|
||||
# Follow the given Type and ID
|
||||
follow(id: ID!, type: FollowTypeEnum): Boolean!
|
||||
# Unfollow the given Type and ID
|
||||
unfollow(id: ID!, type: FollowTypeEnum): Boolean!
|
||||
}
|
||||
|
||||
type Statistics {
|
||||
countUsers: Int!
|
||||
countPosts: Int!
|
||||
countComments: Int!
|
||||
countNotifications: Int!
|
||||
countOrganizations: Int!
|
||||
countProjects: Int!
|
||||
countInvites: Int!
|
||||
countFollows: Int!
|
||||
countShouts: Int!
|
||||
}
|
||||
|
||||
type Notification {
|
||||
id: ID!
|
||||
read: Boolean,
|
||||
user: User @relation(name: "NOTIFIED", direction: "OUT")
|
||||
post: Post @relation(name: "NOTIFIED", direction: "IN")
|
||||
createdAt: String
|
||||
}
|
||||
|
||||
scalar Date
|
||||
scalar Time
|
||||
scalar DateTime
|
||||
|
||||
enum VisibilityEnum {
|
||||
public
|
||||
friends
|
||||
private
|
||||
}
|
||||
|
||||
enum UserGroupEnum {
|
||||
admin
|
||||
moderator
|
||||
user
|
||||
}
|
||||
|
||||
type Location {
|
||||
id: ID!
|
||||
name: String!
|
||||
nameEN: String
|
||||
nameDE: String
|
||||
nameFR: String
|
||||
nameNL: String
|
||||
nameIT: String
|
||||
nameES: String
|
||||
namePT: String
|
||||
namePL: String
|
||||
type: String!
|
||||
lat: Float
|
||||
lng: Float
|
||||
parent: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
actorId: String
|
||||
name: String
|
||||
email: String!
|
||||
slug: String
|
||||
password: String!
|
||||
avatar: String
|
||||
avatarUpload: Upload
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
disabledBy: User @relation(name: "DISABLED", direction: "IN")
|
||||
role: UserGroupEnum
|
||||
publicKey: String
|
||||
privateKey: String
|
||||
|
||||
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
|
||||
locationName: String
|
||||
about: String
|
||||
socialMedia: [SocialMedia]! @relation(name: "OWNED", direction: "OUT")
|
||||
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
|
||||
notifications(read: Boolean): [Notification]! @relation(name: "NOTIFIED", direction: "IN")
|
||||
|
||||
friends: [User]! @relation(name: "FRIENDS", direction: "BOTH")
|
||||
friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)")
|
||||
|
||||
following: [User]! @relation(name: "FOLLOWS", direction: "OUT")
|
||||
followingCount: Int! @cypher(statement: "MATCH (this)-[:FOLLOWS]->(r:User) RETURN COUNT(DISTINCT r)")
|
||||
|
||||
followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN")
|
||||
followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)")
|
||||
|
||||
# Is the currently logged in user following that user?
|
||||
followedByCurrentUser: Boolean! @cypher(
|
||||
statement: """
|
||||
MATCH (this)<-[:FOLLOWS]-(u:User {id: $cypherParams.currentUserId})
|
||||
RETURN COUNT(u) >= 1
|
||||
"""
|
||||
)
|
||||
|
||||
#contributions: [WrittenPost]!
|
||||
#contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]!
|
||||
# @cypher(
|
||||
# statement: "MATCH (this)-[w:WROTE]->(p:Post) RETURN p as Post, w.timestamp as timestamp"
|
||||
# )
|
||||
contributions: [Post]! @relation(name: "WROTE", direction: "OUT")
|
||||
contributionsCount: Int! @cypher(
|
||||
statement: """
|
||||
MATCH (this)-[:WROTE]->(r:Post)
|
||||
WHERE (NOT exists(r.deleted) OR r.deleted = false)
|
||||
AND (NOT exists(r.disabled) OR r.disabled = false)
|
||||
RETURN COUNT(r)
|
||||
"""
|
||||
)
|
||||
|
||||
comments: [Comment]! @relation(name: "WROTE", direction: "OUT")
|
||||
commentsCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)")
|
||||
|
||||
shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT")
|
||||
shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")
|
||||
|
||||
organizationsCreated: [Organization] @relation(name: "CREATED_ORGA", direction: "OUT")
|
||||
organizationsOwned: [Organization] @relation(name: "OWNING_ORGA", direction: "OUT")
|
||||
|
||||
blacklisted: [User]! @relation(name: "BLACKLISTED", direction: "OUT")
|
||||
|
||||
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
|
||||
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
|
||||
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
|
||||
}
|
||||
|
||||
type Post {
|
||||
id: ID!
|
||||
activityId: String
|
||||
objectId: String
|
||||
author: User @relation(name: "WROTE", direction: "IN")
|
||||
title: String!
|
||||
slug: String
|
||||
content: String!
|
||||
contentExcerpt: String
|
||||
image: String
|
||||
imageUpload: Upload
|
||||
visibility: VisibilityEnum
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
disabledBy: User @relation(name: "DISABLED", direction: "IN")
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
|
||||
relatedContributions: [Post]! @cypher(
|
||||
statement: """
|
||||
MATCH (this)-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
|
||||
RETURN DISTINCT post
|
||||
LIMIT 10
|
||||
"""
|
||||
)
|
||||
|
||||
tags: [Tag]! @relation(name: "TAGGED", direction: "OUT")
|
||||
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
|
||||
comments: [Comment]! @relation(name: "COMMENTS", direction: "IN")
|
||||
commentsCount: Int! @cypher(statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)")
|
||||
|
||||
shoutedBy: [User]! @relation(name: "SHOUTED", direction: "IN")
|
||||
shoutedCount: Int! @cypher(statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")
|
||||
|
||||
# Has the currently logged in user shouted that post?
|
||||
shoutedByCurrentUser: Boolean! @cypher(
|
||||
statement: """
|
||||
MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId})
|
||||
RETURN COUNT(u) >= 1
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
type Comment {
|
||||
id: ID!
|
||||
activityId: String
|
||||
postId: ID
|
||||
author: User @relation(name: "WROTE", direction: "IN")
|
||||
content: String!
|
||||
contentExcerpt: String
|
||||
post: Post @relation(name: "COMMENTS", direction: "OUT")
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
disabledBy: User @relation(name: "DISABLED", direction: "IN")
|
||||
}
|
||||
|
||||
type Report {
|
||||
id: ID!
|
||||
submitter: User @relation(name: "REPORTED", direction: "IN")
|
||||
description: String
|
||||
type: String! @cypher(statement: "MATCH (resource)<-[:REPORTED]-(this) RETURN labels(resource)[0]")
|
||||
createdAt: String
|
||||
comment: Comment @relation(name: "REPORTED", direction: "OUT")
|
||||
post: Post @relation(name: "REPORTED", direction: "OUT")
|
||||
user: User @relation(name: "REPORTED", direction: "OUT")
|
||||
}
|
||||
|
||||
type Category {
|
||||
id: ID!
|
||||
name: String!
|
||||
slug: String
|
||||
icon: String!
|
||||
posts: [Post]! @relation(name: "CATEGORIZED", direction: "IN")
|
||||
postCount: Int! @cypher(statement: "MATCH (this)<-[:CATEGORIZED]-(r:Post) RETURN COUNT(r)")
|
||||
}
|
||||
|
||||
type Badge {
|
||||
id: ID!
|
||||
key: String!
|
||||
type: BadgeTypeEnum!
|
||||
status: BadgeStatusEnum!
|
||||
icon: String!
|
||||
|
||||
rewarded: [User]! @relation(name: "REWARDED", direction: "OUT")
|
||||
}
|
||||
|
||||
enum BadgeTypeEnum {
|
||||
role
|
||||
crowdfunding
|
||||
}
|
||||
enum BadgeStatusEnum {
|
||||
permanent
|
||||
temporary
|
||||
}
|
||||
enum ShoutTypeEnum {
|
||||
Post
|
||||
Organization
|
||||
Project
|
||||
}
|
||||
enum FollowTypeEnum {
|
||||
User
|
||||
Organization
|
||||
Project
|
||||
}
|
||||
|
||||
type Reward {
|
||||
id: ID!
|
||||
user: User @relation(name: "REWARDED", direction: "IN")
|
||||
rewarderId: ID
|
||||
createdAt: String
|
||||
badge: Badge @relation(name: "REWARDED", direction: "OUT")
|
||||
}
|
||||
|
||||
type Organization {
|
||||
id: ID!
|
||||
createdBy: User @relation(name: "CREATED_ORGA", direction: "IN")
|
||||
ownedBy: [User] @relation(name: "OWNING_ORGA", direction: "IN")
|
||||
name: String!
|
||||
slug: String
|
||||
description: String!
|
||||
descriptionExcerpt: String
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
|
||||
tags: [Tag]! @relation(name: "TAGGED", direction: "OUT")
|
||||
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
}
|
||||
|
||||
type Tag {
|
||||
id: ID!
|
||||
name: String!
|
||||
taggedPosts: [Post]! @relation(name: "TAGGED", direction: "IN")
|
||||
taggedOrganizations: [Organization]! @relation(name: "TAGGED", direction: "IN")
|
||||
taggedCount: Int! @cypher(statement: "MATCH (this)<-[:TAGGED]-(p) RETURN COUNT(DISTINCT p)")
|
||||
taggedCountUnique: Int! @cypher(statement: "MATCH (this)<-[:TAGGED]-(p)<-[:WROTE]-(u:User) RETURN COUNT(DISTINCT u)")
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
}
|
||||
|
||||
type SharedInboxEndpoint {
|
||||
id: ID!
|
||||
uri: String
|
||||
}
|
||||
|
||||
type SocialMedia {
|
||||
id: ID!
|
||||
url: String
|
||||
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
type Badge {
|
||||
id: ID!
|
||||
key: String!
|
||||
type: BadgeType!
|
||||
status: BadgeStatus!
|
||||
icon: String!
|
||||
@ -10,4 +9,23 @@ type Badge {
|
||||
updatedAt: String
|
||||
|
||||
rewarded: [User]! @relation(name: "REWARDED", direction: "OUT")
|
||||
}
|
||||
}
|
||||
|
||||
enum BadgeStatus {
|
||||
permanent
|
||||
temporary
|
||||
}
|
||||
|
||||
enum BadgeType {
|
||||
role
|
||||
crowdfunding
|
||||
}
|
||||
|
||||
type Query {
|
||||
Badge: [Badge]
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
reward(badgeKey: ID!, userId: ID!): User
|
||||
unreward(badgeKey: ID!, userId: ID!): User
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ type Mutation {
|
||||
): Comment
|
||||
UpdateComment(
|
||||
id: ID!
|
||||
content: String
|
||||
content: String!
|
||||
contentExcerpt: String
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
|
||||
10
backend/src/schema/types/type/EMOTED.gql
Normal file
10
backend/src/schema/types/type/EMOTED.gql
Normal file
@ -0,0 +1,10 @@
|
||||
type EMOTED @relation(name: "EMOTED") {
|
||||
from: User
|
||||
to: Post
|
||||
|
||||
emotion: Emotion
|
||||
#createdAt: DateTime
|
||||
#updatedAt: DateTime
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
}
|
||||
@ -48,6 +48,8 @@ type Post {
|
||||
RETURN COUNT(u) >= 1
|
||||
"""
|
||||
)
|
||||
|
||||
emotions: [EMOTED]
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
|
||||
@ -2,14 +2,14 @@ type User {
|
||||
id: ID!
|
||||
actorId: String
|
||||
name: String
|
||||
email: String!
|
||||
email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
|
||||
slug: String!
|
||||
avatar: String
|
||||
coverImg: String
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
disabledBy: User @relation(name: "DISABLED", direction: "IN")
|
||||
role: UserGroup
|
||||
role: UserGroup!
|
||||
publicKey: String
|
||||
invitedBy: User @relation(name: "INVITED", direction: "IN")
|
||||
invited: [User] @relation(name: "INVITED", direction: "OUT")
|
||||
@ -73,12 +73,17 @@ type User {
|
||||
|
||||
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
|
||||
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
|
||||
|
||||
emotions: [EMOTED]
|
||||
}
|
||||
|
||||
|
||||
input _UserFilter {
|
||||
AND: [_UserFilter!]
|
||||
OR: [_UserFilter!]
|
||||
name_contains: String
|
||||
about_contains: String
|
||||
slug_contains: String
|
||||
id: ID
|
||||
id_not: ID
|
||||
id_in: [ID!]
|
||||
|
||||
@ -1,28 +1,15 @@
|
||||
import uuid from 'uuid/v4'
|
||||
|
||||
export default function(params) {
|
||||
const {
|
||||
id = uuid(),
|
||||
key = '',
|
||||
type = 'crowdfunding',
|
||||
status = 'permanent',
|
||||
icon = '/img/badges/indiegogo_en_panda.svg',
|
||||
} = params
|
||||
|
||||
export default function create() {
|
||||
return {
|
||||
mutation: `
|
||||
mutation(
|
||||
$id: ID
|
||||
$key: String!
|
||||
$type: BadgeType!
|
||||
$status: BadgeStatus!
|
||||
$icon: String!
|
||||
) {
|
||||
CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) {
|
||||
id
|
||||
}
|
||||
factory: async ({ args, neodeInstance }) => {
|
||||
const defaults = {
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
}
|
||||
`,
|
||||
variables: { id, key, type, status, icon },
|
||||
args = {
|
||||
...defaults,
|
||||
...args,
|
||||
}
|
||||
return neodeInstance.create('Badge', args)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,7 @@ export default function Factory(options = {}) {
|
||||
const { factory, mutation, variables } = this.factories[node](args)
|
||||
if (factory) {
|
||||
this.lastResponse = await factory({ args, neodeInstance })
|
||||
return this.lastResponse
|
||||
} else {
|
||||
this.lastResponse = await this.graphQLClient.request(mutation, variables)
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import uuid from 'uuid/v4'
|
||||
import encryptPassword from '../../helpers/encryptPassword'
|
||||
import slugify from 'slug'
|
||||
|
||||
export default function create(params) {
|
||||
export default function create() {
|
||||
return {
|
||||
factory: async ({ args, neodeInstance }) => {
|
||||
const defaults = {
|
||||
@ -22,7 +22,10 @@ export default function create(params) {
|
||||
}
|
||||
args = await encryptPassword(args)
|
||||
const user = await neodeInstance.create('User', args)
|
||||
return user.toJson()
|
||||
const email = await neodeInstance.create('EmailAddress', { email: args.email })
|
||||
await user.relateTo(email, 'primaryEmail')
|
||||
await email.relateTo(user, 'belongsTo')
|
||||
return user
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,52 +5,42 @@ import Factory from './factories'
|
||||
;(async function() {
|
||||
try {
|
||||
const f = Factory()
|
||||
await Promise.all([
|
||||
const [racoon, rabbit, wolf, bear, turtle, rhino] = await Promise.all([
|
||||
f.create('Badge', {
|
||||
id: 'b1',
|
||||
key: 'indiegogo_en_racoon',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
id: 'indiegogo_en_racoon',
|
||||
icon: '/img/badges/indiegogo_en_racoon.svg',
|
||||
}),
|
||||
f.create('Badge', {
|
||||
id: 'b2',
|
||||
key: 'indiegogo_en_rabbit',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
id: 'indiegogo_en_rabbit',
|
||||
icon: '/img/badges/indiegogo_en_rabbit.svg',
|
||||
}),
|
||||
f.create('Badge', {
|
||||
id: 'b3',
|
||||
key: 'indiegogo_en_wolf',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
id: 'indiegogo_en_wolf',
|
||||
icon: '/img/badges/indiegogo_en_wolf.svg',
|
||||
}),
|
||||
f.create('Badge', {
|
||||
id: 'b4',
|
||||
key: 'indiegogo_en_bear',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
id: 'indiegogo_en_bear',
|
||||
icon: '/img/badges/indiegogo_en_bear.svg',
|
||||
}),
|
||||
f.create('Badge', {
|
||||
id: 'b5',
|
||||
key: 'indiegogo_en_turtle',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
id: 'indiegogo_en_turtle',
|
||||
icon: '/img/badges/indiegogo_en_turtle.svg',
|
||||
}),
|
||||
f.create('Badge', {
|
||||
id: 'b6',
|
||||
key: 'indiegogo_en_rhino',
|
||||
type: 'crowdfunding',
|
||||
status: 'permanent',
|
||||
id: 'indiegogo_en_rhino',
|
||||
icon: '/img/badges/indiegogo_en_rhino.svg',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
const [
|
||||
peterLustig,
|
||||
bobDerBaumeister,
|
||||
jennyRostock,
|
||||
tick, // eslint-disable-line no-unused-vars
|
||||
trick, // eslint-disable-line no-unused-vars
|
||||
track, // eslint-disable-line no-unused-vars
|
||||
dagobert,
|
||||
] = await Promise.all([
|
||||
f.create('User', {
|
||||
id: 'u1',
|
||||
name: 'Peter Lustig',
|
||||
@ -69,47 +59,130 @@ 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' }),
|
||||
peterLustig.relateTo(racoon, 'rewarded'),
|
||||
peterLustig.relateTo(rhino, 'rewarded'),
|
||||
peterLustig.relateTo(wolf, 'rewarded'),
|
||||
bobDerBaumeister.relateTo(racoon, 'rewarded'),
|
||||
bobDerBaumeister.relateTo(turtle, 'rewarded'),
|
||||
jennyRostock.relateTo(bear, 'rewarded'),
|
||||
dagobert.relateTo(rabbit, 'rewarded'),
|
||||
])
|
||||
|
||||
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' }),
|
||||
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([
|
||||
f.create('Category', { id: 'cat1', name: 'Just For Fun', slug: 'justforfun', icon: 'smile' }),
|
||||
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: 'cat2',
|
||||
name: 'Happyness & Values',
|
||||
@ -203,10 +276,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 +299,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,11 +666,30 @@ 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',
|
||||
}),
|
||||
])
|
||||
|
||||
await Promise.all(
|
||||
[...Array(30).keys()].map(i => {
|
||||
return f.create('User')
|
||||
}),
|
||||
)
|
||||
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log('Seeded Data...')
|
||||
process.exit(0)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import express from 'express'
|
||||
import helmet from 'helmet'
|
||||
import { GraphQLServer } from 'graphql-yoga'
|
||||
import { ApolloServer } from 'apollo-server-express'
|
||||
import CONFIG, { requiredConfigs } from './config'
|
||||
import mocks from './mocks'
|
||||
import middleware from './middleware'
|
||||
@ -20,28 +20,30 @@ const driver = getDriver()
|
||||
|
||||
const createServer = options => {
|
||||
const defaults = {
|
||||
context: async ({ request }) => {
|
||||
const user = await decode(driver, request.headers.authorization)
|
||||
context: async ({ req }) => {
|
||||
const user = await decode(driver, req.headers.authorization)
|
||||
return {
|
||||
driver,
|
||||
user,
|
||||
req: request,
|
||||
req,
|
||||
cypherParams: {
|
||||
currentUserId: user ? user.id : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
schema,
|
||||
schema: middleware(schema),
|
||||
debug: CONFIG.DEBUG,
|
||||
tracing: CONFIG.DEBUG,
|
||||
middlewares: middleware(schema),
|
||||
mocks: CONFIG.MOCKS ? mocks : false,
|
||||
}
|
||||
const server = new GraphQLServer(Object.assign({}, defaults, options))
|
||||
const server = new ApolloServer(Object.assign({}, defaults, options))
|
||||
|
||||
server.express.use(helmet())
|
||||
server.express.use(express.static('public'))
|
||||
return server
|
||||
const app = express()
|
||||
app.use(helmet())
|
||||
app.use(express.static('public'))
|
||||
server.applyMiddleware({ app, path: '/' })
|
||||
|
||||
return { server, app }
|
||||
}
|
||||
|
||||
export default createServer
|
||||
|
||||
43
backend/src/server.spec.js
Normal file
43
backend/src/server.spec.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import createServer from './server'
|
||||
|
||||
/**
|
||||
* This file is for demonstration purposes. It does not really test the
|
||||
* `isLoggedIn` query but demonstrates how we can use `apollo-server-testing`.
|
||||
* All we need to do is to get an instance of `ApolloServer` and maybe we want
|
||||
* stub out `context` as shown below.
|
||||
*
|
||||
*/
|
||||
|
||||
let user
|
||||
let action
|
||||
describe('isLoggedIn', () => {
|
||||
beforeEach(() => {
|
||||
action = async () => {
|
||||
const { server } = createServer({
|
||||
context: () => {
|
||||
return {
|
||||
user,
|
||||
}
|
||||
},
|
||||
})
|
||||
const { query } = createTestClient(server)
|
||||
|
||||
const isLoggedIn = `{ isLoggedIn }`
|
||||
return query({ query: isLoggedIn })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns false', async () => {
|
||||
const expected = expect.objectContaining({ data: { isLoggedIn: false } })
|
||||
await expect(action()).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
describe('when authenticated', () => {
|
||||
it('returns true', async () => {
|
||||
user = { id: '123' }
|
||||
const expected = expect.objectContaining({ data: { isLoggedIn: true } })
|
||||
await expect(action()).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -22,16 +22,16 @@ Feature: Tags and Categories
|
||||
When I navigate to the administration dashboard
|
||||
And I click on the menu item "Categories"
|
||||
Then I can see the following table:
|
||||
| | Name | Posts |
|
||||
| | Just For Fun | 2 |
|
||||
| | Happyness & Values | 1 |
|
||||
| | Health & Wellbeing | 0 |
|
||||
| | Name | Posts |
|
||||
| | Just For Fun | 2 |
|
||||
| | Happyness & Values | 1 |
|
||||
| | Health & Wellbeing | 0 |
|
||||
|
||||
Scenario: See an overview of tags
|
||||
When I navigate to the administration dashboard
|
||||
And I click on the menu item "Tags"
|
||||
Then I can see the following table:
|
||||
| | Name | Users | Posts |
|
||||
| 1 | Democracy | 3 | 4 |
|
||||
| 2 | Nature | 2 | 3 |
|
||||
| 3 | Ecology | 1 | 1 |
|
||||
| | Name | Users | Posts |
|
||||
| 1 | Democracy | 3 | 4 |
|
||||
| 2 | Nature | 2 | 3 |
|
||||
| 3 | Ecology | 1 | 1 |
|
||||
|
||||
@ -1,36 +1,36 @@
|
||||
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
|
||||
import { When, Then } from "cypress-cucumber-preprocessor/steps";
|
||||
|
||||
/* global cy */
|
||||
|
||||
When('I visit my profile page', () => {
|
||||
cy.openPage('profile/peter-pan')
|
||||
})
|
||||
When("I visit my profile page", () => {
|
||||
cy.openPage("profile/peter-pan");
|
||||
});
|
||||
|
||||
Then('I should be able to change my profile picture', () => {
|
||||
const avatarUpload = 'onourjourney.png'
|
||||
Then("I should be able to change my profile picture", () => {
|
||||
const avatarUpload = "onourjourney.png";
|
||||
|
||||
cy.fixture(avatarUpload, 'base64').then(fileContent => {
|
||||
cy.get('#customdropzone').upload(
|
||||
{ fileContent, fileName: avatarUpload, mimeType: 'image/png' },
|
||||
{ subjectType: 'drag-n-drop' }
|
||||
)
|
||||
})
|
||||
cy.get('.profile-avatar img')
|
||||
.should('have.attr', 'src')
|
||||
.and('contains', 'onourjourney')
|
||||
cy.contains('.iziToast-message', 'Upload successful').should(
|
||||
'have.length',
|
||||
cy.fixture(avatarUpload, "base64").then(fileContent => {
|
||||
cy.get("#customdropzone").upload(
|
||||
{ fileContent, fileName: avatarUpload, mimeType: "image/png" },
|
||||
{ subjectType: "drag-n-drop", force: true }
|
||||
);
|
||||
});
|
||||
cy.get(".profile-avatar img")
|
||||
.should("have.attr", "src")
|
||||
.and("contains", "onourjourney");
|
||||
cy.contains(".iziToast-message", "Upload successful").should(
|
||||
"have.length",
|
||||
1
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
When("I visit another user's profile page", () => {
|
||||
cy.openPage('profile/peter-pan')
|
||||
})
|
||||
cy.openPage("profile/peter-pan");
|
||||
});
|
||||
|
||||
Then('I cannot upload a picture', () => {
|
||||
cy.get('.ds-card-content')
|
||||
Then("I cannot upload a picture", () => {
|
||||
cy.get(".ds-card-content")
|
||||
.children()
|
||||
.should('not.have.id', 'customdropzone')
|
||||
.should('have.class', 'ds-avatar')
|
||||
})
|
||||
.should("not.have.id", "customdropzone")
|
||||
.should("have.class", "ds-avatar");
|
||||
});
|
||||
|
||||
@ -276,9 +276,9 @@ When("I fill the password form with:", table => {
|
||||
table = table.rowsHash();
|
||||
cy.get("input[id=oldPassword]")
|
||||
.type(table["Your old password"])
|
||||
.get("input[id=newPassword]")
|
||||
.get("input[id=password]")
|
||||
.type(table["Your new passsword"])
|
||||
.get("input[id=confirmPassword]")
|
||||
.get("input[id=passwordConfirmation]")
|
||||
.type(table["Confirm new password"]);
|
||||
});
|
||||
|
||||
|
||||
@ -23,24 +23,27 @@ Cypress.Commands.add('factory', () => {
|
||||
Cypress.Commands.add(
|
||||
'create',
|
||||
{ prevSubject: true },
|
||||
(factory, node, properties) => {
|
||||
return factory.create(node, properties)
|
||||
async (factory, node, properties) => {
|
||||
await factory.create(node, properties)
|
||||
return factory
|
||||
}
|
||||
)
|
||||
|
||||
Cypress.Commands.add(
|
||||
'relate',
|
||||
{ prevSubject: true },
|
||||
(factory, node, relationship, properties) => {
|
||||
return factory.relate(node, relationship, properties)
|
||||
async (factory, node, relationship, properties) => {
|
||||
await factory.relate(node, relationship, properties)
|
||||
return factory
|
||||
}
|
||||
)
|
||||
|
||||
Cypress.Commands.add(
|
||||
'mutate',
|
||||
{ prevSubject: true },
|
||||
(factory, mutation, variables) => {
|
||||
return factory.mutate(mutation, variables)
|
||||
async (factory, mutation, variables) => {
|
||||
await factory.mutate(mutation, variables)
|
||||
return factory
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -4,14 +4,10 @@
|
||||
data:
|
||||
SMTP_HOST: "mailserver.human-connection"
|
||||
SMTP_PORT: "25"
|
||||
SMTP_USERNAME: ""
|
||||
SMTP_PASSWORD: ""
|
||||
GRAPHQL_PORT: "4000"
|
||||
GRAPHQL_URI: "http://nitro-backend.human-connection:4000"
|
||||
MOCKS: "false"
|
||||
NEO4J_URI: "bolt://nitro-neo4j.human-connection:7687"
|
||||
NEO4J_USERNAME: "neo4j"
|
||||
NEO4J_PASSWORD: "neo4j"
|
||||
NEO4J_AUTH: "none"
|
||||
CLIENT_URI: "https://nitro-staging.human-connection.org"
|
||||
metadata:
|
||||
|
||||
@ -5,11 +5,10 @@ data:
|
||||
MONGODB_PASSWORD: "TU9OR09EQl9QQVNTV09SRA=="
|
||||
PRIVATE_KEY_PASSPHRASE: "YTdkc2Y3OHNhZGc4N2FkODdzZmFnc2FkZzc4"
|
||||
MAPBOX_TOKEN: "cGsuZXlKMUlqb2lhSFZ0WVc0dFkyOXVibVZqZEdsdmJpSXNJbUVpT2lKamFqbDBjbkJ1Ykdvd2VUVmxNM1Z3WjJsek5UTnVkM1p0SW4wLktaOEtLOWw3MG9talhiRWtrYkhHc1EK"
|
||||
SMTP_HOST:
|
||||
SMTP_PORT: 587
|
||||
SMTP_USERNAME:
|
||||
SMTP_PASSWORD:
|
||||
SMTP_IGNORE_TLS:
|
||||
NEO4J_USERNAME:
|
||||
NEO4J_PASSWORD:
|
||||
metadata:
|
||||
name: human-connection
|
||||
namespace: human-connection
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
[?] type: String, // in nitro this is a defined enum - seems good for now
|
||||
[X] required: true
|
||||
},
|
||||
[X] key: {
|
||||
[X] id: {
|
||||
[X] type: String,
|
||||
[X] required: true
|
||||
},
|
||||
@ -43,7 +43,7 @@
|
||||
CALL apoc.load.json("file:${IMPORT_CHUNK_PATH_CQL_FILE}") YIELD value as badge
|
||||
MERGE(b:Badge {id: badge._id["$oid"]})
|
||||
ON CREATE SET
|
||||
b.key = badge.key,
|
||||
b.id = badge.key,
|
||||
b.type = badge.type,
|
||||
b.icon = replace(badge.image.path, 'https://api-alpha.human-connection.org', ''),
|
||||
b.status = badge.status,
|
||||
|
||||
@ -0,0 +1 @@
|
||||
MATCH (u:User)-[e:EMOTED]->(c:Post) DETACH DELETE e;
|
||||
@ -5,31 +5,54 @@
|
||||
// [-] Omitted in Nitro
|
||||
// [?] Unclear / has work to be done for Nitro
|
||||
{
|
||||
[ ] userId: {
|
||||
[ ] type: String,
|
||||
[ ] required: true,
|
||||
[X] userId: {
|
||||
[X] type: String,
|
||||
[X] required: true,
|
||||
[-] index: true
|
||||
},
|
||||
[ ] contributionId: {
|
||||
[ ] type: String,
|
||||
[ ] required: true,
|
||||
[X] contributionId: {
|
||||
[X] type: String,
|
||||
[X] required: true,
|
||||
[-] index: true
|
||||
},
|
||||
[ ] rated: {
|
||||
[ ] type: String,
|
||||
[?] rated: {
|
||||
[X] type: String,
|
||||
[ ] required: true,
|
||||
[ ] enum: ['funny', 'happy', 'surprised', 'cry', 'angry']
|
||||
[?] enum: ['funny', 'happy', 'surprised', 'cry', 'angry']
|
||||
},
|
||||
[ ] createdAt: {
|
||||
[ ] type: Date,
|
||||
[ ] default: Date.now
|
||||
[X] createdAt: {
|
||||
[X] type: Date,
|
||||
[X] default: Date.now
|
||||
},
|
||||
[ ] updatedAt: {
|
||||
[ ] type: Date,
|
||||
[ ] default: Date.now
|
||||
[X] updatedAt: {
|
||||
[X] type: Date,
|
||||
[X] default: Date.now
|
||||
},
|
||||
[ ] wasSeeded: { type: Boolean }
|
||||
[-] wasSeeded: { type: Boolean }
|
||||
}
|
||||
*/
|
||||
|
||||
CALL apoc.load.json("file:${IMPORT_CHUNK_PATH_CQL_FILE}") YIELD value as emotion;
|
||||
CALL apoc.load.json("file:${IMPORT_CHUNK_PATH_CQL_FILE}") YIELD value as emotion
|
||||
MATCH (u:User {id: emotion.userId}),
|
||||
(c:Post {id: emotion.contributionId})
|
||||
MERGE (u)-[e:EMOTED {
|
||||
id: emotion._id["$oid"],
|
||||
emotion: emotion.rated,
|
||||
createdAt: datetime(emotion.createdAt.`$date`),
|
||||
updatedAt: datetime(emotion.updatedAt.`$date`)
|
||||
}]->(c)
|
||||
RETURN e;
|
||||
/*
|
||||
// Queries
|
||||
// user sets an emotion emotion:
|
||||
// MERGE (u)-[e:EMOTED {id: ..., emotion: "funny", createdAt: ..., updatedAt: ...}]->(c)
|
||||
// user removes emotion
|
||||
// MATCH (u)-[e:EMOTED]->(c) DELETE e
|
||||
// contribution distributions over every `emotion` property value for one post
|
||||
// MATCH (u:User)-[e:EMOTED]->(c:Post {id: "5a70bbc8508f5b000b443b1a"}) RETURN e.emotion,COUNT(e.emotion)
|
||||
// contribution distributions over every `emotion` property value for one user (advanced - "whats the primary emotion used by the user?")
|
||||
// MATCH (u:User{id:"5a663b1ac64291000bf302a1"})-[e:EMOTED]->(c:Post) RETURN e.emotion,COUNT(e.emotion)
|
||||
// contribution distributions over every `emotion` property value for all posts created by one user (advanced - "how do others react to my contributions?")
|
||||
// MATCH (u:User)-[e:EMOTED]->(c:Post)<-[w:WROTE]-(a:User{id:"5a663b1ac64291000bf302a1"}) RETURN e.emotion,COUNT(e.emotion)
|
||||
// if we can filter the above an a variable timescale that would be great (should be possible on createdAt and updatedAt fields)
|
||||
*/
|
||||
@ -1 +1 @@
|
||||
// this is just a relation between users(?) - no need to delete
|
||||
MATCH (u1:User)-[f:FOLLOWS]->(u2:User) DETACH DELETE f;
|
||||
@ -60,8 +60,8 @@ delete_collection "contributions" "contributions_post"
|
||||
delete_collection "contributions" "contributions_cando"
|
||||
delete_collection "shouts" "shouts"
|
||||
delete_collection "comments" "comments"
|
||||
delete_collection "emotions" "emotions"
|
||||
|
||||
#delete_collection "emotions"
|
||||
#delete_collection "invites"
|
||||
#delete_collection "notifications"
|
||||
#delete_collection "organizations"
|
||||
@ -82,12 +82,12 @@ import_collection "users" "users/users.cql"
|
||||
import_collection "follows_users" "follows/follows.cql"
|
||||
#import_collection "follows_organizations" "follows/follows.cql"
|
||||
import_collection "contributions_post" "contributions/contributions.cql"
|
||||
import_collection "contributions_cando" "contributions/contributions.cql"
|
||||
#import_collection "contributions_cando" "contributions/contributions.cql"
|
||||
#import_collection "contributions_DELETED" "contributions/contributions.cql"
|
||||
import_collection "shouts" "shouts/shouts.cql"
|
||||
import_collection "comments" "comments/comments.cql"
|
||||
import_collection "emotions" "emotions/emotions.cql"
|
||||
|
||||
# import_collection "emotions"
|
||||
# import_collection "invites"
|
||||
# import_collection "notifications"
|
||||
# import_collection "organizations"
|
||||
|
||||
@ -101,7 +101,7 @@ ON CREATE SET
|
||||
u.name = user.name,
|
||||
u.slug = user.slug,
|
||||
u.email = user.email,
|
||||
u.password = user.password,
|
||||
u.encryptedPassword = user.password,
|
||||
u.avatar = replace(user.avatar, 'https://api-alpha.human-connection.org', ''),
|
||||
u.coverImg = replace(user.coverImg, 'https://api-alpha.human-connection.org', ''),
|
||||
u.wasInvited = user.wasInvited,
|
||||
@ -111,6 +111,13 @@ u.createdAt = user.createdAt.`$date`,
|
||||
u.updatedAt = user.updatedAt.`$date`,
|
||||
u.deleted = user.deletedAt IS NOT NULL,
|
||||
u.disabled = false
|
||||
MERGE (e:EmailAddress {
|
||||
email: user.email,
|
||||
createdAt: toString(datetime()),
|
||||
verifiedAt: toString(datetime())
|
||||
})
|
||||
MERGE (e)-[:BELONGS_TO]->(u)
|
||||
MERGE (u)<-[:PRIMARY_EMAIL]-(e)
|
||||
WITH u, user, user.badgeIds AS badgeIds
|
||||
UNWIND badgeIds AS badgeId
|
||||
MATCH (b:Badge {id: badgeId})
|
||||
|
||||
@ -26,9 +26,4 @@ services:
|
||||
ports:
|
||||
- 4001:4001
|
||||
- 4123:4123
|
||||
neo4j:
|
||||
environment:
|
||||
- NEO4J_AUTH=none
|
||||
ports:
|
||||
- 7687:7687
|
||||
- 7474:7474
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ services:
|
||||
networks:
|
||||
- hc-network
|
||||
environment:
|
||||
- NUXT_BUILD=.nuxt-dist
|
||||
- HOST=0.0.0.0
|
||||
- GRAPHQL_URI=http://backend:4000
|
||||
- MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.bZ8KK9l70omjXbEkkbHGsQ"
|
||||
|
||||
@ -34,6 +34,8 @@ CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE;
|
||||
CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE;
|
||||
CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE;
|
||||
CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE;
|
||||
|
||||
CREATE CONSTRAINT ON (e:EmailAddress) ASSERT e.email IS UNIQUE;
|
||||
' | cypher-shell
|
||||
|
||||
echo '
|
||||
|
||||
@ -22,9 +22,9 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"codecov": "^3.5.0",
|
||||
"cross-env": "^5.2.0",
|
||||
"cypress": "^3.3.2",
|
||||
"cypress": "^3.4.0",
|
||||
"cypress-cucumber-preprocessor": "^1.12.0",
|
||||
"cypress-file-upload": "^3.2.0",
|
||||
"cypress-file-upload": "^3.3.2",
|
||||
"cypress-plugin-retries": "^1.2.2",
|
||||
"dotenv": "^8.0.0",
|
||||
"faker": "Marak/faker.js#master",
|
||||
|
||||
2
webapp/.gitignore
vendored
2
webapp/.gitignore
vendored
@ -61,6 +61,8 @@ typings/
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
# also the build output in docker container
|
||||
.nuxt-dist
|
||||
|
||||
# Nuxt generate
|
||||
dist
|
||||
|
||||
@ -66,7 +66,8 @@ blockquote {
|
||||
border-left: 3px dotted $color-neutral-70;
|
||||
|
||||
&::before {
|
||||
content: '\201C'; /*Unicode for Left Double Quote*/
|
||||
content: '\201C';
|
||||
/*Unicode for Left Double Quote*/
|
||||
|
||||
/*Font*/
|
||||
font-size: $font-size-xxxx-large;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div :class="[badges.length === 2 && 'hc-badges-dual']" class="hc-badges">
|
||||
<div v-for="badge in badges" :key="badge.key" class="hc-badge-container">
|
||||
<div v-for="badge in badges" :key="badge.id" class="hc-badge-container">
|
||||
<img :title="badge.key" :src="badge.icon | proxyApiUrl" class="hc-badge" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import CategoryQuery from '~/graphql/CategoryQuery.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@ -85,13 +85,7 @@ export default {
|
||||
apollo: {
|
||||
Category: {
|
||||
query() {
|
||||
return gql(`{
|
||||
Category {
|
||||
id
|
||||
name
|
||||
icon
|
||||
}
|
||||
}`)
|
||||
return CategoryQuery()
|
||||
},
|
||||
result(result) {
|
||||
this.categories = result.data.Category
|
||||
|
||||
@ -23,49 +23,61 @@
|
||||
:modalsData="menuModalsData"
|
||||
style="float-right"
|
||||
:is-owner="isAuthor(author.id)"
|
||||
@showEditCommentMenu="editCommentMenu"
|
||||
/>
|
||||
</no-ssr>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<!-- TODO: replace editor content with tiptap render view -->
|
||||
|
||||
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
|
||||
<div
|
||||
v-show="comment.content !== comment.contentExcerpt"
|
||||
style="text-align: right; margin-right: 20px; margin-top: -12px;"
|
||||
>
|
||||
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
|
||||
{{ $t('comment.show.more') }}
|
||||
</a>
|
||||
<ds-space margin-bottom="small" />
|
||||
<div v-if="openEditCommentMenu">
|
||||
<hc-edit-comment-form
|
||||
:comment="comment"
|
||||
:post="post"
|
||||
@showEditCommentMenu="editCommentMenu"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
|
||||
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
|
||||
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
|
||||
{{ $t('comment.show.less') }}
|
||||
</a>
|
||||
<div v-show="!openEditCommentMenu">
|
||||
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
|
||||
<div
|
||||
v-show="comment.content !== comment.contentExcerpt"
|
||||
style="text-align: right; margin-right: 20px; margin-top: -12px;"
|
||||
>
|
||||
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
|
||||
{{ $t('comment.show.more') }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
|
||||
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
|
||||
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
|
||||
{{ $t('comment.show.less') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ds-space margin-bottom="small" />
|
||||
</ds-card>
|
||||
</div>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import HcUser from '~/components/User'
|
||||
import ContentMenu from '~/components/ContentMenu'
|
||||
import HcEditCommentForm from '~/components/comments/EditCommentForm/EditCommentForm'
|
||||
|
||||
export default {
|
||||
data: function() {
|
||||
return {
|
||||
isCollapsed: true,
|
||||
openEditCommentMenu: false,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
HcUser,
|
||||
ContentMenu,
|
||||
HcEditCommentForm,
|
||||
},
|
||||
props: {
|
||||
post: { type: Object, default: () => {} },
|
||||
comment: {
|
||||
type: Object,
|
||||
default() {
|
||||
@ -112,9 +124,16 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setEditPending: 'editor/SET_EDIT_PENDING',
|
||||
}),
|
||||
isAuthor(id) {
|
||||
return this.user.id === id
|
||||
},
|
||||
editCommentMenu(showMenu) {
|
||||
this.openEditCommentMenu = showMenu
|
||||
this.setEditPending(showMenu)
|
||||
},
|
||||
async deleteCommentCallback() {
|
||||
try {
|
||||
var gqlMutation = gql`
|
||||
|
||||
@ -76,14 +76,13 @@ export default {
|
||||
}
|
||||
|
||||
if (this.isOwner && this.resourceType === 'comment') {
|
||||
// routes.push({
|
||||
// name: this.$t(`comment.menu.edit`),
|
||||
// callback: () => {
|
||||
// /* eslint-disable-next-line no-console */
|
||||
// console.log('EDIT COMMENT')
|
||||
// },
|
||||
// icon: 'edit'
|
||||
// })
|
||||
routes.push({
|
||||
name: this.$t(`comment.menu.edit`),
|
||||
callback: () => {
|
||||
this.$emit('showEditCommentMenu', true)
|
||||
},
|
||||
icon: 'edit',
|
||||
})
|
||||
routes.push({
|
||||
name: this.$t(`comment.menu.delete`),
|
||||
callback: () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
134
webapp/components/FilterPosts/FilterPosts.spec.js
Normal file
134
webapp/components/FilterPosts/FilterPosts.spec.js
Normal file
@ -0,0 +1,134 @@
|
||||
import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import VTooltip from 'v-tooltip'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import Vuex from 'vuex'
|
||||
import FilterPosts from './FilterPosts.vue'
|
||||
import FilterPostsMenuItem from './FilterPostsMenuItems.vue'
|
||||
import { mutations } from '~/store/posts'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Styleguide)
|
||||
localVue.use(VTooltip)
|
||||
localVue.use(Vuex)
|
||||
|
||||
describe('FilterPosts.vue', () => {
|
||||
let wrapper
|
||||
let mocks
|
||||
let propsData
|
||||
let menuToggle
|
||||
let allCategoriesButton
|
||||
let environmentAndNatureButton
|
||||
let consumptionAndSustainabiltyButton
|
||||
let democracyAndPoliticsButton
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$apollo: {
|
||||
query: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { Post: { title: 'Post with Category', category: [{ id: 'cat4' }] } },
|
||||
})
|
||||
.mockRejectedValue({ message: 'We were unable to filter' }),
|
||||
},
|
||||
$t: jest.fn(),
|
||||
$i18n: {
|
||||
locale: () => 'en',
|
||||
},
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
}
|
||||
propsData = {
|
||||
categories: [
|
||||
{ id: 'cat4', name: 'Environment & Nature', icon: 'tree' },
|
||||
{ id: 'cat15', name: 'Consumption & Sustainability', icon: 'shopping-cart' },
|
||||
{ id: 'cat9', name: 'Democracy & Politics', icon: 'university' },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
const store = new Vuex.Store({
|
||||
mutations: {
|
||||
'posts/SET_POSTS': mutations.SET_POSTS,
|
||||
},
|
||||
})
|
||||
const Wrapper = () => {
|
||||
return mount(FilterPosts, { mocks, localVue, propsData, store })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
menuToggle = wrapper.findAll('a').at(0)
|
||||
menuToggle.trigger('click')
|
||||
})
|
||||
|
||||
it('groups the categories by pair', () => {
|
||||
expect(wrapper.vm.chunk).toEqual([
|
||||
[
|
||||
{ id: 'cat4', name: 'Environment & Nature', icon: 'tree' },
|
||||
{ id: 'cat15', name: 'Consumption & Sustainability', icon: 'shopping-cart' },
|
||||
],
|
||||
[{ id: 'cat9', name: 'Democracy & Politics', icon: 'university' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('starts with all categories button active', () => {
|
||||
allCategoriesButton = wrapper.findAll('button').at(0)
|
||||
expect(allCategoriesButton.attributes().class).toContain('ds-button-primary')
|
||||
})
|
||||
|
||||
it('adds a categories id to selectedCategoryIds when clicked', () => {
|
||||
environmentAndNatureButton = wrapper.findAll('button').at(1)
|
||||
environmentAndNatureButton.trigger('click')
|
||||
const filterPostsMenuItem = wrapper.find(FilterPostsMenuItem)
|
||||
expect(filterPostsMenuItem.vm.selectedCategoryIds).toEqual(['cat4'])
|
||||
})
|
||||
|
||||
it('sets primary to true when the button is clicked', () => {
|
||||
democracyAndPoliticsButton = wrapper.findAll('button').at(3)
|
||||
democracyAndPoliticsButton.trigger('click')
|
||||
expect(democracyAndPoliticsButton.attributes().class).toContain('ds-button-primary')
|
||||
})
|
||||
|
||||
it('queries a post by its categories', () => {
|
||||
consumptionAndSustainabiltyButton = wrapper.findAll('button').at(2)
|
||||
consumptionAndSustainabiltyButton.trigger('click')
|
||||
expect(mocks.$apollo.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: {
|
||||
filter: { categories_some: { id_in: ['cat15'] } },
|
||||
first: expect.any(Number),
|
||||
offset: expect.any(Number),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('supports a query of multiple categories', () => {
|
||||
environmentAndNatureButton = wrapper.findAll('button').at(1)
|
||||
environmentAndNatureButton.trigger('click')
|
||||
consumptionAndSustainabiltyButton = wrapper.findAll('button').at(2)
|
||||
consumptionAndSustainabiltyButton.trigger('click')
|
||||
expect(mocks.$apollo.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: {
|
||||
filter: { categories_some: { id_in: ['cat4', 'cat15'] } },
|
||||
first: expect.any(Number),
|
||||
offset: expect.any(Number),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles the categoryIds when clicked more than once', () => {
|
||||
environmentAndNatureButton = wrapper.findAll('button').at(1)
|
||||
environmentAndNatureButton.trigger('click')
|
||||
environmentAndNatureButton.trigger('click')
|
||||
const filterPostsMenuItem = wrapper.find(FilterPostsMenuItem)
|
||||
expect(filterPostsMenuItem.vm.selectedCategoryIds).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
61
webapp/components/FilterPosts/FilterPosts.vue
Normal file
61
webapp/components/FilterPosts/FilterPosts.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<dropdown ref="menu" :placement="placement" :offset="offset">
|
||||
<a slot="default" slot-scope="{ toggleMenu }" href="#" @click.prevent="toggleMenu()">
|
||||
<ds-icon style="margin: 12px 0px 0px 10px;" name="filter" size="large" />
|
||||
<ds-icon style="margin: 7px 0px 0px 2px" size="xx-small" name="angle-down" />
|
||||
</a>
|
||||
<template slot="popover">
|
||||
<filter-posts-menu-items :chunk="chunk" @filterPosts="filterPosts" />
|
||||
</template>
|
||||
</dropdown>
|
||||
</template>
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import { filterPosts } from '~/graphql/PostQuery.js'
|
||||
import { mapMutations } from 'vuex'
|
||||
import FilterPostsMenuItems from '~/components/FilterPosts/FilterPostsMenuItems'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Dropdown,
|
||||
FilterPostsMenuItems,
|
||||
},
|
||||
props: {
|
||||
placement: { type: String },
|
||||
offset: { type: [String, Number] },
|
||||
categories: { type: Array, default: () => [] },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pageSize: 12,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
chunk() {
|
||||
return _.chunk(this.categories, 2)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setPosts: 'posts/SET_POSTS',
|
||||
}),
|
||||
filterPosts(categoryIds) {
|
||||
const filter = categoryIds.length ? { categories_some: { id_in: categoryIds } } : {}
|
||||
this.$apollo
|
||||
.query({
|
||||
query: filterPosts(this.$i18n),
|
||||
variables: {
|
||||
filter: filter,
|
||||
first: this.pageSize,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
.then(({ data: { Post } }) => {
|
||||
this.setPosts(Post)
|
||||
})
|
||||
.catch(error => this.$toast.error(error.message))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
126
webapp/components/FilterPosts/FilterPostsMenuItems.vue
Normal file
126
webapp/components/FilterPosts/FilterPostsMenuItems.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<ds-container>
|
||||
<ds-space />
|
||||
<ds-flex id="filter-posts-header">
|
||||
<ds-heading tag="h4">{{ $t('filter-posts.header') }}</ds-heading>
|
||||
<ds-space margin-bottom="large" />
|
||||
</ds-flex>
|
||||
<ds-flex>
|
||||
<ds-flex-item
|
||||
:width="{ base: '100%', sm: '100%', md: '100%', lg: '5%' }"
|
||||
class="categories-menu-item"
|
||||
>
|
||||
<ds-flex>
|
||||
<ds-flex-item width="10%" />
|
||||
<ds-flex-item width="100%">
|
||||
<ds-button
|
||||
icon="check"
|
||||
@click.stop.prevent="toggleCategory()"
|
||||
:primary="allCategories"
|
||||
/>
|
||||
<ds-flex-item>
|
||||
<label class="category-labels">{{ $t('filter-posts.all') }}</label>
|
||||
</ds-flex-item>
|
||||
<ds-space />
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item :width="{ base: '0%', sm: '0%', md: '0%', lg: '4%' }" />
|
||||
<ds-flex-item
|
||||
:width="{ base: '0%', sm: '0%', md: '0%', lg: '3%' }"
|
||||
id="categories-menu-divider"
|
||||
/>
|
||||
<ds-flex-item
|
||||
:width="{ base: '50%', sm: '50%', md: '50%', lg: '11%' }"
|
||||
v-for="index in chunk.length"
|
||||
:key="index"
|
||||
>
|
||||
<ds-flex v-for="category in chunk[index - 1]" :key="category.id" class="categories-menu">
|
||||
<ds-flex class="categories-menu">
|
||||
<ds-flex-item width="100%" class="categories-menu-item">
|
||||
<ds-button
|
||||
:icon="category.icon"
|
||||
:primary="isActive(category.id)"
|
||||
@click.stop.prevent="toggleCategory(category.id)"
|
||||
/>
|
||||
<ds-space margin-bottom="small" />
|
||||
</ds-flex-item>
|
||||
<ds-flex>
|
||||
<ds-flex-item class="categories-menu-item">
|
||||
<label class="category-labels">{{ category.name }}</label>
|
||||
</ds-flex-item>
|
||||
<ds-space margin-bottom="xx-large" />
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
</ds-container>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
chunk: { type: Array, default: () => [] },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedCategoryIds: [],
|
||||
allCategories: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isActive(id) {
|
||||
const index = this.selectedCategoryIds.indexOf(id)
|
||||
if (index > -1) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
toggleCategory(id) {
|
||||
if (!id) {
|
||||
this.selectedCategoryIds = []
|
||||
this.allCategories = true
|
||||
} else {
|
||||
const index = this.selectedCategoryIds.indexOf(id)
|
||||
if (index > -1) {
|
||||
this.selectedCategoryIds.splice(index, 1)
|
||||
} else {
|
||||
this.selectedCategoryIds.push(id)
|
||||
}
|
||||
this.allCategories = false
|
||||
}
|
||||
this.$emit('filterPosts', this.selectedCategoryIds)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
#filter-posts-header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.categories-menu-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.categories-menu {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-labels {
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 960px) {
|
||||
#categories-menu-divider {
|
||||
border-left: 1px solid $border-color-soft;
|
||||
margin: 9px 0px 40px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 960px) {
|
||||
#filter-posts-header {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -54,7 +54,7 @@ describe('ChangePassword.vue', () => {
|
||||
describe('match', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find('input#oldPassword').setValue('some secret')
|
||||
wrapper.find('input#newPassword').setValue('some secret')
|
||||
wrapper.find('input#password').setValue('some secret')
|
||||
})
|
||||
|
||||
it('invalid', () => {
|
||||
@ -90,8 +90,8 @@ describe('ChangePassword.vue', () => {
|
||||
describe('given valid input', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find('input#oldPassword').setValue('supersecret')
|
||||
wrapper.find('input#newPassword').setValue('superdupersecret')
|
||||
wrapper.find('input#confirmPassword').setValue('superdupersecret')
|
||||
wrapper.find('input#password').setValue('superdupersecret')
|
||||
wrapper.find('input#passwordConfirmation').setValue('superdupersecret')
|
||||
})
|
||||
|
||||
describe('submit form', () => {
|
||||
@ -109,8 +109,8 @@ describe('ChangePassword.vue', () => {
|
||||
expect.objectContaining({
|
||||
variables: {
|
||||
oldPassword: 'supersecret',
|
||||
newPassword: 'superdupersecret',
|
||||
confirmPassword: 'superdupersecret',
|
||||
password: 'superdupersecret',
|
||||
passwordConfirmation: 'superdupersecret',
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ -135,8 +135,8 @@ describe('ChangePassword.vue', () => {
|
||||
/* describe('mutation rejects', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#oldPassword').setValue('supersecret')
|
||||
await wrapper.find('input#newPassword').setValue('supersecret')
|
||||
await wrapper.find('input#confirmPassword').setValue('supersecret')
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
})
|
||||
|
||||
it('displays error message', async () => {
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<ds-form
|
||||
v-model="formData"
|
||||
:schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
@input="handleInput"
|
||||
@input-valid="handleInputValid"
|
||||
>
|
||||
<template>
|
||||
<ds-form v-model="formData" :schema="formSchema" @submit="handleSubmit">
|
||||
<template slot-scope="{ errors }">
|
||||
<ds-input
|
||||
id="oldPassword"
|
||||
model="oldPassword"
|
||||
@ -15,22 +9,22 @@
|
||||
:label="$t('settings.security.change-password.label-old-password')"
|
||||
/>
|
||||
<ds-input
|
||||
id="newPassword"
|
||||
model="newPassword"
|
||||
id="password"
|
||||
model="password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('settings.security.change-password.label-new-password')"
|
||||
/>
|
||||
<ds-input
|
||||
id="confirmPassword"
|
||||
model="confirmPassword"
|
||||
id="passwordConfirmation"
|
||||
model="passwordConfirmation"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('settings.security.change-password.label-new-password-confirm')"
|
||||
/>
|
||||
<password-strength :password="formData.newPassword" />
|
||||
<password-strength :password="formData.password" />
|
||||
<ds-space margin-top="base">
|
||||
<ds-button :loading="loading" :disabled="disabled" primary>
|
||||
<ds-button :loading="loading" :disabled="errors" primary>
|
||||
{{ $t('settings.security.change-password.button') }}
|
||||
</ds-button>
|
||||
</ds-space>
|
||||
@ -41,6 +35,7 @@
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import PasswordStrength from './Strength'
|
||||
import PasswordForm from '~/components/utils/PasswordFormHelper'
|
||||
|
||||
export default {
|
||||
name: 'ChangePassword',
|
||||
@ -48,11 +43,11 @@ export default {
|
||||
PasswordStrength,
|
||||
},
|
||||
data() {
|
||||
const passwordForm = PasswordForm({ translate: this.$t })
|
||||
return {
|
||||
formData: {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
...passwordForm.formData,
|
||||
},
|
||||
formSchema: {
|
||||
oldPassword: {
|
||||
@ -60,38 +55,18 @@ export default {
|
||||
required: true,
|
||||
message: this.$t('settings.security.change-password.message-old-password-required'),
|
||||
},
|
||||
newPassword: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: this.$t('settings.security.change-password.message-new-password-required'),
|
||||
},
|
||||
confirmPassword: [
|
||||
{ validator: this.matchPassword },
|
||||
{
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: this.$t(
|
||||
'settings.security.change-password.message-new-password-confirm-required',
|
||||
),
|
||||
},
|
||||
],
|
||||
...passwordForm.formSchema,
|
||||
},
|
||||
loading: false,
|
||||
disabled: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async handleInput(data) {
|
||||
this.disabled = true
|
||||
},
|
||||
async handleInputValid(data) {
|
||||
this.disabled = false
|
||||
},
|
||||
async handleSubmit(data) {
|
||||
this.loading = true
|
||||
const mutation = gql`
|
||||
mutation($oldPassword: String!, $newPassword: String!) {
|
||||
changePassword(oldPassword: $oldPassword, newPassword: $newPassword)
|
||||
mutation($oldPassword: String!, $password: String!) {
|
||||
changePassword(oldPassword: $oldPassword, newPassword: $password)
|
||||
}
|
||||
`
|
||||
const variables = this.formData
|
||||
@ -102,8 +77,8 @@ export default {
|
||||
this.$toast.success(this.$t('settings.security.change-password.success'))
|
||||
this.formData = {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
}
|
||||
} catch (err) {
|
||||
this.$toast.error(err.message)
|
||||
@ -111,15 +86,6 @@ export default {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
matchPassword(rule, value, callback, source, options) {
|
||||
var errors = []
|
||||
if (this.formData.newPassword !== value) {
|
||||
errors.push(
|
||||
new Error(this.$t('settings.security.change-password.message-new-password-missmatch')),
|
||||
)
|
||||
}
|
||||
callback(errors)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -47,8 +47,8 @@ describe('ChangePassword ', () => {
|
||||
describe('submitting new password', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#newPassword').setValue('supersecret')
|
||||
wrapper.find('input#confirmPassword').setValue('supersecret')
|
||||
wrapper.find('input#password').setValue('supersecret')
|
||||
wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
@ -58,7 +58,7 @@ describe('ChangePassword ', () => {
|
||||
|
||||
it('delivers new password to backend', () => {
|
||||
const expected = expect.objectContaining({
|
||||
variables: { code: '123456', email: 'mail@example.org', newPassword: 'supersecret' },
|
||||
variables: { code: '123456', email: 'mail@example.org', password: 'supersecret' },
|
||||
})
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
@ -1,54 +1,52 @@
|
||||
<template>
|
||||
<ds-card class="verify-code">
|
||||
<ds-space margin="large">
|
||||
<template>
|
||||
<ds-form
|
||||
v-if="!changePasswordResult"
|
||||
v-model="formData"
|
||||
:schema="formSchema"
|
||||
@submit="handleSubmitPassword"
|
||||
@input="handleInput"
|
||||
@input-valid="handleInputValid"
|
||||
class="change-password"
|
||||
>
|
||||
<ds-form
|
||||
v-if="!changePasswordResult"
|
||||
v-model="formData"
|
||||
:schema="formSchema"
|
||||
@submit="handleSubmitPassword"
|
||||
class="change-password"
|
||||
>
|
||||
<template slot-scope="{ errors }">
|
||||
<ds-input
|
||||
id="newPassword"
|
||||
model="newPassword"
|
||||
id="password"
|
||||
model="password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('settings.security.change-password.label-new-password')"
|
||||
/>
|
||||
<ds-input
|
||||
id="confirmPassword"
|
||||
model="confirmPassword"
|
||||
id="passwordConfirmation"
|
||||
model="passwordConfirmation"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('settings.security.change-password.label-new-password-confirm')"
|
||||
/>
|
||||
<password-strength :password="formData.newPassword" />
|
||||
<password-strength :password="formData.password" />
|
||||
<ds-space margin-top="base">
|
||||
<ds-button :loading="$apollo.loading" :disabled="disabled" primary>
|
||||
<ds-button :loading="$apollo.loading" :disabled="errors" primary>
|
||||
{{ $t('settings.security.change-password.button') }}
|
||||
</ds-button>
|
||||
</ds-space>
|
||||
</ds-form>
|
||||
<ds-text v-else>
|
||||
<template v-if="changePasswordResult === 'success'">
|
||||
<sweetalert-icon icon="success" />
|
||||
<ds-text>
|
||||
{{ $t(`verify-code.form.change-password.success`) }}
|
||||
</ds-text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<sweetalert-icon icon="error" />
|
||||
<ds-text align="left">
|
||||
{{ $t(`verify-code.form.change-password.error`) }}
|
||||
{{ $t('verify-code.form.change-password.help') }}
|
||||
</ds-text>
|
||||
<a href="mailto:support@human-connection.org">support@human-connection.org</a>
|
||||
</template>
|
||||
</ds-text>
|
||||
</template>
|
||||
</template>
|
||||
</ds-form>
|
||||
<ds-text v-else>
|
||||
<template v-if="changePasswordResult === 'success'">
|
||||
<sweetalert-icon icon="success" />
|
||||
<ds-text>
|
||||
{{ $t(`verify-code.form.change-password.success`) }}
|
||||
</ds-text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<sweetalert-icon icon="error" />
|
||||
<ds-text align="left">
|
||||
{{ $t(`verify-code.form.change-password.error`) }}
|
||||
{{ $t('verify-code.form.change-password.help') }}
|
||||
</ds-text>
|
||||
<a href="mailto:support@human-connection.org">support@human-connection.org</a>
|
||||
</template>
|
||||
</ds-text>
|
||||
</ds-space>
|
||||
</ds-card>
|
||||
</template>
|
||||
@ -57,6 +55,7 @@
|
||||
import PasswordStrength from '../Password/Strength'
|
||||
import gql from 'graphql-tag'
|
||||
import { SweetalertIcon } from 'vue-sweetalert-icons'
|
||||
import PasswordForm from '~/components/utils/PasswordFormHelper'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -68,48 +67,28 @@ export default {
|
||||
code: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
const passwordForm = PasswordForm({ translate: this.$t })
|
||||
return {
|
||||
formData: {
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
...passwordForm.formData,
|
||||
},
|
||||
formSchema: {
|
||||
newPassword: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: this.$t('settings.security.change-password.message-new-password-required'),
|
||||
},
|
||||
confirmPassword: [
|
||||
{ validator: this.matchPassword },
|
||||
{
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: this.$t(
|
||||
'settings.security.change-password.message-new-password-confirm-required',
|
||||
),
|
||||
},
|
||||
],
|
||||
...passwordForm.formSchema,
|
||||
},
|
||||
disabled: true,
|
||||
changePasswordResult: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async handleInput() {
|
||||
this.disabled = true
|
||||
},
|
||||
async handleInputValid() {
|
||||
this.disabled = false
|
||||
},
|
||||
async handleSubmitPassword() {
|
||||
const mutation = gql`
|
||||
mutation($code: String!, $email: String!, $newPassword: String!) {
|
||||
resetPassword(code: $code, email: $email, newPassword: $newPassword)
|
||||
mutation($code: String!, $email: String!, $password: String!) {
|
||||
resetPassword(code: $code, email: $email, newPassword: $password)
|
||||
}
|
||||
`
|
||||
const { newPassword } = this.formData
|
||||
const { password } = this.formData
|
||||
const { email, code } = this
|
||||
const variables = { newPassword, email, code }
|
||||
const variables = { password, email, code }
|
||||
try {
|
||||
const {
|
||||
data: { resetPassword },
|
||||
@ -119,22 +98,13 @@ export default {
|
||||
this.$emit('passwordResetResponse', this.changePasswordResult)
|
||||
}, 3000)
|
||||
this.formData = {
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
}
|
||||
} catch (err) {
|
||||
this.$toast.error(err.message)
|
||||
}
|
||||
},
|
||||
matchPassword(rule, value, callback, source, options) {
|
||||
var errors = []
|
||||
if (this.formData.newPassword !== value) {
|
||||
errors.push(
|
||||
new Error(this.$t('settings.security.change-password.message-new-password-missmatch')),
|
||||
)
|
||||
}
|
||||
callback(errors)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -128,6 +128,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.ds-card-image img {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
-o-object-fit: cover;
|
||||
object-fit: cover;
|
||||
-o-object-position: center;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
132
webapp/components/Registration/CreateUserAccount.spec.js
Normal file
132
webapp/components/Registration/CreateUserAccount.spec.js
Normal file
@ -0,0 +1,132 @@
|
||||
import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import CreateUserAccount, { SignupVerificationMutation } from './CreateUserAccount'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Styleguide)
|
||||
|
||||
describe('CreateUserAccount', () => {
|
||||
let wrapper
|
||||
let Wrapper
|
||||
let mocks
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
$t: jest.fn(),
|
||||
$apollo: {
|
||||
loading: false,
|
||||
mutate: jest.fn(),
|
||||
},
|
||||
}
|
||||
propsData = {}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
Wrapper = () => {
|
||||
return mount(CreateUserAccount, {
|
||||
mocks,
|
||||
propsData,
|
||||
localVue,
|
||||
})
|
||||
}
|
||||
|
||||
describe('given email and nonce', () => {
|
||||
beforeEach(() => {
|
||||
propsData.nonce = '666777'
|
||||
propsData.email = 'sixseven@example.org'
|
||||
})
|
||||
|
||||
it('renders a form to create a new user', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.find('.create-user-account').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('submit', () => {
|
||||
let action
|
||||
beforeEach(() => {
|
||||
action = async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#name').setValue('John Doe')
|
||||
wrapper.find('input#password').setValue('hellopassword')
|
||||
wrapper.find('input#passwordConfirmation').setValue('hellopassword')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.html()
|
||||
}
|
||||
})
|
||||
|
||||
it('calls CreateUserAccount graphql mutation', async () => {
|
||||
await action()
|
||||
const expected = expect.objectContaining({ mutation: SignupVerificationMutation })
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('delivers data to backend', async () => {
|
||||
await action()
|
||||
const expected = expect.objectContaining({
|
||||
variables: {
|
||||
about: '',
|
||||
name: 'John Doe',
|
||||
email: 'sixseven@example.org',
|
||||
nonce: '666777',
|
||||
password: 'hellopassword',
|
||||
},
|
||||
})
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
describe('in case mutation resolves', () => {
|
||||
beforeEach(() => {
|
||||
mocks.$apollo.mutate = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
SignupVerification: {
|
||||
id: 'u1',
|
||||
name: 'John Doe',
|
||||
slug: 'john-doe',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('displays success', async () => {
|
||||
await action()
|
||||
expect(mocks.$t).toHaveBeenCalledWith('registration.create-user-account.success')
|
||||
})
|
||||
|
||||
describe('after timeout', () => {
|
||||
beforeEach(jest.useFakeTimers)
|
||||
|
||||
it('emits `userCreated` with { password, email }', async () => {
|
||||
await action()
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.emitted('userCreated')).toEqual([
|
||||
[
|
||||
{
|
||||
email: 'sixseven@example.org',
|
||||
password: 'hellopassword',
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('in case mutation rejects', () => {
|
||||
beforeEach(() => {
|
||||
mocks.$apollo.mutate = jest.fn().mockRejectedValue(new Error('Invalid nonce'))
|
||||
})
|
||||
|
||||
it('displays form errors', async () => {
|
||||
await action()
|
||||
expect(wrapper.find('.backendErrors').text()).toContain('Invalid nonce')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
142
webapp/components/Registration/CreateUserAccount.vue
Normal file
142
webapp/components/Registration/CreateUserAccount.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<ds-card v-if="success" class="success">
|
||||
<ds-space>
|
||||
<sweetalert-icon icon="success" />
|
||||
<ds-text align="center" bold color="success">
|
||||
{{ $t('registration.create-user-account.success') }}
|
||||
</ds-text>
|
||||
</ds-space>
|
||||
</ds-card>
|
||||
<ds-form
|
||||
v-else
|
||||
class="create-user-account"
|
||||
v-model="formData"
|
||||
:schema="formSchema"
|
||||
@submit="submit"
|
||||
>
|
||||
<template slot-scope="{ errors }">
|
||||
<ds-card :header="$t('registration.create-user-account.title')">
|
||||
<ds-input
|
||||
id="name"
|
||||
model="name"
|
||||
icon="user"
|
||||
:label="$t('settings.data.labelName')"
|
||||
:placeholder="$t('settings.data.namePlaceholder')"
|
||||
/>
|
||||
<ds-input
|
||||
id="bio"
|
||||
model="about"
|
||||
type="textarea"
|
||||
rows="3"
|
||||
:label="$t('settings.data.labelBio')"
|
||||
:placeholder="$t('settings.data.labelBio')"
|
||||
/>
|
||||
<ds-input
|
||||
id="password"
|
||||
model="password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('settings.security.change-password.label-new-password')"
|
||||
/>
|
||||
<ds-input
|
||||
id="passwordConfirmation"
|
||||
model="passwordConfirmation"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('settings.security.change-password.label-new-password-confirm')"
|
||||
/>
|
||||
<password-strength :password="formData.password" />
|
||||
<template slot="footer">
|
||||
<ds-space class="backendErrors" v-if="backendErrors">
|
||||
<ds-text align="center" bold color="danger">
|
||||
{{ backendErrors.message }}
|
||||
</ds-text>
|
||||
</ds-space>
|
||||
<ds-button
|
||||
style="float: right;"
|
||||
icon="check"
|
||||
type="submit"
|
||||
:loading="$apollo.loading"
|
||||
:disabled="errors"
|
||||
primary
|
||||
>
|
||||
{{ $t('actions.save') }}
|
||||
</ds-button>
|
||||
</template>
|
||||
</ds-card>
|
||||
</template>
|
||||
</ds-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import PasswordStrength from '../Password/Strength'
|
||||
import { SweetalertIcon } from 'vue-sweetalert-icons'
|
||||
import PasswordForm from '~/components/utils/PasswordFormHelper'
|
||||
|
||||
export const SignupVerificationMutation = gql`
|
||||
mutation($nonce: String!, $name: String!, $email: String!, $password: String!) {
|
||||
SignupVerification(nonce: $nonce, email: $email, name: $name, password: $password) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
`
|
||||
export default {
|
||||
components: {
|
||||
PasswordStrength,
|
||||
SweetalertIcon,
|
||||
},
|
||||
data() {
|
||||
const passwordForm = PasswordForm({ translate: this.$t })
|
||||
return {
|
||||
formData: {
|
||||
name: '',
|
||||
about: '',
|
||||
...passwordForm.formData,
|
||||
},
|
||||
formSchema: {
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
min: 3,
|
||||
},
|
||||
about: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
...passwordForm.formSchema,
|
||||
},
|
||||
disabled: true,
|
||||
success: null,
|
||||
backendErrors: null,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
nonce: { type: String, required: true },
|
||||
email: { type: String, required: true },
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
const { name, password, about } = this.formData
|
||||
const { email, nonce } = this
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: SignupVerificationMutation,
|
||||
variables: { name, password, about, email, nonce },
|
||||
})
|
||||
this.success = true
|
||||
setTimeout(() => {
|
||||
this.$emit('userCreated', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
this.backendErrors = err
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
146
webapp/components/Registration/Signup.spec.js
Normal file
146
webapp/components/Registration/Signup.spec.js
Normal file
@ -0,0 +1,146 @@
|
||||
import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import Signup, { SignupMutation, SignupByInvitationMutation } from './Signup'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Styleguide)
|
||||
|
||||
describe('Signup', () => {
|
||||
let wrapper
|
||||
let Wrapper
|
||||
let mocks
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
$t: jest.fn(),
|
||||
$apollo: {
|
||||
loading: false,
|
||||
mutate: jest.fn().mockResolvedValue({ data: { Signup: { email: 'mail@example.org' } } }),
|
||||
},
|
||||
}
|
||||
propsData = {}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(jest.useFakeTimers)
|
||||
|
||||
Wrapper = () => {
|
||||
return mount(Signup, {
|
||||
mocks,
|
||||
propsData,
|
||||
localVue,
|
||||
})
|
||||
}
|
||||
|
||||
describe('without invitation code', () => {
|
||||
it('renders signup form', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.find('.signup').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('submit', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#email').setValue('mail@example.org')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls Signup graphql mutation', () => {
|
||||
const expected = expect.objectContaining({ mutation: SignupMutation })
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('delivers email to backend', () => {
|
||||
const expected = expect.objectContaining({
|
||||
variables: { email: 'mail@example.org', token: null },
|
||||
})
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('hides form to avoid re-submission', () => {
|
||||
expect(wrapper.find('form').exists()).not.toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays a message that a mail for email verification was sent', () => {
|
||||
const expected = ['registration.signup.form.success', { email: 'mail@example.org' }]
|
||||
expect(mocks.$t).toHaveBeenCalledWith(...expected)
|
||||
})
|
||||
|
||||
describe('after animation', () => {
|
||||
beforeEach(jest.runAllTimers)
|
||||
|
||||
it('emits `handleSubmitted`', () => {
|
||||
expect(wrapper.emitted('handleSubmitted')).toEqual([[{ email: 'mail@example.org' }]])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with invitation code', () => {
|
||||
let action
|
||||
beforeEach(() => {
|
||||
propsData.token = '666777'
|
||||
action = async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#email').setValue('mail@example.org')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.html()
|
||||
}
|
||||
})
|
||||
|
||||
describe('submit', () => {
|
||||
it('calls SignupByInvitation graphql mutation', async () => {
|
||||
await action()
|
||||
const expected = expect.objectContaining({ mutation: SignupByInvitationMutation })
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('delivers invitation token to backend', async () => {
|
||||
await action()
|
||||
const expected = expect.objectContaining({
|
||||
variables: { email: 'mail@example.org', token: '666777' },
|
||||
})
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
describe('in case a user account with the email already exists', () => {
|
||||
beforeEach(() => {
|
||||
mocks.$apollo.mutate = jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error('UserInputError: User account with this email already exists.'),
|
||||
)
|
||||
})
|
||||
|
||||
it('explains the error', async () => {
|
||||
await action()
|
||||
expect(mocks.$t).toHaveBeenCalledWith('registration.signup.form.errors.email-exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('in case the invitation code was incorrect', () => {
|
||||
beforeEach(() => {
|
||||
mocks.$apollo.mutate = jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error('UserInputError: Invitation code already used or does not exist.'),
|
||||
)
|
||||
})
|
||||
|
||||
it('explains the error', async () => {
|
||||
await action()
|
||||
expect(mocks.$t).toHaveBeenCalledWith(
|
||||
'registration.signup.form.errors.invalid-invitation-token',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
141
webapp/components/Registration/Signup.vue
Normal file
141
webapp/components/Registration/Signup.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<ds-card class="signup">
|
||||
<ds-space margin="large">
|
||||
<ds-form
|
||||
v-if="!success && !error"
|
||||
@input="handleInput"
|
||||
@input-valid="handleInputValid"
|
||||
v-model="formData"
|
||||
:schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<h1>{{ $t('registration.signup.title') }}</h1>
|
||||
<ds-space v-if="token" margin-botton="large">
|
||||
<ds-text v-html="$t('registration.signup.form.invitation-code', { code: token })" />
|
||||
</ds-space>
|
||||
<ds-space margin-botton="large">
|
||||
<ds-text>
|
||||
{{ $t('registration.signup.form.description') }}
|
||||
</ds-text>
|
||||
</ds-space>
|
||||
<ds-input
|
||||
:placeholder="$t('login.email')"
|
||||
type="email"
|
||||
id="email"
|
||||
model="email"
|
||||
name="email"
|
||||
icon="envelope"
|
||||
/>
|
||||
<ds-button
|
||||
:disabled="disabled"
|
||||
:loading="$apollo.loading"
|
||||
primary
|
||||
fullwidth
|
||||
name="submit"
|
||||
type="submit"
|
||||
icon="envelope"
|
||||
>
|
||||
{{ $t('registration.signup.form.submit') }}
|
||||
</ds-button>
|
||||
</ds-form>
|
||||
<div v-else>
|
||||
<template v-if="!error">
|
||||
<sweetalert-icon icon="info" />
|
||||
<ds-text align="center" v-html="submitMessage" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<sweetalert-icon icon="error" />
|
||||
<ds-text align="center">
|
||||
{{ error.message }}
|
||||
</ds-text>
|
||||
</template>
|
||||
</div>
|
||||
</ds-space>
|
||||
</ds-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { SweetalertIcon } from 'vue-sweetalert-icons'
|
||||
|
||||
export const SignupMutation = gql`
|
||||
mutation($email: String!) {
|
||||
Signup(email: $email) {
|
||||
email
|
||||
}
|
||||
}
|
||||
`
|
||||
export const SignupByInvitationMutation = gql`
|
||||
mutation($email: String!, $token: String!) {
|
||||
SignupByInvitation(email: $email, token: $token) {
|
||||
email
|
||||
}
|
||||
}
|
||||
`
|
||||
export default {
|
||||
components: {
|
||||
SweetalertIcon,
|
||||
},
|
||||
props: {
|
||||
token: { type: String, default: null },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
formData: {
|
||||
email: '',
|
||||
},
|
||||
formSchema: {
|
||||
email: {
|
||||
type: 'email',
|
||||
required: true,
|
||||
message: this.$t('common.validations.email'),
|
||||
},
|
||||
},
|
||||
disabled: true,
|
||||
success: false,
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
submitMessage() {
|
||||
const { email } = this.formData
|
||||
return this.$t('registration.signup.form.success', { email })
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.disabled = true
|
||||
},
|
||||
handleInputValid() {
|
||||
this.disabled = false
|
||||
},
|
||||
async handleSubmit() {
|
||||
const mutation = this.token ? SignupByInvitationMutation : SignupMutation
|
||||
const { email } = this.formData
|
||||
const { token } = this
|
||||
|
||||
try {
|
||||
await this.$apollo.mutate({ mutation, variables: { email, token } })
|
||||
this.success = true
|
||||
|
||||
setTimeout(() => {
|
||||
this.$emit('handleSubmitted', { email })
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
const { message } = err
|
||||
const mapping = {
|
||||
'User account with this email already exists': 'email-exists',
|
||||
'Invitation code already used or does not exist': 'invalid-invitation-token',
|
||||
}
|
||||
for (const [pattern, key] of Object.entries(mapping)) {
|
||||
if (message.includes(pattern))
|
||||
this.error = { key, message: this.$t(`registration.signup.form.errors.${key}`) }
|
||||
}
|
||||
if (!this.error) {
|
||||
this.$toast.error(message)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ds-form v-model="form" @submit="handleSubmit">
|
||||
<ds-form v-show="!editPending" v-model="form" @submit="handleSubmit">
|
||||
<template slot-scope="{ errors }">
|
||||
<ds-card>
|
||||
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
|
||||
@ -24,9 +24,10 @@
|
||||
|
||||
<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'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -46,6 +47,11 @@ export default {
|
||||
users: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
editPending: 'editor/editPending',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
updateEditorContent(value) {
|
||||
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
|
||||
@ -40,6 +40,7 @@ describe('CommentForm.vue', () => {
|
||||
'editor/placeholder': () => {
|
||||
return 'some cool placeholder'
|
||||
},
|
||||
'editor/editPending': () => false,
|
||||
}
|
||||
const store = new Vuex.Store({
|
||||
getters,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user