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
14
.codecov.yml
14
.codecov.yml
@ -154,16 +154,4 @@ coverage:
|
|||||||
#fixes:
|
#fixes:
|
||||||
# - "old_path::new_path"
|
# - "old_path::new_path"
|
||||||
|
|
||||||
comment:
|
comment: off
|
||||||
# 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
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@ addons:
|
|||||||
before_install:
|
before_install:
|
||||||
- yarn global add wait-on
|
- yarn global add wait-on
|
||||||
# Install Codecov
|
# Install Codecov
|
||||||
- yarn global add codecov
|
|
||||||
- yarn install
|
- yarn install
|
||||||
- cp cypress.env.template.json cypress.env.json
|
- cp cypress.env.template.json cypress.env.json
|
||||||
|
|
||||||
@ -40,7 +39,7 @@ script:
|
|||||||
# Fullstack
|
# Fullstack
|
||||||
- yarn run cypress:run
|
- yarn run cypress:run
|
||||||
# Coverage
|
# Coverage
|
||||||
- codecov
|
- yarn run codecov
|
||||||
|
|
||||||
after_success:
|
after_success:
|
||||||
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
|
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
|
||||||
|
|||||||
@ -34,7 +34,6 @@
|
|||||||
"!**/src/**/?(*.)+(spec|test).js?(x)"
|
"!**/src/**/?(*.)+(spec|test).js?(x)"
|
||||||
],
|
],
|
||||||
"coverageReporters": [
|
"coverageReporters": [
|
||||||
"text",
|
|
||||||
"lcov"
|
"lcov"
|
||||||
],
|
],
|
||||||
"testMatch": [
|
"testMatch": [
|
||||||
@ -48,7 +47,8 @@
|
|||||||
"apollo-client": "~2.6.3",
|
"apollo-client": "~2.6.3",
|
||||||
"apollo-link-context": "~1.0.18",
|
"apollo-link-context": "~1.0.18",
|
||||||
"apollo-link-http": "~1.5.15",
|
"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",
|
"bcryptjs": "~2.4.3",
|
||||||
"cheerio": "~1.0.0-rc.3",
|
"cheerio": "~1.0.0-rc.3",
|
||||||
"cors": "~2.8.5",
|
"cors": "~2.8.5",
|
||||||
@ -56,7 +56,7 @@
|
|||||||
"date-fns": "2.0.0-beta.1",
|
"date-fns": "2.0.0-beta.1",
|
||||||
"debug": "~4.1.1",
|
"debug": "~4.1.1",
|
||||||
"dotenv": "~8.0.0",
|
"dotenv": "~8.0.0",
|
||||||
"express": "~4.17.1",
|
"express": "^4.17.1",
|
||||||
"faker": "Marak/faker.js#master",
|
"faker": "Marak/faker.js#master",
|
||||||
"graphql": "~14.4.2",
|
"graphql": "~14.4.2",
|
||||||
"graphql-custom-directives": "~0.2.14",
|
"graphql-custom-directives": "~0.2.14",
|
||||||
@ -64,33 +64,32 @@
|
|||||||
"graphql-middleware": "~3.0.2",
|
"graphql-middleware": "~3.0.2",
|
||||||
"graphql-shield": "~6.0.3",
|
"graphql-shield": "~6.0.3",
|
||||||
"graphql-tag": "~2.10.1",
|
"graphql-tag": "~2.10.1",
|
||||||
"graphql-yoga": "~1.18.0",
|
|
||||||
"helmet": "~3.18.0",
|
"helmet": "~3.18.0",
|
||||||
"jsonwebtoken": "~8.5.1",
|
"jsonwebtoken": "~8.5.1",
|
||||||
"linkifyjs": "~2.1.8",
|
"linkifyjs": "~2.1.8",
|
||||||
"lodash": "~4.17.11",
|
"lodash": "~4.17.14",
|
||||||
"merge-graphql-schemas": "^1.5.8",
|
"merge-graphql-schemas": "^1.5.8",
|
||||||
"neo4j-driver": "~1.7.4",
|
"neo4j-driver": "~1.7.4",
|
||||||
"neo4j-graphql-js": "^2.6.3",
|
"neo4j-graphql-js": "^2.6.3",
|
||||||
"neode": "^0.2.16",
|
"neode": "^0.2.16",
|
||||||
"node-fetch": "~2.6.0",
|
"node-fetch": "~2.6.0",
|
||||||
"nodemailer": "^6.2.1",
|
"nodemailer": "^6.3.0",
|
||||||
"npm-run-all": "~4.1.5",
|
"npm-run-all": "~4.1.5",
|
||||||
"request": "~2.88.0",
|
"request": "~2.88.0",
|
||||||
"sanitize-html": "~1.20.1",
|
"sanitize-html": "~1.20.1",
|
||||||
"slug": "~1.1.0",
|
"slug": "~1.1.0",
|
||||||
"trunc-html": "~1.1.2",
|
"trunc-html": "~1.1.2",
|
||||||
"uuid": "~3.3.2",
|
"uuid": "~3.3.2",
|
||||||
"wait-on": "~3.2.0"
|
"wait-on": "~3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "~7.5.0",
|
"@babel/cli": "~7.5.0",
|
||||||
"@babel/core": "~7.5.0",
|
"@babel/core": "~7.5.4",
|
||||||
"@babel/node": "~7.5.0",
|
"@babel/node": "~7.5.0",
|
||||||
"@babel/plugin-proposal-throw-expressions": "^7.2.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",
|
"@babel/register": "~7.4.4",
|
||||||
"apollo-server-testing": "~2.6.7",
|
"apollo-server-testing": "~2.7.0",
|
||||||
"babel-core": "~7.0.0-0",
|
"babel-core": "~7.0.0-0",
|
||||||
"babel-eslint": "~10.0.2",
|
"babel-eslint": "~10.0.2",
|
||||||
"babel-jest": "~24.8.0",
|
"babel-jest": "~24.8.0",
|
||||||
@ -100,7 +99,7 @@
|
|||||||
"eslint-config-prettier": "~6.0.0",
|
"eslint-config-prettier": "~6.0.0",
|
||||||
"eslint-config-standard": "~12.0.0",
|
"eslint-config-standard": "~12.0.0",
|
||||||
"eslint-plugin-import": "~2.18.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-node": "~9.1.0",
|
||||||
"eslint-plugin-prettier": "~3.1.0",
|
"eslint-plugin-prettier": "~3.1.0",
|
||||||
"eslint-plugin-promise": "~4.2.1",
|
"eslint-plugin-promise": "~4.2.1",
|
||||||
|
|||||||
@ -1,88 +1,9 @@
|
|||||||
import Neode from 'neode'
|
import Neode from 'neode'
|
||||||
import uuid from 'uuid/v4'
|
import models from '../models'
|
||||||
|
|
||||||
export default function setupNeode(options) {
|
export default function setupNeode(options) {
|
||||||
const { uri, username, password } = options
|
const { uri, username, password } = options
|
||||||
const neodeInstance = new Neode(uri, username, password)
|
const neodeInstance = new Neode(uri, username, password)
|
||||||
neodeInstance.model('InvitationCode', {
|
neodeInstance.with(models)
|
||||||
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(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return neodeInstance
|
return neodeInstance
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,8 @@
|
|||||||
import createServer from './server'
|
import createServer from './server'
|
||||||
import ActivityPub from './activitypub/ActivityPub'
|
|
||||||
import CONFIG from './config'
|
import CONFIG from './config'
|
||||||
|
|
||||||
const serverConfig = {
|
const { app } = createServer()
|
||||||
port: CONFIG.GRAPHQL_PORT,
|
app.listen({ port: CONFIG.GRAPHQL_PORT }, () => {
|
||||||
// cors: {
|
|
||||||
// credentials: true,
|
|
||||||
// origin: [CONFIG.CLIENT_URI] // your frontend url.
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = createServer()
|
|
||||||
server.start(serverConfig, options => {
|
|
||||||
/* eslint-disable-next-line no-console */
|
/* eslint-disable-next-line no-console */
|
||||||
console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`)
|
console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`)
|
||||||
ActivityPub.init(server)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -15,3 +15,9 @@ export async function login(variables) {
|
|||||||
authorization: `Bearer ${response.login}`,
|
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
|
} = options
|
||||||
const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI)
|
const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI)
|
||||||
actionUrl.searchParams.set('nonce', nonce)
|
actionUrl.searchParams.set('nonce', nonce)
|
||||||
|
actionUrl.searchParams.set('email', email)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
to: email,
|
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', () => {
|
describe('content undefined', () => {
|
||||||
it('returns empty array', () => {
|
it('returns empty array', () => {
|
||||||
expect(extractIds()).toEqual([])
|
expect(extractMentionedUsers()).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -11,33 +11,33 @@ describe('extractIds', () => {
|
|||||||
it('ignores links without .mention class', () => {
|
it('ignores links without .mention class', () => {
|
||||||
const content =
|
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>'
|
'<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', () => {
|
describe('given a link with .mention class', () => {
|
||||||
it('extracts ids', () => {
|
it('extracts ids', () => {
|
||||||
const content =
|
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>'
|
'<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', () => {
|
describe('handles links', () => {
|
||||||
it('with slug and id', () => {
|
it('with slug and id', () => {
|
||||||
const content =
|
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>'
|
'<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', () => {
|
it('with domains', () => {
|
||||||
const content =
|
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>'
|
'<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', () => {
|
it('special characters', () => {
|
||||||
const content =
|
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>'
|
'<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', () => {
|
it('`href` contains no user id', () => {
|
||||||
const content =
|
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>'
|
'<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', () => {
|
it('`href` is empty or invalid', () => {
|
||||||
const content =
|
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>'
|
'<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 CONFIG from './../config'
|
||||||
|
|
||||||
import activityPub from './activityPubMiddleware'
|
import activityPub from './activityPubMiddleware'
|
||||||
import softDelete from './softDeleteMiddleware'
|
import softDelete from './softDeleteMiddleware'
|
||||||
import sluggify from './sluggifyMiddleware'
|
import sluggify from './sluggifyMiddleware'
|
||||||
@ -10,7 +12,7 @@ import user from './userMiddleware'
|
|||||||
import includedFields from './includedFieldsMiddleware'
|
import includedFields from './includedFieldsMiddleware'
|
||||||
import orderBy from './orderByMiddleware'
|
import orderBy from './orderByMiddleware'
|
||||||
import validation from './validation/validationMiddleware'
|
import validation from './validation/validationMiddleware'
|
||||||
import notifications from './notifications'
|
import handleContentData from './handleHtmlContent/handleContentData'
|
||||||
import email from './email/emailMiddleware'
|
import email from './email/emailMiddleware'
|
||||||
|
|
||||||
export default schema => {
|
export default schema => {
|
||||||
@ -21,7 +23,7 @@ export default schema => {
|
|||||||
validation: validation,
|
validation: validation,
|
||||||
sluggify: sluggify,
|
sluggify: sluggify,
|
||||||
excerpt: excerpt,
|
excerpt: excerpt,
|
||||||
notifications: notifications,
|
handleContentData: handleContentData,
|
||||||
xss: xss,
|
xss: xss,
|
||||||
softDelete: softDelete,
|
softDelete: softDelete,
|
||||||
user: user,
|
user: user,
|
||||||
@ -38,7 +40,7 @@ export default schema => {
|
|||||||
'sluggify',
|
'sluggify',
|
||||||
'excerpt',
|
'excerpt',
|
||||||
'email',
|
'email',
|
||||||
'notifications',
|
'handleContentData',
|
||||||
'xss',
|
'xss',
|
||||||
'softDelete',
|
'softDelete',
|
||||||
'user',
|
'user',
|
||||||
@ -56,5 +58,6 @@ export default schema => {
|
|||||||
console.log(`Warning: "${disabledMiddlewares}" middlewares have been disabled.`)
|
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,
|
'*': deny,
|
||||||
findPosts: allow,
|
findPosts: allow,
|
||||||
Category: allow,
|
Category: allow,
|
||||||
Tag: isAdmin,
|
Tag: allow,
|
||||||
Report: isModerator,
|
Report: isModerator,
|
||||||
Notification: isAdmin,
|
Notification: isAdmin,
|
||||||
statistics: allow,
|
statistics: allow,
|
||||||
@ -146,6 +146,7 @@ const permissions = shield(
|
|||||||
Comment: allow,
|
Comment: allow,
|
||||||
User: or(noEmailFilter, isAdmin),
|
User: or(noEmailFilter, isAdmin),
|
||||||
isLoggedIn: allow,
|
isLoggedIn: allow,
|
||||||
|
Badge: allow,
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
'*': deny,
|
'*': deny,
|
||||||
@ -160,9 +161,6 @@ const permissions = shield(
|
|||||||
UpdatePost: isAuthor,
|
UpdatePost: isAuthor,
|
||||||
DeletePost: isAuthor,
|
DeletePost: isAuthor,
|
||||||
report: isAuthenticated,
|
report: isAuthenticated,
|
||||||
CreateBadge: isAdmin,
|
|
||||||
UpdateBadge: isAdmin,
|
|
||||||
DeleteBadge: isAdmin,
|
|
||||||
CreateSocialMedia: isAuthenticated,
|
CreateSocialMedia: isAuthenticated,
|
||||||
UpdateSocialMedia: isAuthenticated,
|
UpdateSocialMedia: isAuthenticated,
|
||||||
DeleteSocialMedia: isAuthenticated,
|
DeleteSocialMedia: isAuthenticated,
|
||||||
@ -179,6 +177,7 @@ const permissions = shield(
|
|||||||
enable: isModerator,
|
enable: isModerator,
|
||||||
disable: isModerator,
|
disable: isModerator,
|
||||||
CreateComment: isAuthenticated,
|
CreateComment: isAuthenticated,
|
||||||
|
UpdateComment: isAuthor,
|
||||||
DeleteComment: isAuthor,
|
DeleteComment: isAuthor,
|
||||||
DeleteUser: isDeletingOwnAccount,
|
DeleteUser: isDeletingOwnAccount,
|
||||||
requestPasswordReset: allow,
|
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 { UserInputError } from 'apollo-server'
|
||||||
import Joi from '@hapi/joi'
|
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 => {
|
const validate = schema => {
|
||||||
return async (resolve, root, args, context, info) => {
|
return async (resolve, root, args, context, info) => {
|
||||||
const validation = schema.validate(args)
|
const validation = schema.validate(args)
|
||||||
@ -15,8 +18,47 @@ const socialMediaSchema = Joi.object().keys({
|
|||||||
.required(),
|
.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 {
|
export default {
|
||||||
Mutation: {
|
Mutation: {
|
||||||
CreateSocialMedia: validate(socialMediaSchema),
|
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,
|
resolvers,
|
||||||
config: {
|
config: {
|
||||||
query: {
|
query: {
|
||||||
exclude: ['InvitationCode', 'EmailAddress', 'Notfication', 'Statistics', 'LoggedInUser'],
|
exclude: [
|
||||||
|
'Badge',
|
||||||
|
'InvitationCode',
|
||||||
|
'EmailAddress',
|
||||||
|
'Notfication',
|
||||||
|
'Statistics',
|
||||||
|
'LoggedInUser',
|
||||||
|
],
|
||||||
// add 'User' here as soon as possible
|
// add 'User' here as soon as possible
|
||||||
},
|
},
|
||||||
mutation: {
|
mutation: {
|
||||||
exclude: ['InvitationCode', 'EmailAddress', 'Notfication', 'Statistics', 'LoggedInUser'],
|
exclude: [
|
||||||
|
'Badge',
|
||||||
|
'InvitationCode',
|
||||||
|
'EmailAddress',
|
||||||
|
'Notfication',
|
||||||
|
'Statistics',
|
||||||
|
'LoggedInUser',
|
||||||
|
],
|
||||||
// add 'User' here as soon as possible
|
// add 'User' here as soon as possible
|
||||||
},
|
},
|
||||||
debug: CONFIG.DEBUG,
|
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 { 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 {
|
export default {
|
||||||
Mutation: {
|
Mutation: {
|
||||||
CreateComment: async (object, params, context, resolveInfo) => {
|
CreateComment: async (object, params, context, resolveInfo) => {
|
||||||
const content = params.content.replace(/<(?:.|\n)*?>/gm, '').trim()
|
|
||||||
const { postId } = params
|
const { postId } = params
|
||||||
// Adding relationship from comment to post by passing in the postId,
|
// 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
|
// 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
|
// because we use relationships for this. So, we are deleting it from params
|
||||||
// before comment creation.
|
// before comment creation.
|
||||||
delete params.postId
|
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 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(
|
const commentWithoutRelationships = await neo4jgraphql(
|
||||||
object,
|
object,
|
||||||
params,
|
params,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import gql from 'graphql-tag'
|
|
||||||
import { GraphQLClient } from 'graphql-request'
|
import { GraphQLClient } from 'graphql-request'
|
||||||
import Factory from '../../seed/factories'
|
import Factory from '../../seed/factories'
|
||||||
import { host, login } from '../../jest/helpers'
|
import { host, login, gql } from '../../jest/helpers'
|
||||||
|
|
||||||
const factory = Factory()
|
const factory = Factory()
|
||||||
let client
|
let client
|
||||||
@ -10,7 +9,28 @@ let createPostVariables
|
|||||||
let createCommentVariablesSansPostId
|
let createCommentVariablesSansPostId
|
||||||
let createCommentVariablesWithNonExistentPost
|
let createCommentVariablesWithNonExistentPost
|
||||||
let userParams
|
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 () => {
|
beforeEach(async () => {
|
||||||
userParams = {
|
userParams = {
|
||||||
@ -26,21 +46,6 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('CreateComment', () => {
|
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', () => {
|
describe('unauthenticated', () => {
|
||||||
it('throws authorization error', async () => {
|
it('throws authorization error', async () => {
|
||||||
createCommentVariables = {
|
createCommentVariables = {
|
||||||
@ -55,7 +60,6 @@ describe('CreateComment', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('authenticated', () => {
|
describe('authenticated', () => {
|
||||||
let headers
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
headers = await login(userParams)
|
headers = await login(userParams)
|
||||||
client = new GraphQLClient(host, {
|
client = new GraphQLClient(host, {
|
||||||
@ -65,11 +69,6 @@ describe('CreateComment', () => {
|
|||||||
postId: 'p1',
|
postId: 'p1',
|
||||||
content: "I'm authorised to comment",
|
content: "I'm authorised to comment",
|
||||||
}
|
}
|
||||||
createPostVariables = {
|
|
||||||
id: 'p1',
|
|
||||||
title: 'post to comment on',
|
|
||||||
content: 'please comment on me',
|
|
||||||
}
|
|
||||||
await client.request(createPostMutation, createPostVariables)
|
await client.request(createPostMutation, createPostVariables)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -188,19 +187,8 @@ describe('CreateComment', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DeleteComment', () => {
|
describe('ManageComments', () => {
|
||||||
const deleteCommentMutation = gql`
|
let authorParams
|
||||||
mutation($id: ID!) {
|
|
||||||
DeleteComment(id: $id) {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
let deleteCommentVariables = {
|
|
||||||
id: 'c1',
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
authorParams = {
|
authorParams = {
|
||||||
email: 'author@example.org',
|
email: 'author@example.org',
|
||||||
@ -214,12 +202,133 @@ describe('DeleteComment', () => {
|
|||||||
content: 'Post to be commented',
|
content: 'Post to be commented',
|
||||||
})
|
})
|
||||||
await asAuthor.create('Comment', {
|
await asAuthor.create('Comment', {
|
||||||
id: 'c1',
|
id: 'c456',
|
||||||
postId: 'p1',
|
postId: 'p1',
|
||||||
content: 'Comment to be deleted',
|
content: 'Comment to be deleted',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('UpdateComment', () => {
|
||||||
|
const updateCommentMutation = gql`
|
||||||
|
mutation($content: String!, $id: ID!) {
|
||||||
|
UpdateComment(content: $content, id: $id) {
|
||||||
|
id
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
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', () => {
|
describe('unauthenticated', () => {
|
||||||
it('throws authorization error', async () => {
|
it('throws authorization error', async () => {
|
||||||
client = new GraphQLClient(host)
|
client = new GraphQLClient(host)
|
||||||
@ -231,9 +340,13 @@ describe('DeleteComment', () => {
|
|||||||
|
|
||||||
describe('authenticated but not the author', () => {
|
describe('authenticated but not the author', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
let headers
|
headers = await login({
|
||||||
headers = await login(userParams)
|
email: 'test@example.org',
|
||||||
client = new GraphQLClient(host, { headers })
|
password: '1234',
|
||||||
|
})
|
||||||
|
client = new GraphQLClient(host, {
|
||||||
|
headers,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws authorization error', async () => {
|
it('throws authorization error', async () => {
|
||||||
@ -245,20 +358,22 @@ describe('DeleteComment', () => {
|
|||||||
|
|
||||||
describe('authenticated as author', () => {
|
describe('authenticated as author', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
let headers
|
|
||||||
headers = await login(authorParams)
|
headers = await login(authorParams)
|
||||||
client = new GraphQLClient(host, { headers })
|
client = new GraphQLClient(host, {
|
||||||
|
headers,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deletes the comment', async () => {
|
it('deletes the comment', async () => {
|
||||||
const expected = {
|
const expected = {
|
||||||
DeleteComment: {
|
DeleteComment: {
|
||||||
id: 'c1',
|
id: 'c456',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).resolves.toEqual(
|
await expect(
|
||||||
expected,
|
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 { driver, code, email, issuedAt = new Date() } = options
|
||||||
const session = driver.session()
|
const session = driver.session()
|
||||||
const cypher = `
|
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})
|
CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL})
|
||||||
MERGE (u)-[:REQUESTED]->(pr)
|
MERGE (u)-[:REQUESTED]->(pr)
|
||||||
RETURN u
|
RETURN u
|
||||||
@ -35,7 +35,7 @@ export default {
|
|||||||
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
|
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
|
||||||
const cypher = `
|
const cypher = `
|
||||||
MATCH (pr:PasswordReset {code: $code})
|
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
|
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
|
||||||
SET pr.usedAt = datetime()
|
SET pr.usedAt = datetime()
|
||||||
SET u.encryptedPassword = $encryptedNewPassword
|
SET u.encryptedPassword = $encryptedNewPassword
|
||||||
|
|||||||
@ -18,10 +18,11 @@ const createPostWithCategoriesMutation = `
|
|||||||
mutation($title: String!, $content: String!, $categoryIds: [ID]) {
|
mutation($title: String!, $content: String!, $categoryIds: [ID]) {
|
||||||
CreatePost(title: $title, content: $content, categoryIds: $categoryIds) {
|
CreatePost(title: $title, content: $content, categoryIds: $categoryIds) {
|
||||||
id
|
id
|
||||||
|
title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
const creatPostWithCategoriesVariables = {
|
const createPostWithCategoriesVariables = {
|
||||||
title: postTitle,
|
title: postTitle,
|
||||||
content: postContent,
|
content: postContent,
|
||||||
categoryIds: ['cat9', 'cat4', 'cat15'],
|
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 () => {
|
beforeEach(async () => {
|
||||||
userParams = {
|
userParams = {
|
||||||
name: 'TestUser',
|
name: 'TestUser',
|
||||||
@ -133,7 +154,8 @@ describe('CreatePost', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('categories', () => {
|
describe('categories', () => {
|
||||||
it('allows a user to set the categories of the post', async () => {
|
let postWithCategories
|
||||||
|
beforeEach(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
factory.create('Category', {
|
factory.create('Category', {
|
||||||
id: 'cat9',
|
id: 'cat9',
|
||||||
@ -151,18 +173,39 @@ describe('CreatePost', () => {
|
|||||||
icon: 'shopping-cart',
|
icon: 'shopping-cart',
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
const expected = [{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]
|
postWithCategories = await client.request(
|
||||||
const postWithCategories = await client.request(
|
|
||||||
createPostWithCategoriesMutation,
|
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 = {
|
const postQueryWithCategoriesVariables = {
|
||||||
id: postWithCategories.CreatePost.id,
|
id: postWithCategories.CreatePost.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.request(postQueryWithCategories, postQueryWithCategoriesVariables),
|
client.request(postQueryWithCategories, postQueryWithCategoriesVariables),
|
||||||
).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] })
|
).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(
|
postWithCategories = await client.request(
|
||||||
createPostWithCategoriesMutation,
|
createPostWithCategoriesMutation,
|
||||||
creatPostWithCategoriesVariables,
|
createPostWithCategoriesVariables,
|
||||||
)
|
)
|
||||||
updatePostVariables = {
|
updatePostVariables = {
|
||||||
id: postWithCategories.CreatePost.id,
|
id: postWithCategories.CreatePost.id,
|
||||||
|
|||||||
@ -12,8 +12,8 @@ const instance = neode()
|
|||||||
*/
|
*/
|
||||||
const checkEmailDoesNotExist = async ({ email }) => {
|
const checkEmailDoesNotExist = async ({ email }) => {
|
||||||
email = email.toLowerCase()
|
email = email.toLowerCase()
|
||||||
const users = await instance.all('User', { email })
|
const emails = await instance.all('EmailAddress', { email })
|
||||||
if (users.length > 0) throw new UserInputError('User account with this email already exists.')
|
if (emails.length > 0) throw new UserInputError('User account with this email already exists.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@ -166,11 +166,12 @@ describe('SignupByInvitation', () => {
|
|||||||
await expect(action()).rejects.toThrow('"email" must be a valid email')
|
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 {
|
try {
|
||||||
await action()
|
await action()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const emailAddresses = await instance.all('EmailAddress')
|
let emailAddresses = await instance.all('EmailAddress')
|
||||||
|
emailAddresses = await emailAddresses.toJson
|
||||||
expect(emailAddresses).toHaveLength(0)
|
expect(emailAddresses).toHaveLength(0)
|
||||||
done()
|
done()
|
||||||
}
|
}
|
||||||
@ -191,16 +192,16 @@ describe('SignupByInvitation', () => {
|
|||||||
describe('creates a EmailAddress node', () => {
|
describe('creates a EmailAddress node', () => {
|
||||||
it('with a `createdAt` attribute', async () => {
|
it('with a `createdAt` attribute', async () => {
|
||||||
await action()
|
await action()
|
||||||
const emailAddresses = await instance.all('EmailAddress')
|
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||||
const emailAddress = await emailAddresses.first().toJson()
|
emailAddress = await emailAddress.toJson()
|
||||||
expect(emailAddress.createdAt).toBeTruthy()
|
expect(emailAddress.createdAt).toBeTruthy()
|
||||||
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
|
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with a cryptographic `nonce`', async () => {
|
it('with a cryptographic `nonce`', async () => {
|
||||||
await action()
|
await action()
|
||||||
const emailAddresses = await instance.all('EmailAddress')
|
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||||
const emailAddress = await emailAddresses.first().toJson()
|
emailAddress = await emailAddress.toJson()
|
||||||
expect(emailAddress.nonce).toEqual(expect.any(String))
|
expect(emailAddress.nonce).toEqual(expect.any(String))
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -220,6 +221,7 @@ describe('SignupByInvitation', () => {
|
|||||||
it('rejects because codes can be used only once', async done => {
|
it('rejects because codes can be used only once', async done => {
|
||||||
await action()
|
await action()
|
||||||
try {
|
try {
|
||||||
|
variables.email = 'yetanotheremail@example.org'
|
||||||
await action()
|
await action()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e.message).toMatch(/Invitation code already used/)
|
expect(e.message).toMatch(/Invitation code already used/)
|
||||||
@ -282,8 +284,8 @@ describe('Signup', () => {
|
|||||||
|
|
||||||
it('creates a Signup with a cryptographic `nonce`', async () => {
|
it('creates a Signup with a cryptographic `nonce`', async () => {
|
||||||
await action()
|
await action()
|
||||||
const emailAddresses = await instance.all('EmailAddress')
|
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||||
const emailAddress = await emailAddresses.first().toJson()
|
emailAddress = await emailAddress.toJson()
|
||||||
expect(emailAddress.nonce).toEqual(expect.any(String))
|
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 {
|
export default {
|
||||||
Mutation: {
|
Mutation: {
|
||||||
reward: async (_object, params, context, _resolveInfo) => {
|
reward: async (_object, params, context, _resolveInfo) => {
|
||||||
const { fromBadgeId, toUserId } = params
|
const { user, badge } = await getUserAndBadge(params)
|
||||||
const session = context.driver.session()
|
await user.relateTo(badge, 'rewarded')
|
||||||
|
return user.toJson()
|
||||||
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
|
|
||||||
},
|
},
|
||||||
|
|
||||||
unreward: async (_object, params, context, _resolveInfo) => {
|
unreward: async (_object, params, context, _resolveInfo) => {
|
||||||
const { fromBadgeId, toUserId } = params
|
const { badgeKey, userId } = params
|
||||||
|
const { user } = await getUserAndBadge(params)
|
||||||
const session = context.driver.session()
|
const session = context.driver.session()
|
||||||
|
try {
|
||||||
let transactionRes = await session.run(
|
// silly neode cannot remove relationships
|
||||||
`MATCH (badge:Badge {id: $badgeId})-[reward:REWARDED]->(rewardedUser:User {id: $rewardedUserId})
|
await session.run(
|
||||||
|
`
|
||||||
|
MATCH (badge:Badge {id: $badgeKey})-[reward:REWARDED]->(rewardedUser:User {id: $userId})
|
||||||
DELETE reward
|
DELETE reward
|
||||||
RETURN rewardedUser {.id}`,
|
RETURN rewardedUser
|
||||||
|
`,
|
||||||
{
|
{
|
||||||
badgeId: fromBadgeId,
|
badgeKey,
|
||||||
rewardedUserId: toUserId,
|
userId,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const [rewardedUser] = transactionRes.records.map(record => {
|
} catch (err) {
|
||||||
return record.get('rewardedUser')
|
throw err
|
||||||
})
|
} finally {
|
||||||
session.close()
|
session.close()
|
||||||
|
}
|
||||||
return rewardedUser.id
|
return user.toJson()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
import { GraphQLClient } from 'graphql-request'
|
import { GraphQLClient } from 'graphql-request'
|
||||||
import Factory from '../../seed/factories'
|
import Factory from '../../seed/factories'
|
||||||
import { host, login } from '../../jest/helpers'
|
import { host, login, gql } from '../../jest/helpers'
|
||||||
|
|
||||||
const factory = Factory()
|
const factory = Factory()
|
||||||
|
let user
|
||||||
|
let badge
|
||||||
|
|
||||||
describe('rewards', () => {
|
describe('rewards', () => {
|
||||||
|
const variables = {
|
||||||
|
from: 'indiegogo_en_rhino',
|
||||||
|
to: 'u1',
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await factory.create('User', {
|
user = await factory.create('User', {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
email: 'user@example.org',
|
email: 'user@example.org',
|
||||||
@ -22,9 +29,8 @@ describe('rewards', () => {
|
|||||||
role: 'admin',
|
role: 'admin',
|
||||||
email: 'admin@example.org',
|
email: 'admin@example.org',
|
||||||
})
|
})
|
||||||
await factory.create('Badge', {
|
badge = await factory.create('Badge', {
|
||||||
id: 'b6',
|
id: 'indiegogo_en_rhino',
|
||||||
key: 'indiegogo_en_rhino',
|
|
||||||
type: 'crowdfunding',
|
type: 'crowdfunding',
|
||||||
status: 'permanent',
|
status: 'permanent',
|
||||||
icon: '/img/badges/indiegogo_en_rhino.svg',
|
icon: '/img/badges/indiegogo_en_rhino.svg',
|
||||||
@ -35,21 +41,19 @@ describe('rewards', () => {
|
|||||||
await factory.cleanDatabase()
|
await factory.cleanDatabase()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('RewardBadge', () => {
|
describe('reward', () => {
|
||||||
const mutation = `
|
const mutation = gql`
|
||||||
mutation(
|
mutation($from: ID!, $to: ID!) {
|
||||||
$from: ID!
|
reward(badgeKey: $from, userId: $to) {
|
||||||
$to: ID!
|
id
|
||||||
) {
|
badges {
|
||||||
reward(fromBadgeId: $from, toUserId: $to)
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
describe('unauthenticated', () => {
|
describe('unauthenticated', () => {
|
||||||
const variables = {
|
|
||||||
from: 'b6',
|
|
||||||
to: 'u1',
|
|
||||||
}
|
|
||||||
let client
|
let client
|
||||||
|
|
||||||
it('throws authorization error', async () => {
|
it('throws authorization error', async () => {
|
||||||
@ -65,74 +69,95 @@ describe('rewards', () => {
|
|||||||
client = new GraphQLClient(host, { headers })
|
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 () => {
|
it('rewards a badge to user', async () => {
|
||||||
const variables = {
|
|
||||||
from: 'b6',
|
|
||||||
to: 'u1',
|
|
||||||
}
|
|
||||||
const expected = {
|
const expected = {
|
||||||
reward: 'u1',
|
reward: {
|
||||||
|
id: 'u1',
|
||||||
|
badges: [{ id: 'indiegogo_en_rhino' }],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rewards a second different badge to same user', async () => {
|
it('rewards a second different badge to same user', async () => {
|
||||||
await factory.create('Badge', {
|
await factory.create('Badge', {
|
||||||
id: 'b1',
|
id: 'indiegogo_en_racoon',
|
||||||
key: 'indiegogo_en_racoon',
|
|
||||||
type: 'crowdfunding',
|
|
||||||
status: 'permanent',
|
|
||||||
icon: '/img/badges/indiegogo_en_racoon.svg',
|
icon: '/img/badges/indiegogo_en_racoon.svg',
|
||||||
})
|
})
|
||||||
const variables = {
|
const badges = [{ id: 'indiegogo_en_racoon' }, { id: 'indiegogo_en_rhino' }]
|
||||||
from: 'b1',
|
|
||||||
to: 'u1',
|
|
||||||
}
|
|
||||||
const expected = {
|
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 () => {
|
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 = {
|
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 = {
|
it('creates no duplicate reward relationships', async () => {
|
||||||
from: 'b6',
|
|
||||||
to: 'u1',
|
|
||||||
}
|
|
||||||
await client.request(mutation, variables)
|
await client.request(mutation, variables)
|
||||||
await client.request(mutation, variables)
|
await client.request(mutation, variables)
|
||||||
|
|
||||||
const query = `{
|
const query = gql`
|
||||||
|
{
|
||||||
User(id: "u1") {
|
User(id: "u1") {
|
||||||
badgesCount
|
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)
|
await expect(client.request(query)).resolves.toEqual(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('authenticated moderator', () => {
|
describe('authenticated moderator', () => {
|
||||||
const variables = {
|
|
||||||
from: 'b6',
|
|
||||||
to: 'u1',
|
|
||||||
}
|
|
||||||
let client
|
let client
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const headers = await login({ email: 'moderator@example.org', password: '1234' })
|
const headers = await login({ email: 'moderator@example.org', password: '1234' })
|
||||||
@ -147,27 +172,41 @@ describe('rewards', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('RemoveReward', () => {
|
describe('unreward', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await factory.relate('User', 'Badges', { from: 'b6', to: 'u1' })
|
await user.relateTo(badge, 'rewarded')
|
||||||
})
|
})
|
||||||
const variables = {
|
const expected = { unreward: { id: 'u1', badges: [] } }
|
||||||
from: 'b6',
|
|
||||||
to: 'u1',
|
|
||||||
}
|
|
||||||
const expected = {
|
|
||||||
unreward: 'u1',
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutation = `
|
const mutation = gql`
|
||||||
mutation(
|
mutation($from: ID!, $to: ID!) {
|
||||||
$from: ID!
|
unreward(badgeKey: $from, userId: $to) {
|
||||||
$to: ID!
|
id
|
||||||
) {
|
badges {
|
||||||
unreward(fromBadgeId: $from, toUserId: $to)
|
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', () => {
|
describe('unauthenticated', () => {
|
||||||
let client
|
let client
|
||||||
|
|
||||||
@ -188,12 +227,9 @@ describe('rewards', () => {
|
|||||||
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
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 client.request(mutation, variables)
|
||||||
|
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
|
||||||
await expect(client.request(mutation, variables)).rejects.toThrow(
|
|
||||||
"Cannot read property 'id' of undefined",
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import gql from 'graphql-tag'
|
|
||||||
import { GraphQLClient } from 'graphql-request'
|
import { GraphQLClient } from 'graphql-request'
|
||||||
import Factory from '../../seed/factories'
|
import Factory from '../../seed/factories'
|
||||||
import { host, login } from '../../jest/helpers'
|
import { host, login, gql } from '../../jest/helpers'
|
||||||
|
|
||||||
const factory = Factory()
|
const factory = Factory()
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import encode from '../../jwt/encode'
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { AuthenticationError } from 'apollo-server'
|
import { AuthenticationError } from 'apollo-server'
|
||||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||||
|
import { neode } from '../../bootstrap/neo4j'
|
||||||
|
|
||||||
|
const instance = neode()
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
@ -21,8 +24,8 @@ export default {
|
|||||||
// }
|
// }
|
||||||
const session = driver.session()
|
const session = driver.session()
|
||||||
const result = await session.run(
|
const result = await session.run(
|
||||||
'MATCH (user:User {email: $userEmail}) ' +
|
'MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})' +
|
||||||
'RETURN user {.id, .slug, .name, .avatar, .email, .encryptedPassword, .role, .disabled} as user LIMIT 1',
|
'RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1',
|
||||||
{
|
{
|
||||||
userEmail: email,
|
userEmail: email,
|
||||||
},
|
},
|
||||||
@ -46,41 +49,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => {
|
changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => {
|
||||||
const session = driver.session()
|
let currentUser = await instance.find('User', user.id)
|
||||||
let result = await session.run(
|
|
||||||
`MATCH (user:User {email: $userEmail})
|
|
||||||
RETURN user {.id, .email, .encryptedPassword}`,
|
|
||||||
{
|
|
||||||
userEmail: user.email,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const [currentUser] = result.records.map(function(record) {
|
const encryptedPassword = currentUser.get('encryptedPassword')
|
||||||
return record.get('user')
|
if (!(await bcrypt.compareSync(oldPassword, encryptedPassword))) {
|
||||||
})
|
|
||||||
|
|
||||||
if (!(await bcrypt.compareSync(oldPassword, currentUser.encryptedPassword))) {
|
|
||||||
throw new AuthenticationError('Old password is not correct')
|
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')
|
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 {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
User: async (object, args, context, resolveInfo) => {
|
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)
|
return neo4jgraphql(object, args, context, resolveInfo, false)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -104,6 +111,14 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
User: {
|
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([
|
...undefinedToNull([
|
||||||
'actorId',
|
'actorId',
|
||||||
'avatar',
|
'avatar',
|
||||||
@ -139,7 +154,7 @@ export default {
|
|||||||
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
|
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
|
||||||
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
|
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
|
||||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||||
badges: '-[:REWARDED]->(related:Badge)',
|
badges: '<-[:REWARDED]-(related:Badge)',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { GraphQLClient } from 'graphql-request'
|
import { GraphQLClient } from 'graphql-request'
|
||||||
import { login, host } from '../../jest/helpers'
|
|
||||||
import Factory from '../../seed/factories'
|
import Factory from '../../seed/factories'
|
||||||
import gql from 'graphql-tag'
|
import { host, login, gql } from '../../jest/helpers'
|
||||||
|
|
||||||
const factory = Factory()
|
const factory = Factory()
|
||||||
let client
|
let client
|
||||||
@ -147,7 +146,7 @@ describe('users', () => {
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
asAuthor = await factory.create('User', {
|
await factory.create('User', {
|
||||||
email: 'test@example.org',
|
email: 'test@example.org',
|
||||||
password: '1234',
|
password: '1234',
|
||||||
id: 'u343',
|
id: 'u343',
|
||||||
@ -191,6 +190,7 @@ describe('users', () => {
|
|||||||
describe('attempting to delete my own account', () => {
|
describe('attempting to delete my own account', () => {
|
||||||
let expectedResponse
|
let expectedResponse
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
asAuthor = Factory()
|
||||||
await asAuthor.authenticateAs({
|
await asAuthor.authenticateAs({
|
||||||
email: 'test@example.org',
|
email: 'test@example.org',
|
||||||
password: '1234',
|
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
|
report(id: ID!, description: String): Report
|
||||||
disable(id: ID!): ID
|
disable(id: ID!): ID
|
||||||
enable(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 the given Type and ID
|
||||||
shout(id: ID!, type: ShoutTypeEnum): Boolean!
|
shout(id: ID!, type: ShoutTypeEnum): Boolean!
|
||||||
# Unshout the given Type and ID
|
# 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 {
|
type Badge {
|
||||||
id: ID!
|
id: ID!
|
||||||
key: String!
|
|
||||||
type: BadgeType!
|
type: BadgeType!
|
||||||
status: BadgeStatus!
|
status: BadgeStatus!
|
||||||
icon: String!
|
icon: String!
|
||||||
@ -11,3 +10,22 @@ type Badge {
|
|||||||
|
|
||||||
rewarded: [User]! @relation(name: "REWARDED", direction: "OUT")
|
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
|
): Comment
|
||||||
UpdateComment(
|
UpdateComment(
|
||||||
id: ID!
|
id: ID!
|
||||||
content: String
|
content: String!
|
||||||
contentExcerpt: String
|
contentExcerpt: String
|
||||||
deleted: Boolean
|
deleted: Boolean
|
||||||
disabled: 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
|
RETURN COUNT(u) >= 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
emotions: [EMOTED]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
|||||||
@ -2,14 +2,14 @@ type User {
|
|||||||
id: ID!
|
id: ID!
|
||||||
actorId: String
|
actorId: String
|
||||||
name: String
|
name: String
|
||||||
email: String!
|
email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
|
||||||
slug: String!
|
slug: String!
|
||||||
avatar: String
|
avatar: String
|
||||||
coverImg: String
|
coverImg: String
|
||||||
deleted: Boolean
|
deleted: Boolean
|
||||||
disabled: Boolean
|
disabled: Boolean
|
||||||
disabledBy: User @relation(name: "DISABLED", direction: "IN")
|
disabledBy: User @relation(name: "DISABLED", direction: "IN")
|
||||||
role: UserGroup
|
role: UserGroup!
|
||||||
publicKey: String
|
publicKey: String
|
||||||
invitedBy: User @relation(name: "INVITED", direction: "IN")
|
invitedBy: User @relation(name: "INVITED", direction: "IN")
|
||||||
invited: [User] @relation(name: "INVITED", direction: "OUT")
|
invited: [User] @relation(name: "INVITED", direction: "OUT")
|
||||||
@ -73,12 +73,17 @@ type User {
|
|||||||
|
|
||||||
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
|
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
|
||||||
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
|
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
|
||||||
|
|
||||||
|
emotions: [EMOTED]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
input _UserFilter {
|
input _UserFilter {
|
||||||
AND: [_UserFilter!]
|
AND: [_UserFilter!]
|
||||||
OR: [_UserFilter!]
|
OR: [_UserFilter!]
|
||||||
|
name_contains: String
|
||||||
|
about_contains: String
|
||||||
|
slug_contains: String
|
||||||
id: ID
|
id: ID
|
||||||
id_not: ID
|
id_not: ID
|
||||||
id_in: [ID!]
|
id_in: [ID!]
|
||||||
|
|||||||
@ -1,28 +1,15 @@
|
|||||||
import uuid from 'uuid/v4'
|
export default function create() {
|
||||||
|
|
||||||
export default function(params) {
|
|
||||||
const {
|
|
||||||
id = uuid(),
|
|
||||||
key = '',
|
|
||||||
type = 'crowdfunding',
|
|
||||||
status = 'permanent',
|
|
||||||
icon = '/img/badges/indiegogo_en_panda.svg',
|
|
||||||
} = params
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutation: `
|
factory: async ({ args, neodeInstance }) => {
|
||||||
mutation(
|
const defaults = {
|
||||||
$id: ID
|
type: 'crowdfunding',
|
||||||
$key: String!
|
status: 'permanent',
|
||||||
$type: BadgeType!
|
}
|
||||||
$status: BadgeStatus!
|
args = {
|
||||||
$icon: String!
|
...defaults,
|
||||||
) {
|
...args,
|
||||||
CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) {
|
}
|
||||||
id
|
return neodeInstance.create('Badge', args)
|
||||||
}
|
},
|
||||||
}
|
|
||||||
`,
|
|
||||||
variables: { id, key, type, status, icon },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,6 +73,7 @@ export default function Factory(options = {}) {
|
|||||||
const { factory, mutation, variables } = this.factories[node](args)
|
const { factory, mutation, variables } = this.factories[node](args)
|
||||||
if (factory) {
|
if (factory) {
|
||||||
this.lastResponse = await factory({ args, neodeInstance })
|
this.lastResponse = await factory({ args, neodeInstance })
|
||||||
|
return this.lastResponse
|
||||||
} else {
|
} else {
|
||||||
this.lastResponse = await this.graphQLClient.request(mutation, variables)
|
this.lastResponse = await this.graphQLClient.request(mutation, variables)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import uuid from 'uuid/v4'
|
|||||||
import encryptPassword from '../../helpers/encryptPassword'
|
import encryptPassword from '../../helpers/encryptPassword'
|
||||||
import slugify from 'slug'
|
import slugify from 'slug'
|
||||||
|
|
||||||
export default function create(params) {
|
export default function create() {
|
||||||
return {
|
return {
|
||||||
factory: async ({ args, neodeInstance }) => {
|
factory: async ({ args, neodeInstance }) => {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
@ -22,7 +22,10 @@ export default function create(params) {
|
|||||||
}
|
}
|
||||||
args = await encryptPassword(args)
|
args = await encryptPassword(args)
|
||||||
const user = await neodeInstance.create('User', 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() {
|
;(async function() {
|
||||||
try {
|
try {
|
||||||
const f = Factory()
|
const f = Factory()
|
||||||
await Promise.all([
|
const [racoon, rabbit, wolf, bear, turtle, rhino] = await Promise.all([
|
||||||
f.create('Badge', {
|
f.create('Badge', {
|
||||||
id: 'b1',
|
id: 'indiegogo_en_racoon',
|
||||||
key: 'indiegogo_en_racoon',
|
|
||||||
type: 'crowdfunding',
|
|
||||||
status: 'permanent',
|
|
||||||
icon: '/img/badges/indiegogo_en_racoon.svg',
|
icon: '/img/badges/indiegogo_en_racoon.svg',
|
||||||
}),
|
}),
|
||||||
f.create('Badge', {
|
f.create('Badge', {
|
||||||
id: 'b2',
|
id: 'indiegogo_en_rabbit',
|
||||||
key: 'indiegogo_en_rabbit',
|
|
||||||
type: 'crowdfunding',
|
|
||||||
status: 'permanent',
|
|
||||||
icon: '/img/badges/indiegogo_en_rabbit.svg',
|
icon: '/img/badges/indiegogo_en_rabbit.svg',
|
||||||
}),
|
}),
|
||||||
f.create('Badge', {
|
f.create('Badge', {
|
||||||
id: 'b3',
|
id: 'indiegogo_en_wolf',
|
||||||
key: 'indiegogo_en_wolf',
|
|
||||||
type: 'crowdfunding',
|
|
||||||
status: 'permanent',
|
|
||||||
icon: '/img/badges/indiegogo_en_wolf.svg',
|
icon: '/img/badges/indiegogo_en_wolf.svg',
|
||||||
}),
|
}),
|
||||||
f.create('Badge', {
|
f.create('Badge', {
|
||||||
id: 'b4',
|
id: 'indiegogo_en_bear',
|
||||||
key: 'indiegogo_en_bear',
|
|
||||||
type: 'crowdfunding',
|
|
||||||
status: 'permanent',
|
|
||||||
icon: '/img/badges/indiegogo_en_bear.svg',
|
icon: '/img/badges/indiegogo_en_bear.svg',
|
||||||
}),
|
}),
|
||||||
f.create('Badge', {
|
f.create('Badge', {
|
||||||
id: 'b5',
|
id: 'indiegogo_en_turtle',
|
||||||
key: 'indiegogo_en_turtle',
|
|
||||||
type: 'crowdfunding',
|
|
||||||
status: 'permanent',
|
|
||||||
icon: '/img/badges/indiegogo_en_turtle.svg',
|
icon: '/img/badges/indiegogo_en_turtle.svg',
|
||||||
}),
|
}),
|
||||||
f.create('Badge', {
|
f.create('Badge', {
|
||||||
id: 'b6',
|
id: 'indiegogo_en_rhino',
|
||||||
key: 'indiegogo_en_rhino',
|
|
||||||
type: 'crowdfunding',
|
|
||||||
status: 'permanent',
|
|
||||||
icon: '/img/badges/indiegogo_en_rhino.svg',
|
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', {
|
f.create('User', {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
name: 'Peter Lustig',
|
name: 'Peter Lustig',
|
||||||
@ -69,47 +59,130 @@ import Factory from './factories'
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
email: 'user@example.org',
|
email: 'user@example.org',
|
||||||
}),
|
}),
|
||||||
f.create('User', { id: 'u4', name: 'Tick', role: 'user', email: 'tick@example.org' }),
|
f.create('User', {
|
||||||
f.create('User', { id: 'u5', name: 'Trick', role: 'user', email: 'trick@example.org' }),
|
id: 'u4',
|
||||||
f.create('User', { id: 'u6', name: 'Track', role: 'user', email: 'track@example.org' }),
|
name: 'Tick',
|
||||||
f.create('User', { id: 'u7', name: 'Dagobert', role: 'user', email: 'dagobert@example.org' }),
|
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([
|
const [asAdmin, asModerator, asUser, asTick, asTrick, asTrack] = await Promise.all([
|
||||||
Factory().authenticateAs({ email: 'admin@example.org', password: '1234' }),
|
Factory().authenticateAs({
|
||||||
Factory().authenticateAs({ email: 'moderator@example.org', password: '1234' }),
|
email: 'admin@example.org',
|
||||||
Factory().authenticateAs({ email: 'user@example.org', password: '1234' }),
|
password: '1234',
|
||||||
Factory().authenticateAs({ email: 'tick@example.org', password: '1234' }),
|
}),
|
||||||
Factory().authenticateAs({ email: 'trick@example.org', password: '1234' }),
|
Factory().authenticateAs({
|
||||||
Factory().authenticateAs({ email: 'track@example.org', password: '1234' }),
|
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([
|
await Promise.all([
|
||||||
f.relate('User', 'Badges', { from: 'b6', to: 'u1' }),
|
peterLustig.relateTo(racoon, 'rewarded'),
|
||||||
f.relate('User', 'Badges', { from: 'b5', to: 'u2' }),
|
peterLustig.relateTo(rhino, 'rewarded'),
|
||||||
f.relate('User', 'Badges', { from: 'b4', to: 'u3' }),
|
peterLustig.relateTo(wolf, 'rewarded'),
|
||||||
f.relate('User', 'Badges', { from: 'b3', to: 'u4' }),
|
bobDerBaumeister.relateTo(racoon, 'rewarded'),
|
||||||
f.relate('User', 'Badges', { from: 'b2', to: 'u5' }),
|
bobDerBaumeister.relateTo(turtle, 'rewarded'),
|
||||||
f.relate('User', 'Badges', { from: 'b1', to: 'u6' }),
|
jennyRostock.relateTo(bear, 'rewarded'),
|
||||||
f.relate('User', 'Friends', { from: 'u1', to: 'u2' }),
|
dagobert.relateTo(rabbit, 'rewarded'),
|
||||||
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([
|
await Promise.all([
|
||||||
asAdmin.follow({ id: 'u3', type: 'User' }),
|
f.relate('User', 'Friends', {
|
||||||
asModerator.follow({ id: 'u4', type: 'User' }),
|
from: 'u1',
|
||||||
asUser.follow({ id: 'u4', type: 'User' }),
|
to: 'u2',
|
||||||
asTick.follow({ id: 'u6', type: 'User' }),
|
}),
|
||||||
asTrick.follow({ id: 'u4', type: 'User' }),
|
f.relate('User', 'Friends', {
|
||||||
asTrack.follow({ id: 'u3', type: 'User' }),
|
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([
|
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', {
|
f.create('Category', {
|
||||||
id: 'cat2',
|
id: 'cat2',
|
||||||
name: 'Happyness & Values',
|
name: 'Happyness & Values',
|
||||||
@ -203,10 +276,22 @@ import Factory from './factories'
|
|||||||
])
|
])
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
f.create('Tag', { id: 't1', name: 'Umwelt' }),
|
f.create('Tag', {
|
||||||
f.create('Tag', { id: 't2', name: 'Naturschutz' }),
|
id: 'Umwelt',
|
||||||
f.create('Tag', { id: 't3', name: 'Demokratie' }),
|
name: 'Umwelt',
|
||||||
f.create('Tag', { id: 't4', name: 'Freiheit' }),
|
}),
|
||||||
|
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?'
|
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!'
|
'Hey <a class="mention" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!'
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
asAdmin.create('Post', { id: 'p0', image: faker.image.unsplash.food() }),
|
asAdmin.create('Post', {
|
||||||
asModerator.create('Post', { id: 'p1', image: faker.image.unsplash.technology() }),
|
id: 'p0',
|
||||||
asUser.create('Post', { id: 'p2' }),
|
image: faker.image.unsplash.food(),
|
||||||
asTick.create('Post', { id: 'p3' }),
|
}),
|
||||||
asTrick.create('Post', { id: 'p4' }),
|
asModerator.create('Post', {
|
||||||
asTrack.create('Post', { id: 'p5' }),
|
id: 'p1',
|
||||||
asAdmin.create('Post', { id: 'p6', image: faker.image.unsplash.buildings() }),
|
image: faker.image.unsplash.technology(),
|
||||||
asModerator.create('Post', { id: 'p7', content: `${mention1} ${faker.lorem.paragraph()}` }),
|
}),
|
||||||
asUser.create('Post', { id: 'p8', image: faker.image.unsplash.nature() }),
|
asUser.create('Post', {
|
||||||
asTick.create('Post', { id: 'p9' }),
|
id: 'p2',
|
||||||
asTrick.create('Post', { id: 'p10' }),
|
}),
|
||||||
asTrack.create('Post', { id: 'p11', image: faker.image.unsplash.people() }),
|
asTick.create('Post', {
|
||||||
asAdmin.create('Post', { id: 'p12', content: `${mention2} ${faker.lorem.paragraph()}` }),
|
id: 'p3',
|
||||||
asModerator.create('Post', { id: 'p13' }),
|
}),
|
||||||
asUser.create('Post', { id: 'p14', image: faker.image.unsplash.objects() }),
|
asTrick.create('Post', {
|
||||||
asTick.create('Post', { id: 'p15' }),
|
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([
|
await Promise.all([
|
||||||
f.relate('Post', 'Categories', { from: 'p0', to: 'cat16' }),
|
f.relate('Post', 'Categories', {
|
||||||
f.relate('Post', 'Categories', { from: 'p1', to: 'cat1' }),
|
from: 'p0',
|
||||||
f.relate('Post', 'Categories', { from: 'p2', to: 'cat2' }),
|
to: 'cat16',
|
||||||
f.relate('Post', 'Categories', { from: 'p3', to: 'cat3' }),
|
}),
|
||||||
f.relate('Post', 'Categories', { from: 'p4', to: 'cat4' }),
|
f.relate('Post', 'Categories', {
|
||||||
f.relate('Post', 'Categories', { from: 'p5', to: 'cat5' }),
|
from: 'p1',
|
||||||
f.relate('Post', 'Categories', { from: 'p6', to: 'cat6' }),
|
to: 'cat1',
|
||||||
f.relate('Post', 'Categories', { from: 'p7', to: 'cat7' }),
|
}),
|
||||||
f.relate('Post', 'Categories', { from: 'p8', to: 'cat8' }),
|
f.relate('Post', 'Categories', {
|
||||||
f.relate('Post', 'Categories', { from: 'p9', to: 'cat9' }),
|
from: 'p2',
|
||||||
f.relate('Post', 'Categories', { from: 'p10', to: 'cat10' }),
|
to: 'cat2',
|
||||||
f.relate('Post', 'Categories', { from: 'p11', to: 'cat11' }),
|
}),
|
||||||
f.relate('Post', 'Categories', { from: 'p12', to: 'cat12' }),
|
f.relate('Post', 'Categories', {
|
||||||
f.relate('Post', 'Categories', { from: 'p13', to: 'cat13' }),
|
from: 'p3',
|
||||||
f.relate('Post', 'Categories', { from: 'p14', to: 'cat14' }),
|
to: 'cat3',
|
||||||
f.relate('Post', 'Categories', { from: 'p15', to: 'cat15' }),
|
}),
|
||||||
|
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', {
|
||||||
f.relate('Post', 'Tags', { from: 'p1', to: 't1' }),
|
from: 'p0',
|
||||||
f.relate('Post', 'Tags', { from: 'p2', to: 't2' }),
|
to: 'Freiheit',
|
||||||
f.relate('Post', 'Tags', { from: 'p3', to: 't3' }),
|
}),
|
||||||
f.relate('Post', 'Tags', { from: 'p4', to: 't4' }),
|
f.relate('Post', 'Tags', {
|
||||||
f.relate('Post', 'Tags', { from: 'p5', to: 't1' }),
|
from: 'p1',
|
||||||
f.relate('Post', 'Tags', { from: 'p6', to: 't2' }),
|
to: 'Umwelt',
|
||||||
f.relate('Post', 'Tags', { from: 'p7', to: 't3' }),
|
}),
|
||||||
f.relate('Post', 'Tags', { from: 'p8', to: 't4' }),
|
f.relate('Post', 'Tags', {
|
||||||
f.relate('Post', 'Tags', { from: 'p9', to: 't1' }),
|
from: 'p2',
|
||||||
f.relate('Post', 'Tags', { from: 'p10', to: 't2' }),
|
to: 'Naturschutz',
|
||||||
f.relate('Post', 'Tags', { from: 'p11', to: 't3' }),
|
}),
|
||||||
f.relate('Post', 'Tags', { from: 'p12', to: 't4' }),
|
f.relate('Post', 'Tags', {
|
||||||
f.relate('Post', 'Tags', { from: 'p13', to: 't1' }),
|
from: 'p3',
|
||||||
f.relate('Post', 'Tags', { from: 'p14', to: 't2' }),
|
to: 'Demokratie',
|
||||||
f.relate('Post', 'Tags', { from: 'p15', to: 't3' }),
|
}),
|
||||||
|
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([
|
await Promise.all([
|
||||||
asAdmin.shout({ id: 'p2', type: 'Post' }),
|
asAdmin.shout({
|
||||||
asAdmin.shout({ id: 'p6', type: 'Post' }),
|
id: 'p2',
|
||||||
asModerator.shout({ id: 'p0', type: 'Post' }),
|
type: 'Post',
|
||||||
asModerator.shout({ id: 'p6', type: 'Post' }),
|
}),
|
||||||
asUser.shout({ id: 'p6', type: 'Post' }),
|
asAdmin.shout({
|
||||||
asUser.shout({ id: 'p7', type: 'Post' }),
|
id: 'p6',
|
||||||
asTick.shout({ id: 'p8', type: 'Post' }),
|
type: 'Post',
|
||||||
asTick.shout({ id: 'p9', type: 'Post' }),
|
}),
|
||||||
asTrack.shout({ id: 'p10', 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([
|
await Promise.all([
|
||||||
asAdmin.shout({ id: 'p2', type: 'Post' }),
|
asAdmin.shout({
|
||||||
asAdmin.shout({ id: 'p6', type: 'Post' }),
|
id: 'p2',
|
||||||
asModerator.shout({ id: 'p0', type: 'Post' }),
|
type: 'Post',
|
||||||
asModerator.shout({ id: 'p6', type: 'Post' }),
|
}),
|
||||||
asUser.shout({ id: 'p6', type: 'Post' }),
|
asAdmin.shout({
|
||||||
asUser.shout({ id: 'p7', type: 'Post' }),
|
id: 'p6',
|
||||||
asTick.shout({ id: 'p8', type: 'Post' }),
|
type: 'Post',
|
||||||
asTick.shout({ id: 'p9', type: 'Post' }),
|
}),
|
||||||
asTrack.shout({ id: 'p10', 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([
|
await Promise.all([
|
||||||
asUser.create('Comment', { id: 'c1', postId: 'p1' }),
|
asUser.create('Comment', {
|
||||||
asTick.create('Comment', { id: 'c2', postId: 'p1' }),
|
id: 'c1',
|
||||||
asTrack.create('Comment', { id: 'c3', postId: 'p3' }),
|
postId: 'p1',
|
||||||
asTrick.create('Comment', { id: 'c4', postId: 'p2' }),
|
}),
|
||||||
asModerator.create('Comment', { id: 'c5', postId: 'p3' }),
|
asTick.create('Comment', {
|
||||||
asAdmin.create('Comment', { id: 'c6', postId: 'p4' }),
|
id: 'c2',
|
||||||
asUser.create('Comment', { id: 'c7', postId: 'p2' }),
|
postId: 'p1',
|
||||||
asTick.create('Comment', { id: 'c8', postId: 'p15' }),
|
}),
|
||||||
asTrick.create('Comment', { id: 'c9', postId: 'p15' }),
|
asTrack.create('Comment', {
|
||||||
asTrack.create('Comment', { id: 'c10', postId: 'p15' }),
|
id: 'c3',
|
||||||
asUser.create('Comment', { id: 'c11', postId: 'p15' }),
|
postId: 'p3',
|
||||||
asUser.create('Comment', { id: 'c12', postId: 'p15' }),
|
}),
|
||||||
|
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) }'
|
const disableMutation = 'mutation($id: ID!) { disable(id: $id) }'
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
asModerator.mutate(disableMutation, { id: 'p11' }),
|
asModerator.mutate(disableMutation, {
|
||||||
asModerator.mutate(disableMutation, { id: 'c5' }),
|
id: 'p11',
|
||||||
|
}),
|
||||||
|
asModerator.mutate(disableMutation, {
|
||||||
|
id: 'c5',
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
asTick.create('Report', { description: "I don't like this comment", id: 'c1' }),
|
asTick.create('Report', {
|
||||||
asTrick.create('Report', { description: "I don't like this post", id: 'p1' }),
|
description: "I don't like this comment",
|
||||||
asTrack.create('Report', { description: "I don't like this user", id: 'u1' }),
|
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([
|
await Promise.all([
|
||||||
@ -342,11 +666,30 @@ import Factory from './factories'
|
|||||||
])
|
])
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
f.relate('Organization', 'CreatedBy', { from: 'u1', to: 'o1' }),
|
f.relate('Organization', 'CreatedBy', {
|
||||||
f.relate('Organization', 'CreatedBy', { from: 'u1', to: 'o2' }),
|
from: 'u1',
|
||||||
f.relate('Organization', 'OwnedBy', { from: 'u2', to: 'o2' }),
|
to: 'o1',
|
||||||
f.relate('Organization', 'OwnedBy', { from: 'u2', to: 'o3' }),
|
}),
|
||||||
|
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 */
|
/* eslint-disable-next-line no-console */
|
||||||
console.log('Seeded Data...')
|
console.log('Seeded Data...')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import helmet from 'helmet'
|
import helmet from 'helmet'
|
||||||
import { GraphQLServer } from 'graphql-yoga'
|
import { ApolloServer } from 'apollo-server-express'
|
||||||
import CONFIG, { requiredConfigs } from './config'
|
import CONFIG, { requiredConfigs } from './config'
|
||||||
import mocks from './mocks'
|
import mocks from './mocks'
|
||||||
import middleware from './middleware'
|
import middleware from './middleware'
|
||||||
@ -20,28 +20,30 @@ const driver = getDriver()
|
|||||||
|
|
||||||
const createServer = options => {
|
const createServer = options => {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
context: async ({ request }) => {
|
context: async ({ req }) => {
|
||||||
const user = await decode(driver, request.headers.authorization)
|
const user = await decode(driver, req.headers.authorization)
|
||||||
return {
|
return {
|
||||||
driver,
|
driver,
|
||||||
user,
|
user,
|
||||||
req: request,
|
req,
|
||||||
cypherParams: {
|
cypherParams: {
|
||||||
currentUserId: user ? user.id : null,
|
currentUserId: user ? user.id : null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
schema,
|
schema: middleware(schema),
|
||||||
debug: CONFIG.DEBUG,
|
debug: CONFIG.DEBUG,
|
||||||
tracing: CONFIG.DEBUG,
|
tracing: CONFIG.DEBUG,
|
||||||
middlewares: middleware(schema),
|
|
||||||
mocks: CONFIG.MOCKS ? mocks : false,
|
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())
|
const app = express()
|
||||||
server.express.use(express.static('public'))
|
app.use(helmet())
|
||||||
return server
|
app.use(express.static('public'))
|
||||||
|
server.applyMiddleware({ app, path: '/' })
|
||||||
|
|
||||||
|
return { server, app }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createServer
|
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
@ -1,36 +1,36 @@
|
|||||||
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
|
import { When, Then } from "cypress-cucumber-preprocessor/steps";
|
||||||
|
|
||||||
/* global cy */
|
/* global cy */
|
||||||
|
|
||||||
When('I visit my profile page', () => {
|
When("I visit my profile page", () => {
|
||||||
cy.openPage('profile/peter-pan')
|
cy.openPage("profile/peter-pan");
|
||||||
})
|
});
|
||||||
|
|
||||||
Then('I should be able to change my profile picture', () => {
|
Then("I should be able to change my profile picture", () => {
|
||||||
const avatarUpload = 'onourjourney.png'
|
const avatarUpload = "onourjourney.png";
|
||||||
|
|
||||||
cy.fixture(avatarUpload, 'base64').then(fileContent => {
|
cy.fixture(avatarUpload, "base64").then(fileContent => {
|
||||||
cy.get('#customdropzone').upload(
|
cy.get("#customdropzone").upload(
|
||||||
{ fileContent, fileName: avatarUpload, mimeType: 'image/png' },
|
{ fileContent, fileName: avatarUpload, mimeType: "image/png" },
|
||||||
{ subjectType: 'drag-n-drop' }
|
{ subjectType: "drag-n-drop", force: true }
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
cy.get('.profile-avatar img')
|
cy.get(".profile-avatar img")
|
||||||
.should('have.attr', 'src')
|
.should("have.attr", "src")
|
||||||
.and('contains', 'onourjourney')
|
.and("contains", "onourjourney");
|
||||||
cy.contains('.iziToast-message', 'Upload successful').should(
|
cy.contains(".iziToast-message", "Upload successful").should(
|
||||||
'have.length',
|
"have.length",
|
||||||
1
|
1
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
When("I visit another user's profile page", () => {
|
When("I visit another user's profile page", () => {
|
||||||
cy.openPage('profile/peter-pan')
|
cy.openPage("profile/peter-pan");
|
||||||
})
|
});
|
||||||
|
|
||||||
Then('I cannot upload a picture', () => {
|
Then("I cannot upload a picture", () => {
|
||||||
cy.get('.ds-card-content')
|
cy.get(".ds-card-content")
|
||||||
.children()
|
.children()
|
||||||
.should('not.have.id', 'customdropzone')
|
.should("not.have.id", "customdropzone")
|
||||||
.should('have.class', 'ds-avatar')
|
.should("have.class", "ds-avatar");
|
||||||
})
|
});
|
||||||
|
|||||||
@ -276,9 +276,9 @@ When("I fill the password form with:", table => {
|
|||||||
table = table.rowsHash();
|
table = table.rowsHash();
|
||||||
cy.get("input[id=oldPassword]")
|
cy.get("input[id=oldPassword]")
|
||||||
.type(table["Your old password"])
|
.type(table["Your old password"])
|
||||||
.get("input[id=newPassword]")
|
.get("input[id=password]")
|
||||||
.type(table["Your new passsword"])
|
.type(table["Your new passsword"])
|
||||||
.get("input[id=confirmPassword]")
|
.get("input[id=passwordConfirmation]")
|
||||||
.type(table["Confirm new password"]);
|
.type(table["Confirm new password"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -23,24 +23,27 @@ Cypress.Commands.add('factory', () => {
|
|||||||
Cypress.Commands.add(
|
Cypress.Commands.add(
|
||||||
'create',
|
'create',
|
||||||
{ prevSubject: true },
|
{ prevSubject: true },
|
||||||
(factory, node, properties) => {
|
async (factory, node, properties) => {
|
||||||
return factory.create(node, properties)
|
await factory.create(node, properties)
|
||||||
|
return factory
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Cypress.Commands.add(
|
Cypress.Commands.add(
|
||||||
'relate',
|
'relate',
|
||||||
{ prevSubject: true },
|
{ prevSubject: true },
|
||||||
(factory, node, relationship, properties) => {
|
async (factory, node, relationship, properties) => {
|
||||||
return factory.relate(node, relationship, properties)
|
await factory.relate(node, relationship, properties)
|
||||||
|
return factory
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Cypress.Commands.add(
|
Cypress.Commands.add(
|
||||||
'mutate',
|
'mutate',
|
||||||
{ prevSubject: true },
|
{ prevSubject: true },
|
||||||
(factory, mutation, variables) => {
|
async (factory, mutation, variables) => {
|
||||||
return factory.mutate(mutation, variables)
|
await factory.mutate(mutation, variables)
|
||||||
|
return factory
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -4,14 +4,10 @@
|
|||||||
data:
|
data:
|
||||||
SMTP_HOST: "mailserver.human-connection"
|
SMTP_HOST: "mailserver.human-connection"
|
||||||
SMTP_PORT: "25"
|
SMTP_PORT: "25"
|
||||||
SMTP_USERNAME: ""
|
|
||||||
SMTP_PASSWORD: ""
|
|
||||||
GRAPHQL_PORT: "4000"
|
GRAPHQL_PORT: "4000"
|
||||||
GRAPHQL_URI: "http://nitro-backend.human-connection:4000"
|
GRAPHQL_URI: "http://nitro-backend.human-connection:4000"
|
||||||
MOCKS: "false"
|
MOCKS: "false"
|
||||||
NEO4J_URI: "bolt://nitro-neo4j.human-connection:7687"
|
NEO4J_URI: "bolt://nitro-neo4j.human-connection:7687"
|
||||||
NEO4J_USERNAME: "neo4j"
|
|
||||||
NEO4J_PASSWORD: "neo4j"
|
|
||||||
NEO4J_AUTH: "none"
|
NEO4J_AUTH: "none"
|
||||||
CLIENT_URI: "https://nitro-staging.human-connection.org"
|
CLIENT_URI: "https://nitro-staging.human-connection.org"
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@ -5,11 +5,10 @@ data:
|
|||||||
MONGODB_PASSWORD: "TU9OR09EQl9QQVNTV09SRA=="
|
MONGODB_PASSWORD: "TU9OR09EQl9QQVNTV09SRA=="
|
||||||
PRIVATE_KEY_PASSPHRASE: "YTdkc2Y3OHNhZGc4N2FkODdzZmFnc2FkZzc4"
|
PRIVATE_KEY_PASSPHRASE: "YTdkc2Y3OHNhZGc4N2FkODdzZmFnc2FkZzc4"
|
||||||
MAPBOX_TOKEN: "cGsuZXlKMUlqb2lhSFZ0WVc0dFkyOXVibVZqZEdsdmJpSXNJbUVpT2lKamFqbDBjbkJ1Ykdvd2VUVmxNM1Z3WjJsek5UTnVkM1p0SW4wLktaOEtLOWw3MG9talhiRWtrYkhHc1EK"
|
MAPBOX_TOKEN: "cGsuZXlKMUlqb2lhSFZ0WVc0dFkyOXVibVZqZEdsdmJpSXNJbUVpT2lKamFqbDBjbkJ1Ykdvd2VUVmxNM1Z3WjJsek5UTnVkM1p0SW4wLktaOEtLOWw3MG9talhiRWtrYkhHc1EK"
|
||||||
SMTP_HOST:
|
|
||||||
SMTP_PORT: 587
|
|
||||||
SMTP_USERNAME:
|
SMTP_USERNAME:
|
||||||
SMTP_PASSWORD:
|
SMTP_PASSWORD:
|
||||||
SMTP_IGNORE_TLS:
|
NEO4J_USERNAME:
|
||||||
|
NEO4J_PASSWORD:
|
||||||
metadata:
|
metadata:
|
||||||
name: human-connection
|
name: human-connection
|
||||||
namespace: human-connection
|
namespace: human-connection
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
[?] type: String, // in nitro this is a defined enum - seems good for now
|
[?] type: String, // in nitro this is a defined enum - seems good for now
|
||||||
[X] required: true
|
[X] required: true
|
||||||
},
|
},
|
||||||
[X] key: {
|
[X] id: {
|
||||||
[X] type: String,
|
[X] type: String,
|
||||||
[X] required: true
|
[X] required: true
|
||||||
},
|
},
|
||||||
@ -43,7 +43,7 @@
|
|||||||
CALL apoc.load.json("file:${IMPORT_CHUNK_PATH_CQL_FILE}") YIELD value as badge
|
CALL apoc.load.json("file:${IMPORT_CHUNK_PATH_CQL_FILE}") YIELD value as badge
|
||||||
MERGE(b:Badge {id: badge._id["$oid"]})
|
MERGE(b:Badge {id: badge._id["$oid"]})
|
||||||
ON CREATE SET
|
ON CREATE SET
|
||||||
b.key = badge.key,
|
b.id = badge.key,
|
||||||
b.type = badge.type,
|
b.type = badge.type,
|
||||||
b.icon = replace(badge.image.path, 'https://api-alpha.human-connection.org', ''),
|
b.icon = replace(badge.image.path, 'https://api-alpha.human-connection.org', ''),
|
||||||
b.status = badge.status,
|
b.status = badge.status,
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
MATCH (u:User)-[e:EMOTED]->(c:Post) DETACH DELETE e;
|
||||||
@ -5,31 +5,54 @@
|
|||||||
// [-] Omitted in Nitro
|
// [-] Omitted in Nitro
|
||||||
// [?] Unclear / has work to be done for Nitro
|
// [?] Unclear / has work to be done for Nitro
|
||||||
{
|
{
|
||||||
[ ] userId: {
|
[X] userId: {
|
||||||
[ ] type: String,
|
[X] type: String,
|
||||||
[ ] required: true,
|
[X] required: true,
|
||||||
[-] index: true
|
[-] index: true
|
||||||
},
|
},
|
||||||
[ ] contributionId: {
|
[X] contributionId: {
|
||||||
[ ] type: String,
|
[X] type: String,
|
||||||
[ ] required: true,
|
[X] required: true,
|
||||||
[-] index: true
|
[-] index: true
|
||||||
},
|
},
|
||||||
[ ] rated: {
|
[?] rated: {
|
||||||
[ ] type: String,
|
[X] type: String,
|
||||||
[ ] required: true,
|
[ ] required: true,
|
||||||
[ ] enum: ['funny', 'happy', 'surprised', 'cry', 'angry']
|
[?] enum: ['funny', 'happy', 'surprised', 'cry', 'angry']
|
||||||
},
|
},
|
||||||
[ ] createdAt: {
|
[X] createdAt: {
|
||||||
[ ] type: Date,
|
[X] type: Date,
|
||||||
[ ] default: Date.now
|
[X] default: Date.now
|
||||||
},
|
},
|
||||||
[ ] updatedAt: {
|
[X] updatedAt: {
|
||||||
[ ] type: Date,
|
[X] type: Date,
|
||||||
[ ] default: Date.now
|
[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 "contributions" "contributions_cando"
|
||||||
delete_collection "shouts" "shouts"
|
delete_collection "shouts" "shouts"
|
||||||
delete_collection "comments" "comments"
|
delete_collection "comments" "comments"
|
||||||
|
delete_collection "emotions" "emotions"
|
||||||
|
|
||||||
#delete_collection "emotions"
|
|
||||||
#delete_collection "invites"
|
#delete_collection "invites"
|
||||||
#delete_collection "notifications"
|
#delete_collection "notifications"
|
||||||
#delete_collection "organizations"
|
#delete_collection "organizations"
|
||||||
@ -82,12 +82,12 @@ import_collection "users" "users/users.cql"
|
|||||||
import_collection "follows_users" "follows/follows.cql"
|
import_collection "follows_users" "follows/follows.cql"
|
||||||
#import_collection "follows_organizations" "follows/follows.cql"
|
#import_collection "follows_organizations" "follows/follows.cql"
|
||||||
import_collection "contributions_post" "contributions/contributions.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 "contributions_DELETED" "contributions/contributions.cql"
|
||||||
import_collection "shouts" "shouts/shouts.cql"
|
import_collection "shouts" "shouts/shouts.cql"
|
||||||
import_collection "comments" "comments/comments.cql"
|
import_collection "comments" "comments/comments.cql"
|
||||||
|
import_collection "emotions" "emotions/emotions.cql"
|
||||||
|
|
||||||
# import_collection "emotions"
|
|
||||||
# import_collection "invites"
|
# import_collection "invites"
|
||||||
# import_collection "notifications"
|
# import_collection "notifications"
|
||||||
# import_collection "organizations"
|
# import_collection "organizations"
|
||||||
|
|||||||
@ -101,7 +101,7 @@ ON CREATE SET
|
|||||||
u.name = user.name,
|
u.name = user.name,
|
||||||
u.slug = user.slug,
|
u.slug = user.slug,
|
||||||
u.email = user.email,
|
u.email = user.email,
|
||||||
u.password = user.password,
|
u.encryptedPassword = user.password,
|
||||||
u.avatar = replace(user.avatar, 'https://api-alpha.human-connection.org', ''),
|
u.avatar = replace(user.avatar, 'https://api-alpha.human-connection.org', ''),
|
||||||
u.coverImg = replace(user.coverImg, 'https://api-alpha.human-connection.org', ''),
|
u.coverImg = replace(user.coverImg, 'https://api-alpha.human-connection.org', ''),
|
||||||
u.wasInvited = user.wasInvited,
|
u.wasInvited = user.wasInvited,
|
||||||
@ -111,6 +111,13 @@ u.createdAt = user.createdAt.`$date`,
|
|||||||
u.updatedAt = user.updatedAt.`$date`,
|
u.updatedAt = user.updatedAt.`$date`,
|
||||||
u.deleted = user.deletedAt IS NOT NULL,
|
u.deleted = user.deletedAt IS NOT NULL,
|
||||||
u.disabled = false
|
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
|
WITH u, user, user.badgeIds AS badgeIds
|
||||||
UNWIND badgeIds AS badgeId
|
UNWIND badgeIds AS badgeId
|
||||||
MATCH (b:Badge {id: badgeId})
|
MATCH (b:Badge {id: badgeId})
|
||||||
|
|||||||
@ -26,9 +26,4 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 4001:4001
|
- 4001:4001
|
||||||
- 4123:4123
|
- 4123:4123
|
||||||
neo4j:
|
|
||||||
environment:
|
|
||||||
- NEO4J_AUTH=none
|
|
||||||
ports:
|
|
||||||
- 7687:7687
|
|
||||||
- 7474:7474
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- hc-network
|
- hc-network
|
||||||
environment:
|
environment:
|
||||||
|
- NUXT_BUILD=.nuxt-dist
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- GRAPHQL_URI=http://backend:4000
|
- GRAPHQL_URI=http://backend:4000
|
||||||
- MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.bZ8KK9l70omjXbEkkbHGsQ"
|
- 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 (c:Category) ASSERT c.slug IS UNIQUE;
|
||||||
CREATE CONSTRAINT ON (u:User) ASSERT u.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 (o:Organization) ASSERT o.slug IS UNIQUE;
|
||||||
|
|
||||||
|
CREATE CONSTRAINT ON (e:EmailAddress) ASSERT e.email IS UNIQUE;
|
||||||
' | cypher-shell
|
' | cypher-shell
|
||||||
|
|
||||||
echo '
|
echo '
|
||||||
|
|||||||
@ -22,9 +22,9 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"codecov": "^3.5.0",
|
"codecov": "^3.5.0",
|
||||||
"cross-env": "^5.2.0",
|
"cross-env": "^5.2.0",
|
||||||
"cypress": "^3.3.2",
|
"cypress": "^3.4.0",
|
||||||
"cypress-cucumber-preprocessor": "^1.12.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",
|
"cypress-plugin-retries": "^1.2.2",
|
||||||
"dotenv": "^8.0.0",
|
"dotenv": "^8.0.0",
|
||||||
"faker": "Marak/faker.js#master",
|
"faker": "Marak/faker.js#master",
|
||||||
|
|||||||
2
webapp/.gitignore
vendored
2
webapp/.gitignore
vendored
@ -61,6 +61,8 @@ typings/
|
|||||||
|
|
||||||
# nuxt.js build output
|
# nuxt.js build output
|
||||||
.nuxt
|
.nuxt
|
||||||
|
# also the build output in docker container
|
||||||
|
.nuxt-dist
|
||||||
|
|
||||||
# Nuxt generate
|
# Nuxt generate
|
||||||
dist
|
dist
|
||||||
|
|||||||
@ -66,7 +66,8 @@ blockquote {
|
|||||||
border-left: 3px dotted $color-neutral-70;
|
border-left: 3px dotted $color-neutral-70;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '\201C'; /*Unicode for Left Double Quote*/
|
content: '\201C';
|
||||||
|
/*Unicode for Left Double Quote*/
|
||||||
|
|
||||||
/*Font*/
|
/*Font*/
|
||||||
font-size: $font-size-xxxx-large;
|
font-size: $font-size-xxxx-large;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="[badges.length === 2 && 'hc-badges-dual']" class="hc-badges">
|
<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" />
|
<img :title="badge.key" :src="badge.icon | proxyApiUrl" class="hc-badge" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import gql from 'graphql-tag'
|
import CategoryQuery from '~/graphql/CategoryQuery.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@ -85,13 +85,7 @@ export default {
|
|||||||
apollo: {
|
apollo: {
|
||||||
Category: {
|
Category: {
|
||||||
query() {
|
query() {
|
||||||
return gql(`{
|
return CategoryQuery()
|
||||||
Category {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
icon
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
},
|
},
|
||||||
result(result) {
|
result(result) {
|
||||||
this.categories = result.data.Category
|
this.categories = result.data.Category
|
||||||
|
|||||||
@ -23,11 +23,19 @@
|
|||||||
:modalsData="menuModalsData"
|
:modalsData="menuModalsData"
|
||||||
style="float-right"
|
style="float-right"
|
||||||
:is-owner="isAuthor(author.id)"
|
:is-owner="isAuthor(author.id)"
|
||||||
|
@showEditCommentMenu="editCommentMenu"
|
||||||
/>
|
/>
|
||||||
</no-ssr>
|
</no-ssr>
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
|
||||||
<!-- TODO: replace editor content with tiptap render view -->
|
|
||||||
|
|
||||||
|
<ds-space margin-bottom="small" />
|
||||||
|
<div v-if="openEditCommentMenu">
|
||||||
|
<hc-edit-comment-form
|
||||||
|
:comment="comment"
|
||||||
|
:post="post"
|
||||||
|
@showEditCommentMenu="editCommentMenu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-show="!openEditCommentMenu">
|
||||||
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
|
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
|
||||||
<div
|
<div
|
||||||
v-show="comment.content !== comment.contentExcerpt"
|
v-show="comment.content !== comment.contentExcerpt"
|
||||||
@ -43,29 +51,33 @@
|
|||||||
{{ $t('comment.show.less') }}
|
{{ $t('comment.show.less') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<ds-space margin-bottom="small" />
|
<ds-space margin-bottom="small" />
|
||||||
</ds-card>
|
</ds-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters, mapMutations } from 'vuex'
|
||||||
import HcUser from '~/components/User'
|
import HcUser from '~/components/User'
|
||||||
import ContentMenu from '~/components/ContentMenu'
|
import ContentMenu from '~/components/ContentMenu'
|
||||||
|
import HcEditCommentForm from '~/components/comments/EditCommentForm/EditCommentForm'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data: function() {
|
data: function() {
|
||||||
return {
|
return {
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
|
openEditCommentMenu: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
HcUser,
|
HcUser,
|
||||||
ContentMenu,
|
ContentMenu,
|
||||||
|
HcEditCommentForm,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
post: { type: Object, default: () => {} },
|
||||||
comment: {
|
comment: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default() {
|
default() {
|
||||||
@ -112,9 +124,16 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
...mapMutations({
|
||||||
|
setEditPending: 'editor/SET_EDIT_PENDING',
|
||||||
|
}),
|
||||||
isAuthor(id) {
|
isAuthor(id) {
|
||||||
return this.user.id === id
|
return this.user.id === id
|
||||||
},
|
},
|
||||||
|
editCommentMenu(showMenu) {
|
||||||
|
this.openEditCommentMenu = showMenu
|
||||||
|
this.setEditPending(showMenu)
|
||||||
|
},
|
||||||
async deleteCommentCallback() {
|
async deleteCommentCallback() {
|
||||||
try {
|
try {
|
||||||
var gqlMutation = gql`
|
var gqlMutation = gql`
|
||||||
|
|||||||
@ -76,14 +76,13 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.isOwner && this.resourceType === 'comment') {
|
if (this.isOwner && this.resourceType === 'comment') {
|
||||||
// routes.push({
|
routes.push({
|
||||||
// name: this.$t(`comment.menu.edit`),
|
name: this.$t(`comment.menu.edit`),
|
||||||
// callback: () => {
|
callback: () => {
|
||||||
// /* eslint-disable-next-line no-console */
|
this.$emit('showEditCommentMenu', true)
|
||||||
// console.log('EDIT COMMENT')
|
},
|
||||||
// },
|
icon: 'edit',
|
||||||
// icon: 'edit'
|
})
|
||||||
// })
|
|
||||||
routes.push({
|
routes.push({
|
||||||
name: this.$t(`comment.menu.delete`),
|
name: this.$t(`comment.menu.delete`),
|
||||||
callback: () => {
|
callback: () => {
|
||||||
|
|||||||
@ -47,7 +47,9 @@ describe('ContributionForm.vue', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.mockRejectedValue({ message: 'Not Authorised!' }),
|
.mockRejectedValue({
|
||||||
|
message: 'Not Authorised!',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
$toast: {
|
$toast: {
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
@ -74,12 +76,26 @@ describe('ContributionForm.vue', () => {
|
|||||||
getters,
|
getters,
|
||||||
})
|
})
|
||||||
const Wrapper = () => {
|
const Wrapper = () => {
|
||||||
return mount(ContributionForm, { mocks, localVue, store, propsData })
|
return mount(ContributionForm, {
|
||||||
|
mocks,
|
||||||
|
localVue,
|
||||||
|
store,
|
||||||
|
propsData,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = Wrapper()
|
wrapper = Wrapper()
|
||||||
wrapper.setData({ form: { languageOptions: [{ label: 'Deutsch', value: 'de' }] } })
|
wrapper.setData({
|
||||||
|
form: {
|
||||||
|
languageOptions: [
|
||||||
|
{
|
||||||
|
label: 'Deutsch',
|
||||||
|
value: 'de',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('CreatePost', () => {
|
describe('CreatePost', () => {
|
||||||
|
|||||||
@ -11,7 +11,12 @@
|
|||||||
</hc-teaser-image>
|
</hc-teaser-image>
|
||||||
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
|
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
|
||||||
<no-ssr>
|
<no-ssr>
|
||||||
<hc-editor :users="users" :value="form.content" @input="updateEditorContent" />
|
<hc-editor
|
||||||
|
:users="users"
|
||||||
|
:hashtags="hashtags"
|
||||||
|
:value="form.content"
|
||||||
|
@input="updateEditorContent"
|
||||||
|
/>
|
||||||
</no-ssr>
|
</no-ssr>
|
||||||
<ds-space margin-bottom="xxx-large" />
|
<ds-space margin-bottom="xxx-large" />
|
||||||
<hc-categories-select
|
<hc-categories-select
|
||||||
@ -32,18 +37,19 @@
|
|||||||
/>
|
/>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
</ds-flex>
|
</ds-flex>
|
||||||
|
<ds-space />
|
||||||
<div slot="footer" style="text-align: right">
|
<div slot="footer" style="text-align: right">
|
||||||
<ds-button
|
<ds-button
|
||||||
|
class="cancel-button"
|
||||||
:disabled="loading || disabled"
|
:disabled="loading || disabled"
|
||||||
ghost
|
ghost
|
||||||
class="cancel-button"
|
|
||||||
@click.prevent="$router.back()"
|
@click.prevent="$router.back()"
|
||||||
>
|
>
|
||||||
{{ $t('actions.cancel') }}
|
{{ $t('actions.cancel') }}
|
||||||
</ds-button>
|
</ds-button>
|
||||||
<ds-button
|
<ds-button
|
||||||
icon="check"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
|
icon="check"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disabled="disabled || errors"
|
:disabled="disabled || errors"
|
||||||
primary
|
primary
|
||||||
@ -59,7 +65,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import HcEditor from '~/components/Editor'
|
import HcEditor from '~/components/Editor/Editor'
|
||||||
import orderBy from 'lodash/orderBy'
|
import orderBy from 'lodash/orderBy'
|
||||||
import locales from '~/locales'
|
import locales from '~/locales'
|
||||||
import PostMutations from '~/graphql/PostMutations.js'
|
import PostMutations from '~/graphql/PostMutations.js'
|
||||||
@ -95,6 +101,7 @@ export default {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
slug: null,
|
slug: null,
|
||||||
users: [],
|
users: [],
|
||||||
|
hashtags: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -193,17 +200,34 @@ export default {
|
|||||||
apollo: {
|
apollo: {
|
||||||
User: {
|
User: {
|
||||||
query() {
|
query() {
|
||||||
return gql(`{
|
return gql`
|
||||||
|
{
|
||||||
User(orderBy: slug_asc) {
|
User(orderBy: slug_asc) {
|
||||||
id
|
id
|
||||||
slug
|
slug
|
||||||
}
|
}
|
||||||
}`)
|
}
|
||||||
|
`
|
||||||
},
|
},
|
||||||
result(result) {
|
result(result) {
|
||||||
this.users = result.data.User
|
this.users = result.data.User
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Tag: {
|
||||||
|
query() {
|
||||||
|
return gql`
|
||||||
|
{
|
||||||
|
Tag(orderBy: name_asc) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
},
|
||||||
|
result(result) {
|
||||||
|
this.hashtags = result.data.Tag
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { mount, createLocalVue } from '@vue/test-utils'
|
import { mount, createLocalVue } from '@vue/test-utils'
|
||||||
import Editor from './'
|
import Editor from './Editor'
|
||||||
import Vuex from 'vuex'
|
import Vuex from 'vuex'
|
||||||
|
|
||||||
import Styleguide from '@human-connection/styleguide'
|
import Styleguide from '@human-connection/styleguide'
|
||||||
@ -36,7 +36,9 @@ describe('Editor.vue', () => {
|
|||||||
propsData,
|
propsData,
|
||||||
localVue,
|
localVue,
|
||||||
sync: false,
|
sync: false,
|
||||||
stubs: { transition: false },
|
stubs: {
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
store,
|
store,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -1,18 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="editor">
|
<div class="editor">
|
||||||
|
<!-- Mention and Hashtag Suggestions Menu -->
|
||||||
<div v-show="showSuggestions" ref="suggestions" class="suggestion-list">
|
<div v-show="showSuggestions" ref="suggestions" class="suggestion-list">
|
||||||
|
<!-- "filteredItems" array is not empty -->
|
||||||
<template v-if="hasResults">
|
<template v-if="hasResults">
|
||||||
<div
|
<div
|
||||||
v-for="(user, index) in filteredUsers"
|
v-for="(item, index) in filteredItems"
|
||||||
:key="user.id"
|
:key="item.id"
|
||||||
class="suggestion-list__item"
|
class="suggestion-list__item"
|
||||||
:class="{ 'is-selected': navigatedUserIndex === index }"
|
:class="{ 'is-selected': navigatedItemIndex === index }"
|
||||||
@click="selectUser(user)"
|
@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>
|
</div>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
|
|
||||||
<editor-menu-bubble :editor="editor">
|
<editor-menu-bubble :editor="editor">
|
||||||
@ -173,6 +206,7 @@ import {
|
|||||||
History,
|
History,
|
||||||
} from 'tiptap-extensions'
|
} from 'tiptap-extensions'
|
||||||
import Mention from './nodes/Mention.js'
|
import Mention from './nodes/Mention.js'
|
||||||
|
import Hashtag from './nodes/Hashtag.js'
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
let throttleInputEvent
|
let throttleInputEvent
|
||||||
@ -185,6 +219,7 @@ export default {
|
|||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
users: { type: Array, default: () => [] },
|
users: { type: Array, default: () => [] },
|
||||||
|
hashtags: { type: Array, default: () => [] },
|
||||||
value: { type: String, default: '' },
|
value: { type: String, default: '' },
|
||||||
doc: { type: Object, default: () => {} },
|
doc: { type: Object, default: () => {} },
|
||||||
},
|
},
|
||||||
@ -215,34 +250,40 @@ export default {
|
|||||||
}),
|
}),
|
||||||
new History(),
|
new History(),
|
||||||
new Mention({
|
new Mention({
|
||||||
|
// a list of all suggested items
|
||||||
items: () => {
|
items: () => {
|
||||||
return this.users
|
return this.users
|
||||||
},
|
},
|
||||||
|
// is called when a suggestion starts
|
||||||
onEnter: ({ items, query, range, command, virtualNode }) => {
|
onEnter: ({ items, query, range, command, virtualNode }) => {
|
||||||
|
this.suggestionType = this.mentionSuggestionType
|
||||||
|
|
||||||
this.query = query
|
this.query = query
|
||||||
this.filteredUsers = items
|
this.filteredItems = items
|
||||||
this.suggestionRange = range
|
this.suggestionRange = range
|
||||||
this.renderPopup(virtualNode)
|
this.renderPopup(virtualNode)
|
||||||
// we save the command for inserting a selected mention
|
// we save the command for inserting a selected mention
|
||||||
// this allows us to call it inside of our custom popup
|
// this allows us to call it inside of our custom popup
|
||||||
// via keyboard navigation and on click
|
// via keyboard navigation and on click
|
||||||
this.insertMention = command
|
this.insertMentionOrHashtag = command
|
||||||
},
|
},
|
||||||
// is called when a suggestion has changed
|
// is called when a suggestion has changed
|
||||||
onChange: ({ items, query, range, virtualNode }) => {
|
onChange: ({ items, query, range, virtualNode }) => {
|
||||||
this.query = query
|
this.query = query
|
||||||
this.filteredUsers = items
|
this.filteredItems = items
|
||||||
this.suggestionRange = range
|
this.suggestionRange = range
|
||||||
this.navigatedUserIndex = 0
|
this.navigatedItemIndex = 0
|
||||||
this.renderPopup(virtualNode)
|
this.renderPopup(virtualNode)
|
||||||
},
|
},
|
||||||
// is called when a suggestion is cancelled
|
// is called when a suggestion is cancelled
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
|
this.suggestionType = this.nullSuggestionType
|
||||||
|
|
||||||
// reset all saved values
|
// reset all saved values
|
||||||
this.query = null
|
this.query = null
|
||||||
this.filteredUsers = []
|
this.filteredItems = []
|
||||||
this.suggestionRange = null
|
this.suggestionRange = null
|
||||||
this.navigatedUserIndex = 0
|
this.navigatedItemIndex = 0
|
||||||
this.destroyPopup()
|
this.destroyPopup()
|
||||||
},
|
},
|
||||||
// is called on every keyDown event while a suggestion is active
|
// is called on every keyDown event while a suggestion is active
|
||||||
@ -279,6 +320,83 @@ export default {
|
|||||||
return fuse.search(query)
|
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 => {
|
onUpdate: e => {
|
||||||
clearTimeout(throttleInputEvent)
|
clearTimeout(throttleInputEvent)
|
||||||
@ -287,22 +405,32 @@ export default {
|
|||||||
}),
|
}),
|
||||||
linkUrl: null,
|
linkUrl: null,
|
||||||
linkMenuIsActive: false,
|
linkMenuIsActive: false,
|
||||||
|
nullSuggestionType: '',
|
||||||
|
mentionSuggestionType: 'mention',
|
||||||
|
hashtagSuggestionType: 'hashtag',
|
||||||
|
suggestionType: this.nullSuggestionType,
|
||||||
query: null,
|
query: null,
|
||||||
suggestionRange: null,
|
suggestionRange: null,
|
||||||
filteredUsers: [],
|
filteredItems: [],
|
||||||
navigatedUserIndex: 0,
|
navigatedItemIndex: 0,
|
||||||
insertMention: () => {},
|
insertMentionOrHashtag: () => {},
|
||||||
observer: null,
|
observer: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({ placeholder: 'editor/placeholder' }),
|
...mapGetters({ placeholder: 'editor/placeholder' }),
|
||||||
hasResults() {
|
hasResults() {
|
||||||
return this.filteredUsers.length
|
return this.filteredItems.length
|
||||||
},
|
},
|
||||||
showSuggestions() {
|
showSuggestions() {
|
||||||
return this.query || this.hasResults
|
return this.query || this.hasResults
|
||||||
},
|
},
|
||||||
|
isMention() {
|
||||||
|
return this.suggestionType === this.mentionSuggestionType
|
||||||
|
},
|
||||||
|
isHashtag() {
|
||||||
|
return this.suggestionType === this.hashtagSuggestionType
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value: {
|
value: {
|
||||||
@ -330,33 +458,54 @@ export default {
|
|||||||
this.editor.destroy()
|
this.editor.destroy()
|
||||||
},
|
},
|
||||||
methods: {
|
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
|
// navigate to the previous item
|
||||||
// if it's the first item, navigate to the last one
|
// if it's the first item, navigate to the last one
|
||||||
upHandler() {
|
upHandler() {
|
||||||
this.navigatedUserIndex =
|
this.navigatedItemIndex =
|
||||||
(this.navigatedUserIndex + this.filteredUsers.length - 1) % this.filteredUsers.length
|
(this.navigatedItemIndex + this.filteredItems.length - 1) % this.filteredItems.length
|
||||||
},
|
},
|
||||||
// navigate to the next item
|
// navigate to the next item
|
||||||
// if it's the last item, navigate to the first one
|
// if it's the last item, navigate to the first one
|
||||||
downHandler() {
|
downHandler() {
|
||||||
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredUsers.length
|
this.navigatedItemIndex = (this.navigatedItemIndex + 1) % this.filteredItems.length
|
||||||
},
|
},
|
||||||
|
// Handles pressing of enter.
|
||||||
enterHandler() {
|
enterHandler() {
|
||||||
const user = this.filteredUsers[this.navigatedUserIndex]
|
const item = this.filteredItems[this.navigatedItemIndex]
|
||||||
if (user) {
|
if (item) {
|
||||||
this.selectUser(user)
|
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
|
// we have to replace our suggestion text with a mention
|
||||||
// so it's important to pass also the position of your suggestion text
|
// so it's important to pass also the position of your suggestion text
|
||||||
selectUser(user) {
|
selectItem(item) {
|
||||||
this.insertMention({
|
const typeAttrs = {
|
||||||
range: this.suggestionRange,
|
mention: {
|
||||||
attrs: {
|
|
||||||
// TODO: use router here
|
// TODO: use router here
|
||||||
url: `/profile/${user.id}`,
|
url: `/profile/${item.id}`,
|
||||||
label: user.slug,
|
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()
|
this.editor.focus()
|
||||||
},
|
},
|
||||||
@ -535,6 +684,12 @@ li > p {
|
|||||||
.mention-suggestion {
|
.mention-suggestion {
|
||||||
color: $color-primary;
|
color: $color-primary;
|
||||||
}
|
}
|
||||||
|
.hashtag {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
.hashtag-suggestion {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
&__floating-menu {
|
&__floating-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
margin-top: -0.25rem;
|
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'
|
import { Mention as TipTapMention } from 'tiptap-extensions'
|
||||||
|
|
||||||
export default class Mention extends TipTapMention {
|
export default class Mention extends TipTapMention {
|
||||||
|
get name() {
|
||||||
|
return 'mention'
|
||||||
|
}
|
||||||
|
|
||||||
get schema() {
|
get schema() {
|
||||||
const patchedSchema = super.schema
|
const patchedSchema = super.schema
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<ds-card>
|
<ds-card class="filter-menu-card">
|
||||||
<ds-flex>
|
<ds-flex>
|
||||||
<ds-flex-item class="filter-menu-title">
|
<ds-flex-item class="filter-menu-title">
|
||||||
<ds-heading size="h3">{{ $t('filter-menu.title') }}</ds-heading>
|
<ds-heading size="h3">{{ $t('filter-menu.title') }}</ds-heading>
|
||||||
@ -20,6 +20,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
</ds-flex>
|
</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>
|
</ds-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -27,6 +49,7 @@
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
user: { type: Object, required: true },
|
user: { type: Object, required: true },
|
||||||
|
hashtag: { type: Object, default: null },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -50,11 +73,18 @@ export default {
|
|||||||
: { author: { followedBy_some: { id: this.user.id } } }
|
: { author: { followedBy_some: { id: this.user.id } } }
|
||||||
this.$emit('changeFilterBubble', this.filter)
|
this.$emit('changeFilterBubble', this.filter)
|
||||||
},
|
},
|
||||||
|
clearSearch() {
|
||||||
|
this.$emit('clearSearch')
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
.filter-menu-card {
|
||||||
|
background-color: $background-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-menu-title {
|
.filter-menu-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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', () => {
|
describe('match', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper.find('input#oldPassword').setValue('some secret')
|
wrapper.find('input#oldPassword').setValue('some secret')
|
||||||
wrapper.find('input#newPassword').setValue('some secret')
|
wrapper.find('input#password').setValue('some secret')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('invalid', () => {
|
it('invalid', () => {
|
||||||
@ -90,8 +90,8 @@ describe('ChangePassword.vue', () => {
|
|||||||
describe('given valid input', () => {
|
describe('given valid input', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper.find('input#oldPassword').setValue('supersecret')
|
wrapper.find('input#oldPassword').setValue('supersecret')
|
||||||
wrapper.find('input#newPassword').setValue('superdupersecret')
|
wrapper.find('input#password').setValue('superdupersecret')
|
||||||
wrapper.find('input#confirmPassword').setValue('superdupersecret')
|
wrapper.find('input#passwordConfirmation').setValue('superdupersecret')
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('submit form', () => {
|
describe('submit form', () => {
|
||||||
@ -109,8 +109,8 @@ describe('ChangePassword.vue', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
variables: {
|
variables: {
|
||||||
oldPassword: 'supersecret',
|
oldPassword: 'supersecret',
|
||||||
newPassword: 'superdupersecret',
|
password: 'superdupersecret',
|
||||||
confirmPassword: 'superdupersecret',
|
passwordConfirmation: 'superdupersecret',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ -135,8 +135,8 @@ describe('ChangePassword.vue', () => {
|
|||||||
/* describe('mutation rejects', () => {
|
/* describe('mutation rejects', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await wrapper.find('input#oldPassword').setValue('supersecret')
|
await wrapper.find('input#oldPassword').setValue('supersecret')
|
||||||
await wrapper.find('input#newPassword').setValue('supersecret')
|
await wrapper.find('input#password').setValue('supersecret')
|
||||||
await wrapper.find('input#confirmPassword').setValue('supersecret')
|
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays error message', async () => {
|
it('displays error message', async () => {
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<ds-form
|
<ds-form v-model="formData" :schema="formSchema" @submit="handleSubmit">
|
||||||
v-model="formData"
|
<template slot-scope="{ errors }">
|
||||||
:schema="formSchema"
|
|
||||||
@submit="handleSubmit"
|
|
||||||
@input="handleInput"
|
|
||||||
@input-valid="handleInputValid"
|
|
||||||
>
|
|
||||||
<template>
|
|
||||||
<ds-input
|
<ds-input
|
||||||
id="oldPassword"
|
id="oldPassword"
|
||||||
model="oldPassword"
|
model="oldPassword"
|
||||||
@ -15,22 +9,22 @@
|
|||||||
:label="$t('settings.security.change-password.label-old-password')"
|
:label="$t('settings.security.change-password.label-old-password')"
|
||||||
/>
|
/>
|
||||||
<ds-input
|
<ds-input
|
||||||
id="newPassword"
|
id="password"
|
||||||
model="newPassword"
|
model="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('settings.security.change-password.label-new-password')"
|
:label="$t('settings.security.change-password.label-new-password')"
|
||||||
/>
|
/>
|
||||||
<ds-input
|
<ds-input
|
||||||
id="confirmPassword"
|
id="passwordConfirmation"
|
||||||
model="confirmPassword"
|
model="passwordConfirmation"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('settings.security.change-password.label-new-password-confirm')"
|
: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-space margin-top="base">
|
||||||
<ds-button :loading="loading" :disabled="disabled" primary>
|
<ds-button :loading="loading" :disabled="errors" primary>
|
||||||
{{ $t('settings.security.change-password.button') }}
|
{{ $t('settings.security.change-password.button') }}
|
||||||
</ds-button>
|
</ds-button>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
@ -41,6 +35,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import PasswordStrength from './Strength'
|
import PasswordStrength from './Strength'
|
||||||
|
import PasswordForm from '~/components/utils/PasswordFormHelper'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ChangePassword',
|
name: 'ChangePassword',
|
||||||
@ -48,11 +43,11 @@ export default {
|
|||||||
PasswordStrength,
|
PasswordStrength,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
const passwordForm = PasswordForm({ translate: this.$t })
|
||||||
return {
|
return {
|
||||||
formData: {
|
formData: {
|
||||||
oldPassword: '',
|
oldPassword: '',
|
||||||
newPassword: '',
|
...passwordForm.formData,
|
||||||
confirmPassword: '',
|
|
||||||
},
|
},
|
||||||
formSchema: {
|
formSchema: {
|
||||||
oldPassword: {
|
oldPassword: {
|
||||||
@ -60,38 +55,18 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
message: this.$t('settings.security.change-password.message-old-password-required'),
|
message: this.$t('settings.security.change-password.message-old-password-required'),
|
||||||
},
|
},
|
||||||
newPassword: {
|
...passwordForm.formSchema,
|
||||||
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',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async handleInput(data) {
|
|
||||||
this.disabled = true
|
|
||||||
},
|
|
||||||
async handleInputValid(data) {
|
|
||||||
this.disabled = false
|
|
||||||
},
|
|
||||||
async handleSubmit(data) {
|
async handleSubmit(data) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
const mutation = gql`
|
const mutation = gql`
|
||||||
mutation($oldPassword: String!, $newPassword: String!) {
|
mutation($oldPassword: String!, $password: String!) {
|
||||||
changePassword(oldPassword: $oldPassword, newPassword: $newPassword)
|
changePassword(oldPassword: $oldPassword, newPassword: $password)
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
const variables = this.formData
|
const variables = this.formData
|
||||||
@ -102,8 +77,8 @@ export default {
|
|||||||
this.$toast.success(this.$t('settings.security.change-password.success'))
|
this.$toast.success(this.$t('settings.security.change-password.success'))
|
||||||
this.formData = {
|
this.formData = {
|
||||||
oldPassword: '',
|
oldPassword: '',
|
||||||
newPassword: '',
|
password: '',
|
||||||
confirmPassword: '',
|
passwordConfirmation: '',
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.$toast.error(err.message)
|
this.$toast.error(err.message)
|
||||||
@ -111,15 +86,6 @@ export default {
|
|||||||
this.loading = false
|
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>
|
</script>
|
||||||
|
|||||||
@ -47,8 +47,8 @@ describe('ChangePassword ', () => {
|
|||||||
describe('submitting new password', () => {
|
describe('submitting new password', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = Wrapper()
|
wrapper = Wrapper()
|
||||||
wrapper.find('input#newPassword').setValue('supersecret')
|
wrapper.find('input#password').setValue('supersecret')
|
||||||
wrapper.find('input#confirmPassword').setValue('supersecret')
|
wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||||
wrapper.find('form').trigger('submit')
|
wrapper.find('form').trigger('submit')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -58,7 +58,7 @@ describe('ChangePassword ', () => {
|
|||||||
|
|
||||||
it('delivers new password to backend', () => {
|
it('delivers new password to backend', () => {
|
||||||
const expected = expect.objectContaining({
|
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)
|
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,36 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<ds-card class="verify-code">
|
<ds-card class="verify-code">
|
||||||
<ds-space margin="large">
|
<ds-space margin="large">
|
||||||
<template>
|
|
||||||
<ds-form
|
<ds-form
|
||||||
v-if="!changePasswordResult"
|
v-if="!changePasswordResult"
|
||||||
v-model="formData"
|
v-model="formData"
|
||||||
:schema="formSchema"
|
:schema="formSchema"
|
||||||
@submit="handleSubmitPassword"
|
@submit="handleSubmitPassword"
|
||||||
@input="handleInput"
|
|
||||||
@input-valid="handleInputValid"
|
|
||||||
class="change-password"
|
class="change-password"
|
||||||
>
|
>
|
||||||
|
<template slot-scope="{ errors }">
|
||||||
<ds-input
|
<ds-input
|
||||||
id="newPassword"
|
id="password"
|
||||||
model="newPassword"
|
model="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('settings.security.change-password.label-new-password')"
|
:label="$t('settings.security.change-password.label-new-password')"
|
||||||
/>
|
/>
|
||||||
<ds-input
|
<ds-input
|
||||||
id="confirmPassword"
|
id="passwordConfirmation"
|
||||||
model="confirmPassword"
|
model="passwordConfirmation"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('settings.security.change-password.label-new-password-confirm')"
|
: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-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') }}
|
{{ $t('settings.security.change-password.button') }}
|
||||||
</ds-button>
|
</ds-button>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
|
</template>
|
||||||
</ds-form>
|
</ds-form>
|
||||||
<ds-text v-else>
|
<ds-text v-else>
|
||||||
<template v-if="changePasswordResult === 'success'">
|
<template v-if="changePasswordResult === 'success'">
|
||||||
@ -48,7 +47,6 @@
|
|||||||
<a href="mailto:support@human-connection.org">support@human-connection.org</a>
|
<a href="mailto:support@human-connection.org">support@human-connection.org</a>
|
||||||
</template>
|
</template>
|
||||||
</ds-text>
|
</ds-text>
|
||||||
</template>
|
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-card>
|
</ds-card>
|
||||||
</template>
|
</template>
|
||||||
@ -57,6 +55,7 @@
|
|||||||
import PasswordStrength from '../Password/Strength'
|
import PasswordStrength from '../Password/Strength'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import { SweetalertIcon } from 'vue-sweetalert-icons'
|
import { SweetalertIcon } from 'vue-sweetalert-icons'
|
||||||
|
import PasswordForm from '~/components/utils/PasswordFormHelper'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -68,48 +67,28 @@ export default {
|
|||||||
code: { type: String, required: true },
|
code: { type: String, required: true },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
const passwordForm = PasswordForm({ translate: this.$t })
|
||||||
return {
|
return {
|
||||||
formData: {
|
formData: {
|
||||||
newPassword: '',
|
...passwordForm.formData,
|
||||||
confirmPassword: '',
|
|
||||||
},
|
},
|
||||||
formSchema: {
|
formSchema: {
|
||||||
newPassword: {
|
...passwordForm.formSchema,
|
||||||
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',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
disabled: true,
|
disabled: true,
|
||||||
changePasswordResult: null,
|
changePasswordResult: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async handleInput() {
|
|
||||||
this.disabled = true
|
|
||||||
},
|
|
||||||
async handleInputValid() {
|
|
||||||
this.disabled = false
|
|
||||||
},
|
|
||||||
async handleSubmitPassword() {
|
async handleSubmitPassword() {
|
||||||
const mutation = gql`
|
const mutation = gql`
|
||||||
mutation($code: String!, $email: String!, $newPassword: String!) {
|
mutation($code: String!, $email: String!, $password: String!) {
|
||||||
resetPassword(code: $code, email: $email, newPassword: $newPassword)
|
resetPassword(code: $code, email: $email, newPassword: $password)
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
const { newPassword } = this.formData
|
const { password } = this.formData
|
||||||
const { email, code } = this
|
const { email, code } = this
|
||||||
const variables = { newPassword, email, code }
|
const variables = { password, email, code }
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
data: { resetPassword },
|
data: { resetPassword },
|
||||||
@ -119,22 +98,13 @@ export default {
|
|||||||
this.$emit('passwordResetResponse', this.changePasswordResult)
|
this.$emit('passwordResetResponse', this.changePasswordResult)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
this.formData = {
|
this.formData = {
|
||||||
newPassword: '',
|
password: '',
|
||||||
confirmPassword: '',
|
passwordConfirmation: '',
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.$toast.error(err.message)
|
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>
|
</script>
|
||||||
|
|||||||
@ -128,6 +128,15 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<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 {
|
.post-card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
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>
|
<template>
|
||||||
<ds-form v-model="form" @submit="handleSubmit">
|
<ds-form v-show="!editPending" v-model="form" @submit="handleSubmit">
|
||||||
<template slot-scope="{ errors }">
|
<template slot-scope="{ errors }">
|
||||||
<ds-card>
|
<ds-card>
|
||||||
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
|
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
|
||||||
@ -24,9 +24,10 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import HcEditor from '~/components/Editor'
|
import HcEditor from '~/components/Editor/Editor'
|
||||||
import PostCommentsQuery from '~/graphql/PostCommentsQuery.js'
|
import PostCommentsQuery from '~/graphql/PostCommentsQuery.js'
|
||||||
import CommentMutations from '~/graphql/CommentMutations.js'
|
import CommentMutations from '~/graphql/CommentMutations.js'
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -46,6 +47,11 @@ export default {
|
|||||||
users: [],
|
users: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
editPending: 'editor/editPending',
|
||||||
|
}),
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateEditorContent(value) {
|
updateEditorContent(value) {
|
||||||
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim()
|
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||||
|
|||||||
@ -40,6 +40,7 @@ describe('CommentForm.vue', () => {
|
|||||||
'editor/placeholder': () => {
|
'editor/placeholder': () => {
|
||||||
return 'some cool placeholder'
|
return 'some cool placeholder'
|
||||||
},
|
},
|
||||||
|
'editor/editPending': () => false,
|
||||||
}
|
}
|
||||||
const store = new Vuex.Store({
|
const store = new Vuex.Store({
|
||||||
getters,
|
getters,
|
||||||
|
|||||||
@ -16,11 +16,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<ds-space margin-bottom="large" />
|
<ds-space margin-bottom="large" />
|
||||||
<div v-if="comments && comments.length" class="comments">
|
<div v-if="comments && comments.length" id="comments" class="comments">
|
||||||
<comment
|
<comment
|
||||||
v-for="(comment, index) in comments"
|
v-for="(comment, index) in comments"
|
||||||
:key="comment.id"
|
:key="comment.id"
|
||||||
:comment="comment"
|
:comment="comment"
|
||||||
|
:post="post"
|
||||||
@deleteComment="comments.splice(index, 1)"
|
@deleteComment="comments.splice(index, 1)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -47,7 +48,8 @@ export default {
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
Post(post) {
|
Post(post) {
|
||||||
this.comments = post[0].comments || []
|
const [first] = post
|
||||||
|
this.comments = (first && first.comments) || []
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
apollo: {
|
apollo: {
|
||||||
|
|||||||
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