Merge branch 'master' of github.com:Human-Connection/Human-Connection into 906-maintenace-mode

This commit is contained in:
Matt Rider 2019-07-29 08:52:10 +02:00
commit 733d3333c9
105 changed files with 18507 additions and 2104 deletions

View File

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

View File

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

View File

@ -9,6 +9,7 @@
"dev": "nodemon --exec babel-node src/ -e js,gql", "dev": "nodemon --exec babel-node src/ -e js,gql",
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql",
"lint": "eslint src --config .eslintrc.js", "lint": "eslint src --config .eslintrc.js",
"jest": "jest --forceExit --detectOpenHandles --runInBand",
"test": "run-s test:jest test:cucumber", "test": "run-s test:jest test:cucumber",
"test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null", "test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null",
"test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null", "test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null",
@ -34,7 +35,6 @@
"!**/src/**/?(*.)+(spec|test).js?(x)" "!**/src/**/?(*.)+(spec|test).js?(x)"
], ],
"coverageReporters": [ "coverageReporters": [
"text",
"lcov" "lcov"
], ],
"testMatch": [ "testMatch": [
@ -48,59 +48,75 @@
"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.8", "apollo-server": "~2.7.0",
"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",
"cross-env": "~5.2.0", "cross-env": "~5.2.0",
"date-fns": "2.0.0-beta.1", "date-fns": "2.0.0-beta.3",
"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",
"graphql-iso-date": "~3.6.1", "graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.2", "graphql-middleware": "~3.0.2",
"graphql-shield": "~6.0.3", "graphql-shield": "~6.0.4",
"graphql-tag": "~2.10.1", "graphql-tag": "~2.10.1",
"graphql-yoga": "~1.18.0", "helmet": "~3.19.0",
"helmet": "~3.18.0",
"jsonwebtoken": "~8.5.1", "jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8", "linkifyjs": "~2.1.8",
"lodash": "~4.17.14", "lodash": "~4.17.14",
"merge-graphql-schemas": "^1.5.8", "merge-graphql-schemas": "^1.6.1",
"metascraper": "^4.10.3",
"metascraper-audio": "^5.5.0",
"metascraper-author": "^5.6.3",
"metascraper-clearbit-logo": "^5.3.0",
"metascraper-date": "^5.6.3",
"metascraper-description": "^5.5.0",
"metascraper-image": "^5.6.3",
"metascraper-lang": "^5.6.3",
"metascraper-lang-detector": "^4.8.5",
"metascraper-logo": "^5.5.0",
"metascraper-publisher": "^5.6.3",
"metascraper-soundcloud": "^5.5.3",
"metascraper-title": "^5.6.3",
"metascraper-url": "^5.5.0",
"metascraper-video": "^4.8.5",
"metascraper-youtube": "^4.8.5",
"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.5",
"@babel/core": "~7.5.4", "@babel/core": "~7.5.5",
"@babel/node": "~7.5.0", "@babel/node": "~7.5.5",
"@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.5.4", "@babel/preset-env": "~7.5.5",
"@babel/register": "~7.4.4", "@babel/register": "~7.5.5",
"apollo-server-testing": "~2.6.8", "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",
"chai": "~4.2.0", "chai": "~4.2.0",
"cucumber": "~5.1.0", "cucumber": "~5.1.0",
"eslint": "~6.0.1", "eslint": "~6.1.0",
"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.2",
"eslint-plugin-jest": "~22.7.2", "eslint-plugin-jest": "~22.13.6",
"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",

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,6 +1,5 @@
import { GraphQLClient } from 'graphql-request' import { GraphQLClient } from 'graphql-request'
import gql from 'graphql-tag' import { host, login, gql } from '../../jest/helpers'
import { host, login } from '../../jest/helpers'
import Factory from '../../seed/factories' import Factory from '../../seed/factories'
const factory = Factory() const factory = Factory()

View File

@ -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'
@ -50,11 +52,14 @@ export default schema => {
if (CONFIG.DISABLED_MIDDLEWARES) { if (CONFIG.DISABLED_MIDDLEWARES) {
const disabledMiddlewares = CONFIG.DISABLED_MIDDLEWARES.split(',') const disabledMiddlewares = CONFIG.DISABLED_MIDDLEWARES.split(',')
order = order.filter(key => { order = order.filter(key => {
if (disabledMiddlewares.includes(key)) {
/* eslint-disable-next-line no-console */
console.log(`Warning: Disabled "${disabledMiddlewares}" middleware.`)
}
return !disabledMiddlewares.includes(key) return !disabledMiddlewares.includes(key)
}) })
/* eslint-disable-next-line no-console */
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)
} }

View File

@ -136,6 +136,7 @@ const permissions = shield(
Query: { Query: {
'*': deny, '*': deny,
findPosts: allow, findPosts: allow,
embed: allow,
Category: allow, Category: allow,
Tag: allow, Tag: allow,
Report: isModerator, Report: isModerator,
@ -146,6 +147,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 +162,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,
DeleteSocialMedia: isAuthenticated, DeleteSocialMedia: isAuthenticated,
// AddBadgeRewarded: isAdmin, // AddBadgeRewarded: isAdmin,
@ -178,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,

View File

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

View File

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

View 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() },
}

View File

@ -8,5 +8,6 @@ module.exports = {
relationship: 'BELONGS_TO', relationship: 'BELONGS_TO',
target: 'User', target: 'User',
direction: 'out', direction: 'out',
eager: true,
}, },
} }

View File

@ -4,7 +4,6 @@ module.exports = {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
actorId: { type: 'string', allow: [null] }, actorId: { type: 'string', allow: [null] },
name: { type: 'string', min: 3 }, name: { type: 'string', min: 3 },
email: { type: 'string', lowercase: true, email: true },
slug: 'string', slug: 'string',
encryptedPassword: 'string', encryptedPassword: 'string',
avatar: { type: 'string', allow: [null] }, avatar: { type: 'string', allow: [null] },
@ -43,6 +42,12 @@ module.exports = {
target: 'User', target: 'User',
direction: 'in', direction: 'in',
}, },
rewarded: {
type: 'relationship',
relationship: 'REWARDED',
target: 'Badge',
direction: 'in',
},
invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' }, invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: { updatedAt: {

View File

@ -1,6 +1,7 @@
// NOTE: We cannot use `fs` here to clean up the code. Cypress breaks on any npm // 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 // module that is not browser-compatible. Node's `fs` module is server-side only
export default { export default {
Badge: require('./Badge.js'),
User: require('./User.js'), User: require('./User.js'),
InvitationCode: require('./InvitationCode.js'), InvitationCode: require('./InvitationCode.js'),
EmailAddress: require('./EmailAddress.js'), EmailAddress: require('./EmailAddress.js'),

View File

@ -0,0 +1,9 @@
export const undefinedToNull = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
import scrape from './embeds/scraper.js'
import { undefinedToNull } from '../helpers'
export default {
Query: {
embed: async (object, { url }, context, resolveInfo) => {
return scrape(url)
},
},
Embed: {
...undefinedToNull([
'type',
'title',
'author',
'publisher',
'date',
'description',
'url',
'image',
'audio',
'video',
'lang',
'html',
]),
sources: async (parent, params, context, resolveInfo) => {
return typeof parent.sources === 'undefined' ? [] : parent.sources
},
},
}

View File

@ -0,0 +1,216 @@
import fetch from 'node-fetch'
import fs from 'fs'
import path from 'path'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
import { gql } from '../../jest/helpers'
jest.mock('node-fetch')
const { Response } = jest.requireActual('node-fetch')
afterEach(() => {
fetch.mockRestore()
})
let variables = {}
const HumanConnectionOrg = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/HumanConnectionOrg.html'),
'utf8',
)
const pr960 = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/pr960.html'),
'utf8',
)
const babyLovesCat = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/babyLovesCat.html'),
'utf8',
)
const babyLovesCatEmbedResponse = new Response(
JSON.stringify({
height: 270,
provider_name: 'YouTube',
title: 'Baby Loves Cat',
type: 'video',
width: 480,
thumbnail_height: 360,
provider_url: 'https://www.youtube.com/',
thumbnail_width: 480,
html:
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?start=18&feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
thumbnail_url: 'https://i.ytimg.com/vi/qkdXAtO40Fo/hqdefault.jpg',
version: '1.0',
author_name: 'Merkley Family',
author_url: 'https://www.youtube.com/channel/UC5P8yei950tif7UmdPpkJLQ',
}),
)
describe('Query', () => {
describe('embed', () => {
let embedAction
beforeEach(() => {
embedAction = async variables => {
const { server } = createServer({
context: () => {},
})
const { query } = createTestClient(server)
const embed = gql`
query($url: String!) {
embed(url: $url) {
type
title
author
publisher
date
description
url
image
audio
video
lang
sources
html
}
}
`
return query({ query: embed, variables })
}
})
describe('given a video link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response('')))
.mockReturnValueOnce(Promise.resolve(JSON.stringify({})))
variables = { url: 'https://www.w3schools.com/html/mov_bbb.mp4' }
})
it('shows some default data', async () => {
const expected = expect.objectContaining({
data: {
embed: {
audio: null,
author: null,
date: null,
description: null,
html: null,
image: null,
lang: null,
publisher: null,
sources: ['resource'],
title: null,
type: 'link',
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
video: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a Facebook link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(HumanConnectionOrg)))
.mockReturnValueOnce(Promise.resolve('invalid json'))
variables = { url: 'https://www.facebook.com/HumanConnectionOrg/' }
})
it('does not crash if embed provider returns invalid JSON', async () => {
const expected = expect.objectContaining({
data: {
embed: {
audio: null,
author: null,
date: expect.any(String),
description:
'Human Connection, Weilheim an der Teck. Gefällt 24.407 Mal. An upcoming non-profit social network focused on local and global positive change. Twitter accounts : @hc_world (EN), @hc_deutschland (GE),...',
html: null,
image:
'https://scontent.ftxl3-1.fna.fbcdn.net/v/t1.0-1/c5.0.200.200a/p200x200/12108307_997373093648222_70057205881020137_n.jpg?_nc_cat=110&_nc_oc=AQnPPYQlR0dU556gOfl4xkXr7IPZdRIAUfQeXl3fpUv4DAsFN8T4PfgOjPwuq85GPKGZ5S5E5mWQ8IVV1UiRBAIZ&_nc_ht=scontent.ftxl3-1.fna&oh=90309adddaab38839782f16e7d4b7bcf&oe=5DEEDFE5',
lang: 'de',
publisher: 'Facebook',
sources: ['resource'],
title: 'Human Connection',
type: 'link',
url: 'https://www.facebook.com/HumanConnectionOrg/',
video: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a Github link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(pr960)))
.mockReturnValueOnce(Promise.resolve(JSON.stringify({})))
variables = { url: 'https://github.com/Human-Connection/Human-Connection/pull/960' }
})
it('returns meta data even if no embed html can be retrieved', async () => {
const expected = expect.objectContaining({
data: {
embed: {
type: 'link',
title:
'Editor embeds merge in nitro embed by mattwr18 · Pull Request #960 · Human-Connection/Human-Connection',
author: 'Human-Connection',
publisher: 'GitHub',
date: expect.any(String),
description: '🍰 Pullrequest Issues fixes #256',
url: 'https://github.com/Human-Connection/Human-Connection/pull/960',
image:
'https://repository-images.githubusercontent.com/112590397/52c9a000-7e11-11e9-899d-aaa55f3a3d72',
audio: null,
video: null,
lang: 'en',
sources: ['resource'],
html: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a youtube link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(babyLovesCat)))
.mockReturnValueOnce(Promise.resolve(babyLovesCatEmbedResponse))
variables = { url: 'https://www.youtube.com/watch?v=qkdXAtO40Fo&t=18s' }
})
it('returns meta data plus youtube iframe html', async () => {
const expected = expect.objectContaining({
data: {
embed: {
type: 'video',
title: 'Baby Loves Cat',
author: 'Merkley Family',
publisher: 'YouTube',
date: expect.any(String),
description:
'Shes incapable of controlling her limbs when her kitty is around. The obsession grows every day. Ps. Thats a sleep sack shes in. Not a starfish outfit. Al...',
url: 'https://www.youtube.com/watch?v=qkdXAtO40Fo',
image: 'https://i.ytimg.com/vi/qkdXAtO40Fo/maxresdefault.jpg',
audio: null,
video: null,
lang: 'de',
sources: ['resource', 'oembed'],
html:
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?start=18&feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,102 @@
import Metascraper from 'metascraper'
import fetch from 'node-fetch'
import fs from 'fs'
import path from 'path'
import { ApolloError } from 'apollo-server'
import isEmpty from 'lodash/isEmpty'
import isArray from 'lodash/isArray'
import mergeWith from 'lodash/mergeWith'
const error = require('debug')('embed:error')
const metascraper = Metascraper([
require('metascraper-author')(),
require('metascraper-date')(),
require('metascraper-description')(),
require('metascraper-image')(),
require('metascraper-lang')(),
require('metascraper-lang-detector')(),
require('metascraper-logo')(),
// require('metascraper-clearbit-logo')(),
require('metascraper-publisher')(),
require('metascraper-title')(),
require('metascraper-url')(),
require('metascraper-audio')(),
require('metascraper-soundcloud')(),
require('metascraper-video')(),
require('metascraper-youtube')(),
// require('./rules/metascraper-embed')()
])
let oEmbedProvidersFile = fs.readFileSync(path.join(__dirname, './providers.json'), 'utf8')
// some providers allow a format parameter
// we need JSON
oEmbedProvidersFile = oEmbedProvidersFile.replace('{format}', 'json')
const oEmbedProviders = JSON.parse(oEmbedProvidersFile)
const fetchEmbed = async url => {
const provider = oEmbedProviders.find(provider => {
return provider.provider_url.includes(url.hostname)
})
if (!provider) return {}
const {
endpoints: [endpoint],
} = provider
const endpointUrl = new URL(endpoint.url)
endpointUrl.searchParams.append('url', url.href)
endpointUrl.searchParams.append('format', 'json')
let json
try {
const response = await fetch(endpointUrl)
json = await response.json()
} catch (err) {
error(`Error fetching embed data: ${err.message}`)
return {}
}
return {
type: json.type,
html: json.html,
author: json.author_name,
date: json.upload_date,
sources: ['oembed'],
}
}
const fetchResource = async url => {
const response = await fetch(url)
const html = await response.text()
const resource = await metascraper({ html, url: url.href })
return {
sources: ['resource'],
...resource,
}
}
export default async function scrape(url) {
url = new URL(url)
if (url.hostname === 'youtu.be') {
// replace youtu.be to get proper results
url.hostname = 'youtube.com'
}
const [meta, embed] = await Promise.all([fetchResource(url), fetchEmbed(url)])
const output = mergeWith(meta, embed, (objValue, srcValue) => {
if (isArray(objValue)) {
return objValue.concat(srcValue)
}
})
if (isEmpty(output)) {
throw new ApolloError('Not found', 'NOT_FOUND')
}
return {
type: 'link',
...output,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload' import fileUpload from './fileUpload'
import { neode } from '../../bootstrap/neo4j' import { neode } from '../../bootstrap/neo4j'
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import { undefinedToNull } from '../helpers'
const instance = neode() const instance = neode()
@ -36,16 +37,6 @@ const count = obj => {
return resolvers return resolvers
} }
const undefinedToNull = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}
export const hasMany = obj => { export const hasMany = obj => {
const resolvers = {} const resolvers = {}
for (const [key, connection] of Object.entries(obj)) { for (const [key, connection] of Object.entries(obj)) {
@ -65,6 +56,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 +102,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 +145,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)',
}), }),
}, },
} }

View File

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

View File

@ -0,0 +1,19 @@
type Embed {
type: String
title: String
author: String
publisher: String
date: String
description: String
url: String
image: String
audio: String
video: String
lang: String
html: String
sources: [String]
}
type Query {
embed(url: String!): Embed
}

View File

@ -1,4 +0,0 @@
enum BadgeStatus {
permanent
temporary
}

View File

@ -1,4 +0,0 @@
enum BadgeType {
role
crowdfunding
}

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ 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
@ -81,6 +81,9 @@ type User {
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!]

View File

@ -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!
$icon: String!
) {
CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) {
id
} }
args = {
...defaults,
...args,
} }
`, return neodeInstance.create('Badge', args)
variables: { id, key, type, status, icon }, },
} }
} }

View File

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

View File

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

View File

@ -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',
@ -123,30 +113,16 @@ import Factory from './factories'
]) ])
await Promise.all([ await Promise.all([
f.relate('User', 'Badges', { peterLustig.relateTo(racoon, 'rewarded'),
from: 'b6', peterLustig.relateTo(rhino, 'rewarded'),
to: 'u1', peterLustig.relateTo(wolf, 'rewarded'),
}), bobDerBaumeister.relateTo(racoon, 'rewarded'),
f.relate('User', 'Badges', { bobDerBaumeister.relateTo(turtle, 'rewarded'),
from: 'b5', jennyRostock.relateTo(bear, 'rewarded'),
to: 'u2', dagobert.relateTo(rabbit, 'rewarded'),
}), ])
f.relate('User', 'Badges', {
from: 'b4', await Promise.all([
to: 'u3',
}),
f.relate('User', 'Badges', {
from: 'b3',
to: 'u4',
}),
f.relate('User', 'Badges', {
from: 'b2',
to: 'u5',
}),
f.relate('User', 'Badges', {
from: 'b1',
to: 'u6',
}),
f.relate('User', 'Friends', { f.relate('User', 'Friends', {
from: 'u1', from: 'u1',
to: 'u2', to: 'u2',
@ -707,6 +683,13 @@ import Factory from './factories'
to: 'o3', 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)

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,14 +23,14 @@
"codecov": "^3.5.0", "codecov": "^3.5.0",
"cross-env": "^5.2.0", "cross-env": "^5.2.0",
"cypress": "^3.4.0", "cypress": "^3.4.0",
"cypress-cucumber-preprocessor": "^1.12.0", "cypress-cucumber-preprocessor": "^1.13.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",
"graphql-request": "^1.8.2", "graphql-request": "^1.8.2",
"neo4j-driver": "^1.7.5", "neo4j-driver": "^1.7.5",
"neode": "^0.2.16", "neode": "^0.2.18",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"slug": "^1.1.0" "slug": "^1.1.0"
} }

2
webapp/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@ -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" />
@ -27,6 +27,7 @@ import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/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()

View File

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

View File

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

View File

@ -0,0 +1,108 @@
<template>
<ds-form v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<ds-card>
<!-- with no-ssr the content is not shown -->
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<ds-space />
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">
<ds-flex-item :width="{ base: '0%', md: '50%', sm: '0%', xs: '0%' }" />
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '30%', xs: '30%' }">
<ds-button ghost class="cancelBtn" @click.prevent="closeEditWindow">
{{ $t('actions.cancel') }}
</ds-button>
</ds-flex-item>
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '40%', xs: '40%' }">
<ds-button type="submit" :loading="loading" :disabled="disabled || errors" primary>
{{ $t('post.comment.submit') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</ds-card>
</template>
</ds-form>
</template>
<script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor'
import { mapMutations } from 'vuex'
import CommentMutations from '~/graphql/CommentMutations.js'
export default {
components: {
HcEditor,
},
props: {
comment: {
type: Object,
default() {
return {}
},
},
},
data() {
return {
disabled: true,
loading: false,
form: {
content: this.comment.content,
},
users: [],
}
},
methods: {
...mapMutations({
setEditPending: 'editor/SET_EDIT_PENDING',
}),
updateEditorContent(value) {
const sanitizedContent = value.replace(/<(?:.|\n)*?>/gm, '').trim()
this.disabled = value === this.comment.content || sanitizedContent.length < 1
this.form.content = value
},
closeEditWindow() {
this.$emit('showEditCommentMenu', false)
},
handleSubmit() {
this.loading = true
this.disabled = true
this.$apollo
.mutate({
mutation: CommentMutations().UpdateComment,
variables: {
content: this.form.content,
id: this.comment.id,
},
})
.then(() => {
this.loading = false
this.$toast.success(this.$t('post.comment.updated'))
this.disabled = false
this.$emit('showEditCommentMenu', false)
this.setEditPending(false)
})
.catch(err => {
this.$toast.error(err.message)
})
},
},
apollo: {
User: {
query() {
return gql`
{
User(orderBy: slug_asc) {
id
slug
}
}
`
},
result({ data: { User } }) {
this.users = User
},
},
},
}
</script>

View File

@ -2,7 +2,7 @@
<ds-button v-if="totalNotifications <= 0" class="notifications-menu" disabled icon="bell"> <ds-button v-if="totalNotifications <= 0" class="notifications-menu" disabled icon="bell">
{{ totalNotifications }} {{ totalNotifications }}
</ds-button> </ds-button>
<dropdown v-else class="notifications-menu"> <dropdown v-else class="notifications-menu" :placement="placement">
<template slot="default" slot-scope="{ toggleMenu }"> <template slot="default" slot-scope="{ toggleMenu }">
<ds-button primary icon="bell" @click.prevent="toggleMenu"> <ds-button primary icon="bell" @click.prevent="toggleMenu">
{{ totalNotifications }} {{ totalNotifications }}
@ -48,6 +48,9 @@ export default {
NotificationList, NotificationList,
Dropdown, Dropdown,
}, },
props: {
placement: { type: String },
},
computed: { computed: {
totalNotifications() { totalNotifications() {
return (this.notifications || []).length return (this.notifications || []).length

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag'
export default () => {
return gql(`{
Category {
id
name
icon
}
}`)
}

View File

@ -20,5 +20,14 @@ export default () => {
} }
} }
`, `,
UpdateComment: gql`
mutation($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
content
contentExcerpt
}
}
`,
} }
} }

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag'
export default app => { export default app => {
const lang = app.$i18n.locale().toUpperCase() const lang = app.$i18n.locale().toUpperCase()
return gql(` return gql`
query Comment($postId: ID) { query Comment($postId: ID) {
Comment(postId: $postId) { Comment(postId: $postId) {
id id
@ -25,11 +25,10 @@ export default app => {
} }
badges { badges {
id id
key
icon icon
} }
} }
} }
} }
`) `
} }

View File

@ -29,7 +29,6 @@ export default i18n => {
} }
badges { badges {
id id
key
icon icon
} }
} }

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag'
export default i18n => { export default i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql(` return gql`
query Post($slug: String!) { query Post($slug: String!) {
Post(slug: $slug) { Post(slug: $slug) {
id id
@ -30,7 +30,6 @@ export default i18n => {
} }
badges { badges {
id id
key
icon icon
} }
} }
@ -61,7 +60,6 @@ export default i18n => {
} }
badges { badges {
id id
key
icon icon
} }
} }
@ -75,5 +73,50 @@ export default i18n => {
shoutedByCurrentUser shoutedByCurrentUser
} }
} }
`) `
}
export const filterPosts = i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
Post(filter: $filter, first: $first, offset: $offset) {
id
title
contentExcerpt
createdAt
disabled
deleted
slug
image
author {
id
avatar
slug
name
disabled
deleted
contributionsCount
shoutedCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${lang}
}
badges {
id
icon
}
}
commentsCount
categories {
id
name
icon
}
shoutedCount
}
}
`
} }

View File

@ -19,7 +19,6 @@ export default i18n => {
createdAt createdAt
badges { badges {
id id
key
icon icon
} }
badgesCount badgesCount
@ -39,7 +38,6 @@ export default i18n => {
commentsCount commentsCount
badges { badges {
id id
key
icon icon
} }
location { location {
@ -61,7 +59,6 @@ export default i18n => {
commentsCount commentsCount
badges { badges {
id id
key
icon icon
} }
location { location {

View File

@ -3,14 +3,24 @@
<div class="main-navigation"> <div class="main-navigation">
<ds-container class="main-navigation-container" style="padding: 10px 10px;"> <ds-container class="main-navigation-container" style="padding: 10px 10px;">
<div> <div>
<ds-flex> <ds-flex class="main-navigation-flex">
<ds-flex-item :width="{ base: '49px', md: '150px' }"> <ds-flex-item :width="{ lg: '3.5%' }" />
<nuxt-link to="/"> <ds-flex-item :width="{ base: '80%', sm: '80%', md: '80%', lg: '15%' }">
<a @click="redirectToRoot">
<ds-logo /> <ds-logo />
</nuxt-link> </a>
</ds-flex-item> </ds-flex-item>
<ds-flex-item> <ds-flex-item
<div id="nav-search-box" v-on:click="unfolded" @blur.capture="foldedup"> :width="{ base: '20%', sm: '20%', md: '20%', lg: '0%' }"
class="mobile-hamburger-menu"
>
<ds-button icon="bars" @click="toggleMobileMenuView" right />
</ds-flex-item>
<ds-flex-item
:width="{ base: '85%', sm: '85%', md: '50%', lg: '50%' }"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
>
<div id="nav-search-box">
<search-input <search-input
id="nav-search" id="nav-search"
:delay="300" :delay="300"
@ -22,17 +32,36 @@
/> />
</div> </div>
</ds-flex-item> </ds-flex-item>
<ds-flex-item width="200px" style="background-color:white"> <ds-flex-item
<div class="main-navigation-right" style="float:right"> :width="{ base: '15%', sm: '15%', md: '10%', lg: '10%' }"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
>
<no-ssr> <no-ssr>
<locale-switch class="topbar-locale-switch" placement="bottom" offset="23" /> <filter-posts placement="top-start" offset="8" :categories="categories" />
</no-ssr>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '10%', lg: '2%' }" />
<ds-flex-item
:width="{ base: '100%', sm: '100%', md: '100%', lg: '13%' }"
style="background-color:white"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
>
<div
class="main-navigation-right"
:class="{
'desktop-view': !toggleMobileMenu,
'hide-mobile-menu': !toggleMobileMenu,
}"
>
<no-ssr>
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
</no-ssr> </no-ssr>
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<no-ssr> <no-ssr>
<notification-menu /> <notification-menu placement="top" />
</no-ssr> </no-ssr>
<no-ssr> <no-ssr>
<dropdown class="avatar-menu"> <dropdown class="avatar-menu" offset="8">
<template slot="default" slot-scope="{ toggleMenu }"> <template slot="default" slot-scope="{ toggleMenu }">
<a <a
class="avatar-menu-trigger" class="avatar-menu-trigger"
@ -118,6 +147,8 @@ import NotificationMenu from '~/components/notifications/NotificationMenu'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import HcAvatar from '~/components/Avatar/Avatar.vue' import HcAvatar from '~/components/Avatar/Avatar.vue'
import seo from '~/mixins/seo' import seo from '~/mixins/seo'
import FilterPosts from '~/components/FilterPosts/FilterPosts.vue'
import CategoryQuery from '~/graphql/CategoryQuery.js'
export default { export default {
components: { components: {
@ -127,11 +158,14 @@ export default {
Modal, Modal,
NotificationMenu, NotificationMenu,
HcAvatar, HcAvatar,
FilterPosts,
}, },
mixins: [seo], mixins: [seo],
data() { data() {
return { return {
mobileSearchVisible: false, mobileSearchVisible: false,
toggleMobileMenu: false,
categories: [],
} }
}, },
computed: { computed: {
@ -180,10 +214,16 @@ export default {
return routes return routes
}, },
}, },
watch: {
Category(category) {
this.categories = category || []
},
},
methods: { methods: {
...mapActions({ ...mapActions({
quickSearchClear: 'search/quickClear', quickSearchClear: 'search/quickClear',
quickSearch: 'search/quickSearch', quickSearch: 'search/quickSearch',
fetchPosts: 'posts/fetchPosts',
}), }),
goToPost(item) { goToPost(item) {
this.$nextTick(() => { this.$nextTick(() => {
@ -200,23 +240,24 @@ export default {
} }
return this.$route.path.indexOf(url) === 0 return this.$route.path.indexOf(url) === 0
}, },
unfolded: function() { toggleMobileMenuView() {
document.getElementById('nav-search-box').classList.add('unfolded') this.toggleMobileMenu = !this.toggleMobileMenu
}, },
foldedup: function() { redirectToRoot() {
document.getElementById('nav-search-box').classList.remove('unfolded') this.$router.replace('/')
this.fetchPosts({ i18n: this.$i18n, filter: {} })
},
},
apollo: {
Category: {
query() {
return CategoryQuery()
},
fetchPolicy: 'cache-and-network',
}, },
}, },
} }
</script> </script>
<style>
.unfolded {
position: absolute;
right: 0px;
left: 0px;
z-index: 1;
}
</style>
<style lang="scss"> <style lang="scss">
.topbar-locale-switch { .topbar-locale-switch {
@ -228,7 +269,7 @@ export default {
.main-container { .main-container {
padding-top: 6rem; padding-top: 6rem;
padding-botton: 5rem; padding-bottom: 5rem;
} }
.main-navigation { .main-navigation {
@ -242,6 +283,14 @@ export default {
flex: 1; flex: 1;
} }
.main-navigation-right .desktop-view {
float: right;
}
.avatar-menu {
margin: 2px 0px 0px 5px;
}
.avatar-menu-trigger { .avatar-menu-trigger {
user-select: none; user-select: none;
display: flex; display: flex;
@ -285,6 +334,24 @@ export default {
} }
} }
} }
@media only screen and (min-width: 960px) {
.mobile-hamburger-menu {
display: none;
}
}
@media only screen and (max-width: 960px) {
#nav-search-box,
.main-navigation-right {
margin: 10px 0px;
}
.hide-mobile-menu {
display: none;
}
}
.ds-footer { .ds-footer {
text-align: center; text-align: center;
position: fixed; position: fixed;

View File

@ -4,6 +4,10 @@
"hashtag-search": "Suche nach #{hashtag}", "hashtag-search": "Suche nach #{hashtag}",
"clearSearch": "Suche löschen" "clearSearch": "Suche löschen"
}, },
"filter-posts": {
"header": "Themenkategorien",
"all": "Alle"
},
"site": { "site": {
"made": "Mit &#10084; gemacht", "made": "Mit &#10084; gemacht",
"imprint": "Impressum", "imprint": "Impressum",
@ -188,7 +192,19 @@
"name": "Organisationen" "name": "Organisationen"
}, },
"users": { "users": {
"name": "Benutzer" "name": "Benutzer",
"form": {
"placeholder": "E-Mail, Name oder Beschreibung"
},
"table": {
"columns": {
"name": "Name",
"slug": "Slug",
"role": "Rolle",
"createdAt": "Erstellt am"
}
},
"empty": "Keine Benutzer gefunden"
}, },
"pages": { "pages": {
"name": "Seiten" "name": "Seiten"
@ -396,5 +412,4 @@
"terms": { "terms": {
"text": "<div ><ol><li><strong>UNFALLGEFAHR: </strong>Das ist eine Testversion! Alle Daten, Dein Profil und die Server können jederzeit komplett vernichtet, verloren, verbrannt und vielleicht auch in der Nähe von Alpha Centauri synchronisiert werden. Die Benutzung läuft auf eigene Gefahr. Mit kommerziellen Nebenwirkungen ist jedoch nicht zu rechnen.</li><br><li><strong>DU UND DEINE DATEN: </strong>Bitte beachte, dass wir die Inhalte der Alphaversion zu Werbezwecken, Webpräsentationen usw. verwenden, aber wir glauben, dass das auch in Deinem Interesse ist. Am besten keinen Nachnamen eingeben und bei noch mehr Datensparsamkeit ein Profilfoto ohne Identität verwenden. Mehr in unserer <a href='/pages/privacy' target='_blank'>Datenschutzerklärung</a>.</li><br><li><strong>BAUSTELLEN: </strong>Das ist immer noch eine Testversion. Wenn etwas nicht funktioniert, blockiert, irritiert, falsch angezeigt, verbogen oder nicht anklickbar ist, bitten wir dies zu entschuldigen. Fehler, Käfer und Bugs bitte melden! <a href='http://localhost:3000/%22https://human-connection.org/alpha/#bugreport%5C%22' target='_blank'>https://human-connection.org/support</a></li><br><li><strong>VERHALTENSCODEX</strong>: Die Verhaltensregeln dienen als Leitsätze für den persönlichen Auftritt und den Umgang untereinander. Wer als Nutzer im Human Connection Netzwerk aktiv ist, Beiträge verfasst, kommentiert oder mit anderen Nutzern, auch außerhalb des Netzwerkes, Kontakt aufnimmt, erkennt diese Verhaltensregeln als verbindlich an: <a href='https://alpha.human-connection.org/pages/code-of-conduct' target='_blank'>https://alpha.human-connection.org/pages/code-of-conduct</a></li><br><li><strong>MODERATION: </strong>Solange kein Community-Moderationssystem lauffähig ist, entscheidet ein Regenbogen-Einhorn darüber, ob Du körperlich und psychisch stabil genug bist, unsere Testversion zu bedienen. Das Einhorn kann Dich jederzeit von der Alpha entfernen. Also sei nett und lass Regenbogenfutter da!</li><br><li><strong>FAIRNESS: </strong>Sollte Dir die Alphaversion unseres Netzwerks wider Erwarten, egal aus welchen Gründen, nicht gefallen, überweisen wir Dir Deine gespendeten Monatsbeiträge innerhalb der ersten 2 Monate gerne zurück. Einfach Mail an: <a href='mailto:info@human-connection.org' target='_blank'>info@human-connection.org </a><strong>Achtung: Viele Funktionen werden erst nach und nach eingebaut. </strong></li><br><li><strong>FRAGEN?</strong> Die Termine und Links zu den Zoom-Räumen findest Du hier: <a href='http://localhost:3000/%22https://human-connection.org/events-und-news//%22' target='_blank'>https://human-connection.org/veranstaltungen/</a></li><br><li><strong>VON MENSCHEN FÜR MENSCHEN: </strong>Bitte hilf uns weitere monatlichen Spender für Human Connection zu bekommen, damit das Netzwerk so schnell wie möglich offiziell an den Start gehen kann. <a href='http://localhost:3000/%22https://human-connection.org/alpha/#bugreport%5C%22' target='_blank'>https://human-connection.org</a></li></ol><p>Jetzt aber viel Spaß mit der Alpha von Human Connection! Für den ersten Weltfrieden. ♥︎</p><br><p><strong>Herzlichst,</strong></p><p><strong>Euer Human Connection Team</strong></p></div>" "text": "<div ><ol><li><strong>UNFALLGEFAHR: </strong>Das ist eine Testversion! Alle Daten, Dein Profil und die Server können jederzeit komplett vernichtet, verloren, verbrannt und vielleicht auch in der Nähe von Alpha Centauri synchronisiert werden. Die Benutzung läuft auf eigene Gefahr. Mit kommerziellen Nebenwirkungen ist jedoch nicht zu rechnen.</li><br><li><strong>DU UND DEINE DATEN: </strong>Bitte beachte, dass wir die Inhalte der Alphaversion zu Werbezwecken, Webpräsentationen usw. verwenden, aber wir glauben, dass das auch in Deinem Interesse ist. Am besten keinen Nachnamen eingeben und bei noch mehr Datensparsamkeit ein Profilfoto ohne Identität verwenden. Mehr in unserer <a href='/pages/privacy' target='_blank'>Datenschutzerklärung</a>.</li><br><li><strong>BAUSTELLEN: </strong>Das ist immer noch eine Testversion. Wenn etwas nicht funktioniert, blockiert, irritiert, falsch angezeigt, verbogen oder nicht anklickbar ist, bitten wir dies zu entschuldigen. Fehler, Käfer und Bugs bitte melden! <a href='http://localhost:3000/%22https://human-connection.org/alpha/#bugreport%5C%22' target='_blank'>https://human-connection.org/support</a></li><br><li><strong>VERHALTENSCODEX</strong>: Die Verhaltensregeln dienen als Leitsätze für den persönlichen Auftritt und den Umgang untereinander. Wer als Nutzer im Human Connection Netzwerk aktiv ist, Beiträge verfasst, kommentiert oder mit anderen Nutzern, auch außerhalb des Netzwerkes, Kontakt aufnimmt, erkennt diese Verhaltensregeln als verbindlich an: <a href='https://alpha.human-connection.org/pages/code-of-conduct' target='_blank'>https://alpha.human-connection.org/pages/code-of-conduct</a></li><br><li><strong>MODERATION: </strong>Solange kein Community-Moderationssystem lauffähig ist, entscheidet ein Regenbogen-Einhorn darüber, ob Du körperlich und psychisch stabil genug bist, unsere Testversion zu bedienen. Das Einhorn kann Dich jederzeit von der Alpha entfernen. Also sei nett und lass Regenbogenfutter da!</li><br><li><strong>FAIRNESS: </strong>Sollte Dir die Alphaversion unseres Netzwerks wider Erwarten, egal aus welchen Gründen, nicht gefallen, überweisen wir Dir Deine gespendeten Monatsbeiträge innerhalb der ersten 2 Monate gerne zurück. Einfach Mail an: <a href='mailto:info@human-connection.org' target='_blank'>info@human-connection.org </a><strong>Achtung: Viele Funktionen werden erst nach und nach eingebaut. </strong></li><br><li><strong>FRAGEN?</strong> Die Termine und Links zu den Zoom-Räumen findest Du hier: <a href='http://localhost:3000/%22https://human-connection.org/events-und-news//%22' target='_blank'>https://human-connection.org/veranstaltungen/</a></li><br><li><strong>VON MENSCHEN FÜR MENSCHEN: </strong>Bitte hilf uns weitere monatlichen Spender für Human Connection zu bekommen, damit das Netzwerk so schnell wie möglich offiziell an den Start gehen kann. <a href='http://localhost:3000/%22https://human-connection.org/alpha/#bugreport%5C%22' target='_blank'>https://human-connection.org</a></li></ol><p>Jetzt aber viel Spaß mit der Alpha von Human Connection! Für den ersten Weltfrieden. ♥︎</p><br><p><strong>Herzlichst,</strong></p><p><strong>Euer Human Connection Team</strong></p></div>"
} }
} }

View File

@ -4,6 +4,10 @@
"hashtag-search": "Searching for #{hashtag}", "hashtag-search": "Searching for #{hashtag}",
"clearSearch": "Clear search" "clearSearch": "Clear search"
}, },
"filter-posts": {
"header": "Categories of Content",
"all": "All"
},
"site": { "site": {
"made": "Made with &#10084;", "made": "Made with &#10084;",
"imprint": "Imprint", "imprint": "Imprint",
@ -14,8 +18,8 @@
"tribunal": "Registry court", "tribunal": "Registry court",
"register": "Registry number", "register": "Registry number",
"director": "Managing Director", "director": "Managing Director",
"taxident": "Value added tax identification number according to § 27 a Value Added Tax Act (Germany)", "taxident": "USt-ID. according to §27a of the German Sales Tax Law:",
"responsible": "Responsible according to § 55 Abs. 2 RStV (Germany) ", "responsible": "responsible for contents of this page (§ 55 Abs. 2 RStV)",
"bank": "bank account", "bank": "bank account",
"germany": "Germany" "germany": "Germany"
}, },
@ -189,7 +193,19 @@
"name": "Organizations" "name": "Organizations"
}, },
"users": { "users": {
"name": "Users" "name": "Users",
"form": {
"placeholder": "E-Mail, name or description"
},
"table": {
"columns": {
"name": "Name",
"slug": "Slug",
"role": "Role",
"createdAt": "Created at"
}
},
"empty": "No users found"
}, },
"pages": { "pages": {
"name": "Pages" "name": "Pages"
@ -230,7 +246,8 @@
}, },
"comment": { "comment": {
"submit": "Comment", "submit": "Comment",
"submitted": "Comment Submitted" "submitted": "Comment Submitted",
"updated": "Changes Saved"
} }
}, },
"comment": { "comment": {
@ -245,7 +262,6 @@
"more": "show more", "more": "show more",
"less": "show less" "less": "show less"
} }
}, },
"quotes": { "quotes": {
"african": { "african": {

View File

@ -1,4 +1,7 @@
{ {
"filter-menu": {
"title": "Twoja bańka filtrująca"
},
"site": { "site": {
"made": "Z &#10084; zrobiony", "made": "Z &#10084; zrobiony",
"imprint": "Nadruk", "imprint": "Nadruk",
@ -13,6 +16,7 @@
"responsible": "Odpowiedzialny zgodnie z § 55 Abs. 2 RStV (Niemcy)", "responsible": "Odpowiedzialny zgodnie z § 55 Abs. 2 RStV (Niemcy)",
"bank": "rachunek bankowy", "bank": "rachunek bankowy",
"germany": "Niemcy" "germany": "Niemcy"
}, },
"login": { "login": {
"copy": "Jeśli masz już konto Human Connection, zaloguj się tutaj.", "copy": "Jeśli masz już konto Human Connection, zaloguj się tutaj.",
@ -20,9 +24,35 @@
"logout": "Wyloguj się", "logout": "Wyloguj się",
"email": "Twój adres e-mail", "email": "Twój adres e-mail",
"password": "Twoje hasło", "password": "Twoje hasło",
"forgotPassword": "Zapomniałeś hasła?",
"moreInfo": "Co to jest Human Connection?", "moreInfo": "Co to jest Human Connection?",
"moreInfoURL": "https://human-connection.org/pl/",
"moreInfoHint": "na stronę prezentacji",
"hello": "Cześć" "hello": "Cześć"
}, },
"password-reset": {
"title": "Zresetuj hasło",
"form": {
"description": "Na podany adres e-mail zostanie wysłany email z resetem hasła.",
"submit": "Poproś o wiadomość e-mail",
"submitted": "Na adres <b>{email}</b> została wysłana wiadomość z dalszymi instrukcjami."
}
},
"verify-code": {
"form": {
"code": "Wprowadź swój kod",
"description": "Otwórz swoją skrzynkę odbiorczą i wpisz kod, który do Ciebie wysłaliśmy.",
"next": "Kontynuuj",
"change-password": {
"success": "Zmiana hasła zakończyła się sukcesem!",
"error": "Zmiana hasła nie powiodła się. Może kod bezpieczeństwa nie był poprawny?",
"help": "W przypadku problemów, zachęcamy do zwrócenia się o pomoc, wysyłając do nas wiadomość e-mail:"
}
}
},
"editor": {
"placeholder": "Zostaw swoje inspirujące myśli...."
},
"profile": { "profile": {
"name": "Mój profil", "name": "Mój profil",
"memberSince": "Członek od", "memberSince": "Członek od",
@ -31,7 +61,27 @@
"following": "Obserwowani", "following": "Obserwowani",
"shouted": "Krzyknij", "shouted": "Krzyknij",
"commented": "Skomentuj", "commented": "Skomentuj",
"userAnonym": "Anonymous" "userAnonym": "Anonimowy",
"socialMedia": "Gdzie indziej mogę znaleźć",
"network": {
"title": "Sieć",
"following": "jest następująca:",
"followingNobody": "nie podąża za nikim.",
"followedBy": "po którym następuje:",
"followedByNobody": "nie jest śledzona przez nikogo.",
"and": "i",
"more": "więcej"
}
},
"notifications": {
"menu": {
"mentioned": "wspomniała o tobie na posterunku."
}
},
"search": {
"placeholder": "Wyszukiwanie",
"hint": "Czego szukasz?",
"failed": "Nic nie znaleziono"
}, },
"settings": { "settings": {
"name": "Ustawienia", "name": "Ustawienia",
@ -40,10 +90,28 @@
"labelName": "Twoje dane", "labelName": "Twoje dane",
"namePlaceholder": "Anonymous", "namePlaceholder": "Anonymous",
"labelCity": "Twoje miasto lub region", "labelCity": "Twoje miasto lub region",
"labelBio": "O Tobie" "labelBio": "O Tobie",
"success": "Twoje dane zostały pomyślnie zaktualizowane!"
}, },
"security": { "security": {
"name": "Bezpieczeństwo" "name": "Bezpieczeństwo",
"change-password": {
"button": "Zmień hasło",
"success": "Hasło zostało pomyślnie zmienione!",
"label-old-password": "Twoje stare hasło",
"label-new-password": "Twoje nowe hasło",
"label-new-password-confirm": "Potwierdź nowe hasło",
"message-old-password-required": "Wprowadź swoje stare hasło",
"message-new-password-required": "Wprowadź nowe hasło",
"message-new-password-confirm-required": "Potwierdź nowe hasło.",
"message-new-password-missmatch": "Wpisz ponownie to samo hasło.",
"passwordSecurity": "Zabezpieczenie hasłem",
"passwordStrength0": "Bardzo niepewne hasło",
"passwordStrength1": "Niepewne hasło",
"passwordStrength2": "Hasło pośredniczące",
"passwordStrength3": "Silne hasło",
"passwordStrength4": "Bardzo mocne hasło"
}
}, },
"invites": { "invites": {
"name": "Zaproszenia" "name": "Zaproszenia"
@ -51,29 +119,42 @@
"download": { "download": {
"name": "Pobierz dane" "name": "Pobierz dane"
}, },
"delete": { "deleteUserAccount": {
"name": "Usuń konto" "name": "Usuwanie danych",
"contributionsCount": "Usuń moje stanowiska.",
"commentsCount": "Usuń moje komentarze {liczba}.",
"accountDescription": "Bądź świadomy, że Twój post i komentarze są ważne dla naszej społeczności. Jeśli nadal chcesz je usunąć, musisz zaznaczyć je poniżej.",
"accountWarning": "<b>Nie możesz zarządzać</b> i <b>Nie możesz REKOVER</b> swoje konto, posty lub komentarze po usunięciu konta!",
"success": "Konto zostało pomyślnie usunięte",
"pleaseConfirm": "<b class='is-danger'>Niszczycielskie działanie!</b> Typ <b>{potwierdź}</b> aby potwierdzić"
}, },
"organizations": { "organizations": {
"name": "Moje organizacje" "name": "My Organizations"
}, },
"languages": { "languages": {
"name": "Języki" "name": "Languages"
},
"social-media": {
"name": "Social media",
"placeholder": "Add social media url",
"submit": "Add link",
"successAdd": "Added social media. Updated user profile!",
"successDelete": "Deleted social media. Updated user profile!"
} }
}, },
"admin": { "admin": {
"name": "Administrator", "name": "Admin",
"dashboard": { "dashboard": {
"name": "Tablica rozdzielcza", "name": "Tablica rozdzielcza",
"users": "Użytkownicy", "users": "Użytkownicy",
"posts": "Posty", "posts": "Stanowiska",
"comments": "Komentarze", "comments": "Komentarze",
"notifications": "Powiadomienia", "notifications": "Powiadomienia",
"organizations": "Organizacje", "organizations": "Organizacje",
"projects": "Projekty", "projects": "Projekty",
"invites": "Zaproszenia", "invites": "Zaprasza",
"follows": "Obserwowań", "follows": "Podąża za",
"shouts": "Okrzyk" "shouts": "Zalecane"
}, },
"organizations": { "organizations": {
"name": "Organizacje" "name": "Organizacje"
@ -90,116 +171,193 @@
"categories": { "categories": {
"name": "Kategorie", "name": "Kategorie",
"categoryName": "Nazwa", "categoryName": "Nazwa",
"postCount": "Posty" "postCount": "Stanowiska"
}, },
"tags": { "tags": {
"name": "Tagi", "name": "Znaczniki",
"tagCountUnique": "Użytkownicy", "tagCountUnique": "Użytkownicy",
"tagCount": "Posty" "tagCount": "Stanowiska"
}, },
"settings": { "settings": {
"name": "Ustawienia" "name": "Ustawienia"
} }
}, },
"post": { "post": {
"name": "Post", "name": "Poczta",
"moreInfo": { "moreInfo": {
"name": "Więcej informacji" "name": "Więcej informacji"
}, },
"takeAction": { "takeAction": {
"name": "Podejmij działanie" "name": "Podejmij działania"
},
"menu": {
"edit": "Edytuj Post",
"delete": "Usuń wpis"
},
"comment": {
"submit": "Komentarz",
"submitted": "Przedłożony komentarz"
} }
}, },
"comment": {
"content": {
"unavailable-placeholder": " ...ten komentarz nie jest już dostępny."
},
"menu": {
"edit": "Edytuj komentarz",
"delete": "Usuń komentarz"
},
"show": {
"more": "Pokaż więcej",
"less": "Pokaż mniej"
}
},
"quotes": { "quotes": {
"african": { "african": {
"quote": "Wielu małych ludzi w wielu małych miejscach robi wiele małych rzeczy, które mogą zmienić oblicze świata.", "quote": "Wielu małych ludzi w wielu małych miejscowościach robi wiele małych rzeczy, które mogą zmienić oblicze świata.",
"author": "Afrykańskie przysłowie" "author": "Afrykańskie przysłowie"
} }
}, },
"common": { "common": {
"post": "Post ::: Posty", "post": "Poczta ::: Posty",
"comment": "Komentarz ::: Komentarze", "comment": "Komentarz ::: Komentarze",
"letsTalk": "Porozmawiajmy", "letsTalk": "Porozmawiajmy",
"versus": "Versus", "versus": "werset",
"moreInfo": "Więcej informacji", "moreInfo": "Więcej informacji",
"takeAction": "Podejmij działanie", "takeAction": "Podejmij działania",
"shout": "okrzyk okrzyki", "shout": "przekazanie sprawy ::: Polecam tę stronę",
"user": "Użytkownik ::: Użytkownicy", "user": "Użytkownik ::: Użytkownicy",
"category": "kategoria kategorie", "category": "Kategoria ::: Kategorie",
"organization": "Organizacja ::: Organizacje", "organization": "Organization ::: Organizations",
"project": "Projekt ::: Projekty", "project": "Projekt ::: Projekty",
"tag": "Tag ::: Tagi", "tag": "Znacznik ::: Znaczniki",
"name": "imię", "name": "Nazwa",
"loadMore": "załaduj więcej", "loadMore": "Obciążenie więcej",
"loading": "ładowanie", "loading": "załadunek",
"reportContent": "Raport" "reportContent": "Sprawozdanie",
"validations": {
"email": "musi być ważny adres e-mail.",
"verification-code": "musi mieć długość 6 znaków."
}
}, },
"actions": { "actions": {
"loading": "ładowanie", "loading": "załadunek",
"loadMore": "załaduj więcej", "loadMore": "Obciążenie więcej",
"create": "Stwórz", "create": "Tworzenie",
"save": "Zapisz", "save": "Oszczędzaj",
"edit": "Edytuj", "edit": "Edycja",
"delete": "Usuń", "delete": "Usuń",
"cancel": "Anuluj" "cancel": "Odwołaj"
}, },
"moderation": { "moderation": {
"name": "Moderacja", "name": "Umiarkowanie",
"reports": { "reports": {
"empty": "Gratulacje, moderacja nie jest potrzebna", "empty": "Gratulacje, nic do umiarkowanego.",
"name": "Raporty", "name": "Sprawozdania",
"reporter": "zgłoszone przez" "submitter": "zgłaszane przez",
"disabledBy": "niepełnosprawni przez"
} }
}, },
"disable": { "disable": {
"submit": "Niepełnosprawność",
"cancel": "Odwołaj",
"success": "Niepełnosprawni skutecznie",
"user": { "user": {
"title": "Ukryj użytkownika", "title": "Wyłączenie użytkownika",
"type": "Użytkownik", "type": "Użytkownik",
"message": "Czy na pewno chcesz wyłączyć użytkownika \" <b> {name} </b> \"?" "message": "Czy naprawdę chcesz wyłączyć użytkownika \"<b>{name}</b>\"?"
}, },
"contribution": { "contribution": {
"title": "Ukryj wpis", "title": "Wyłącz Wkład",
"type": "Wpis / Post", "type": "Wkład",
"message": "Czy na pewno chcesz ukryć wpis \" <b> tytuł} </b> \"?" "message": "Naprawdę chcesz unieszkodliwić ten wkład \"<b>{name}</b>\"?"
}, },
"comment": { "comment": {
"title": "Ukryj wpis", "title": "Wyłącz komentarz",
"type": "Komentarz", "type": "Komentarz",
"message": "Czy na pewno chcesz ukryć komentarz użytkownika\"<b>(Imie/Avatar</b>\"?" "message": "Naprawdę chcesz wyłączyć komentarz \"<b>{name}</b>\"?"
}
},
"delete": {
"submit": "Usuń",
"cancel": "Odwołaj",
"contribution": {
"title": "Usuń Post",
"type": "Wkład",
"message": "Naprawdę chcesz usunąć post \"<b>{name}</b>\"?",
"success": "Wyślij pomyślnie usunięty!"
},
"comment": {
"title": "Usuń komentarz",
"type": "Komentarz",
"message": "Czy naprawdę chcesz usunąć komentarz \"<b>{name}</b>\"?",
"success": "Komentarz został pomyślnie usunięty!"
} }
}, },
"report": { "report": {
"submit": "Wyślij raport", "submit": "Sprawozdanie",
"cancel": "Anuluj", "cancel": "Odwołaj",
"success": "Dzięki za zgłoszenie!",
"user": { "user": {
"title": "Zgłoś użytkownika", "title": "Raport Użytkownik",
"type": "Użytkownik", "type": "Użytkownik",
"message": "Czy na pewno chcesz zgłosić użytkownika \" <b> {Imie} </b> \"?" "message": "Naprawdę chcesz zgłosić użytkownika \"<b>{name}</b>\"?",
"error": "Zgłosiłeś już użytkownika!"
}, },
"contribution": { "contribution": {
"title": "Zgłoś wpis", "title": "Wkład w raport",
"type": "Wpis / Post", "type": "Wkład",
"message": "Czy na pewno chcesz zgłosić ten wpis użytkownika \" <b> {Imie} </b> \"?" "message": "Naprawdę chcesz zgłosić wkład, jaki wniosłaś do programu \"<b>{name}</b>\"?",
"error": "Zgłosiłeś już ten wkład!"
}, },
"comment": { "comment": {
"title": "Zgłoś komentarz", "title": "Sprawozdanie Komentarz",
"type": "Komentarz", "type": "Komentarz",
"message": "Czy na pewno chcesz zgłosić komentarz użytkownika\"<b>(Imie/Avatar</b>\"?" "message": "Naprawdę chcesz zgłosić komentarz od \"<b>{name}</b>\"?",
"error": "Zgłosiłeś już komentarz!"
} }
}, },
"contribution": {
"edit": "Edytuj wpis",
"delete": "Usuń wpis"
},
"comment": {
"edit": "Edytuj komentarz",
"delete": "Usuń komentarz"
},
"followButton": { "followButton": {
"follow": "Obserwuj", "follow": "naśladować",
"following": "Obserwowani" "following": "w skutek"
}, },
"shoutButton": { "shoutButton": {
"shouted": "krzyczeć" "shouted": "wykrzyczany"
},
"release": {
"submit": "Zwolnienie",
"cancel": "Odwołaj",
"success": "Wydany z powodzeniem!",
"user": {
"title": "Zwolnienie użytkownika",
"type": "Użytkownik",
"message": "Naprawdę chcesz uwolnić użytkownika \"<b>{name}</b>\"?"
},
"contribution": {
"title": "Zwolnienie Wkład",
"type": "Wkład",
"message": "Naprawdę chcesz uwolnić swój wkład \"<b>{name}</b>\"?"
},
"comment": {
"title": "Zwolnienie komentarz",
"type": "komentarz",
"message": "Czy naprawdę chcesz opublikować komentarz od \"<b>{name}</b>\"?"
}
},
"user": {
"avatar": {
"submitted": "Przesłanie udane"
}
},
"contribution": {
"newPost": "Utwórz nowy post",
"filterFollow": "Filtrowanie wkładu użytkowników, za którymi podążam",
"filterALL": "Wyświetl wszystkie wkłady",
"success": "Zachowany!",
"languageSelectLabel": "Język",
"categories": {
"infoSelectedNoOfMaxCategories": "{chosen} z {max} wybrane kategorie"
}
} }
} }

View File

@ -10,7 +10,10 @@ const styleguideStyles = process.env.STYLEGUIDE_DEV
] ]
: '@human-connection/styleguide/dist/shared.scss' : '@human-connection/styleguide/dist/shared.scss'
const buildDir = process.env.NUXT_BUILD || '.nuxt'
module.exports = { module.exports = {
buildDir,
mode: 'universal', mode: 'universal',
dev: dev, dev: dev,

View File

@ -29,7 +29,6 @@
"!**/?(*.)+(spec|test).js?(x)" "!**/?(*.)+(spec|test).js?(x)"
], ],
"coverageReporters": [ "coverageReporters": [
"text",
"lcov" "lcov"
], ],
"transform": { "transform": {
@ -60,11 +59,12 @@
"apollo-client": "~2.6.3", "apollo-client": "~2.6.3",
"cookie-universal-nuxt": "~2.0.16", "cookie-universal-nuxt": "~2.0.16",
"cross-env": "~5.2.0", "cross-env": "~5.2.0",
"date-fns": "2.0.0-beta.2", "date-fns": "2.0.0-beta.3",
"express": "~4.17.1", "express": "~4.17.1",
"graphql": "~14.4.2", "graphql": "~14.4.2",
"isemail": "^3.2.0",
"jsonwebtoken": "~8.5.1", "jsonwebtoken": "~8.5.1",
"linkify-it": "~2.1.0", "linkify-it": "~2.2.0",
"nuxt": "~2.8.1", "nuxt": "~2.8.1",
"nuxt-dropzone": "^1.0.2", "nuxt-dropzone": "^1.0.2",
"nuxt-env": "~0.1.0", "nuxt-env": "~0.1.0",
@ -80,11 +80,11 @@
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "~7.5.4", "@babel/core": "~7.5.5",
"@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "~7.5.4", "@babel/preset-env": "~7.5.5",
"@vue/cli-shared-utils": "~3.9.0", "@vue/cli-shared-utils": "~3.9.0",
"@vue/eslint-config-prettier": "~4.0.1", "@vue/eslint-config-prettier": "~5.0.0",
"@vue/server-test-utils": "~1.0.0-beta.29", "@vue/server-test-utils": "~1.0.0-beta.29",
"@vue/test-utils": "~1.0.0-beta.29", "@vue/test-utils": "~1.0.0-beta.29",
"babel-core": "~7.0.0-bridge.0", "babel-core": "~7.0.0-bridge.0",
@ -94,8 +94,8 @@
"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-loader": "~2.2.1", "eslint-loader": "~2.2.1",
"eslint-plugin-import": "~2.18.0", "eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.7.2", "eslint-plugin-jest": "~22.13.6",
"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",

View File

@ -24,11 +24,10 @@ export default {
name: this.$t('admin.dashboard.name'), name: this.$t('admin.dashboard.name'),
path: `/admin`, path: `/admin`,
}, },
// TODO implement {
/* {
name: this.$t('admin.users.name'), name: this.$t('admin.users.name'),
path: `/admin/users` path: `/admin/users`,
}, */ },
// TODO implement // TODO implement
/* { /* {
name: this.$t('admin.organizations.name'), name: this.$t('admin.organizations.name'),

View File

@ -0,0 +1,70 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Users from './users.vue'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('Users', () => {
let wrapper
let Wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
$apollo: {
loading: false,
},
}
})
describe('mount', () => {
Wrapper = () => {
return mount(Users, {
mocks,
localVue,
})
}
it('renders', () => {
wrapper = Wrapper()
expect(wrapper.is('div')).toBe(true)
})
describe('search', () => {
let searchAction
beforeEach(() => {
searchAction = (wrapper, { query }) => {
wrapper.find('input').setValue(query)
wrapper.find('form').trigger('submit')
return wrapper
}
})
describe('query looks like an email address', () => {
it('searches users for exact email address', async () => {
const wrapper = await searchAction(Wrapper(), { query: 'email@example.org' })
expect(wrapper.vm.email).toEqual('email@example.org')
expect(wrapper.vm.filter).toBe(null)
})
})
describe('query is just text', () => {
it('tries to find matching users by `name`, `slug` or `about`', async () => {
const wrapper = await searchAction(await Wrapper(), { query: 'Find me' })
const expected = {
OR: [
{ name_contains: 'Find me' },
{ slug_contains: 'Find me' },
{ about_contains: 'Find me' },
],
}
expect(wrapper.vm.email).toBe(null)
expect(wrapper.vm.filter).toEqual(expected)
})
})
})
})
})

View File

@ -1,15 +1,173 @@
<template> <template>
<div>
<ds-space>
<ds-card :header="$t('admin.users.name')"> <ds-card :header="$t('admin.users.name')">
<hc-empty icon="tasks" message="Coming Soon…" /> <ds-form v-model="form" @submit="submit">
<ds-flex gutter="small">
<ds-flex-item width="90%">
<ds-input
model="query"
:placeholder="$t('admin.users.form.placeholder')"
icon="search"
/>
</ds-flex-item>
<ds-flex-item width="30px">
<ds-button primary type="submit" icon="search" :loading="$apollo.loading" />
</ds-flex-item>
</ds-flex>
</ds-form>
</ds-card> </ds-card>
</ds-space>
<ds-card v-if="User && User.length">
<ds-table :data="User" :fields="fields" condensed>
<template slot="index" slot-scope="scope">
{{ scope.row.index }}.
</template>
<template slot="name" slot-scope="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
}"
>
<b>{{ scope.row.name | truncate(20) }}</b>
</nuxt-link>
</template>
<template slot="slug" slot-scope="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
}"
>
<b>{{ scope.row.slug | truncate(20) }}</b>
</nuxt-link>
</template>
<template slot="createdAt" slot-scope="scope">
{{ scope.row.createdAt | dateTime }}
</template>
</ds-table>
<ds-flex direction="row-reverse">
<ds-flex-item width="50px">
<ds-button @click="next" :disabled="!hasNext" icon="arrow-right" primary />
</ds-flex-item>
<ds-flex-item width="50px">
<ds-button @click="back" :disabled="!hasPrevious" icon="arrow-left" primary />
</ds-flex-item>
</ds-flex>
</ds-card>
<ds-card v-else>
<ds-placeholder>
{{ $t('admin.users.empty') }}
</ds-placeholder>
</ds-card>
</div>
</template> </template>
<script> <script>
import HcEmpty from '~/components/Empty.vue' import gql from 'graphql-tag'
import isemail from 'isemail'
export default { export default {
components: { data() {
HcEmpty, const pageSize = 15
return {
offset: 0,
pageSize,
first: pageSize,
User: [],
hasNext: false,
email: null,
filter: null,
form: {
formData: {
query: '',
},
},
}
},
computed: {
hasPrevious() {
return this.offset > 0
},
fields() {
return {
index: '#',
name: this.$t('admin.users.table.columns.name'),
slug: this.$t('admin.users.table.columns.slug'),
createdAt: this.$t('admin.users.table.columns.createdAt'),
contributionsCount: {
label: '🖉',
align: 'right',
},
commentedCount: {
label: '🗨',
align: 'right',
},
shoutedCount: {
label: '❤',
align: 'right',
},
role: {
label: this.$t('admin.users.table.columns.role'),
align: 'right',
},
}
},
},
apollo: {
User: {
query() {
return gql(`
query($filter: _UserFilter, $first: Int, $offset: Int, $email: String) {
User(email: $email, filter: $filter, first: $first, offset: $offset, orderBy: createdAt_desc) {
id
name
slug
role
createdAt
contributionsCount
commentedCount
shoutedCount
}
}
`)
},
variables() {
const { offset, first, email, filter } = this
const variables = { first, offset }
if (email) variables.email = email
if (filter) variables.filter = filter
return variables
},
update({ User }) {
if (!User) return []
this.hasNext = User.length >= this.pageSize
if (User.length <= 0 && this.offset > 0) return this.User // edge case, avoid a blank page
return User.map((u, i) => Object.assign({}, u, { index: this.offset + i }))
},
},
},
methods: {
back() {
this.offset = Math.max(this.offset - this.pageSize, 0)
},
next() {
this.offset += this.pageSize
},
submit(formData) {
this.offset = 0
const { query } = formData
if (isemail.validate(query)) {
this.email = query
this.filter = null
} else {
this.email = null
this.filter = {
OR: [{ name_contains: query }, { slug_contains: query }, { about_contains: query }],
}
}
},
}, },
} }
</script> </script>

View File

@ -10,7 +10,7 @@
/> />
</ds-flex-item> </ds-flex-item>
<hc-post-card <hc-post-card
v-for="(post, index) in uniq(Post)" v-for="(post, index) in posts"
:key="post.id" :key="post.id"
:post="post" :post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }" :width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
@ -33,11 +33,11 @@
<script> <script>
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue' import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
import gql from 'graphql-tag'
import uniqBy from 'lodash/uniqBy' import uniqBy from 'lodash/uniqBy'
import HcPostCard from '~/components/PostCard' import HcPostCard from '~/components/PostCard'
import HcLoadMore from '~/components/LoadMore.vue' import HcLoadMore from '~/components/LoadMore.vue'
import { mapGetters } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { filterPosts } from '~/graphql/PostQuery.js'
export default { export default {
components: { components: {
@ -49,7 +49,6 @@ export default {
const { hashtag = null } = this.$route.query const { hashtag = null } = this.$route.query
return { return {
// Initialize your apollo data // Initialize your apollo data
Post: [],
page: 1, page: 1,
pageSize: 12, pageSize: 12,
filter: {}, filter: {},
@ -61,18 +60,27 @@ export default {
this.changeFilterBubble({ tags_some: { name: this.hashtag } }) this.changeFilterBubble({ tags_some: { name: this.hashtag } })
} }
}, },
watch: {
Post(post) {
this.setPosts(this.Post)
},
},
computed: { computed: {
...mapGetters({ ...mapGetters({
currentUser: 'auth/user', currentUser: 'auth/user',
posts: 'posts/posts',
}), }),
tags() { tags() {
return this.Post ? this.Post[0].tags.map(tag => tag.name) : '-' return this.posts ? this.posts.tags.map(tag => tag.name) : '-'
}, },
offset() { offset() {
return (this.page - 1) * this.pageSize return (this.page - 1) * this.pageSize
}, },
}, },
methods: { methods: {
...mapMutations({
setPosts: 'posts/SET_POSTS',
}),
changeFilterBubble(filter) { changeFilterBubble(filter) {
if (this.hashtag) { if (this.hashtag) {
filter = { filter = {
@ -129,48 +137,7 @@ export default {
apollo: { apollo: {
Post: { Post: {
query() { query() {
return gql(` return filterPosts(this.$i18n)
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
Post(filter: $filter, first: $first, offset: $offset) {
id
title
contentExcerpt
createdAt
disabled
deleted
slug
image
author {
id
avatar
slug
name
disabled
deleted
contributionsCount
shoutedCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${this.$i18n.locale().toUpperCase()}
}
badges {
id
key
icon
}
}
commentsCount
categories {
id
name
icon
}
shoutedCount
}
}
`)
}, },
variables() { variables() {
return { return {

View File

@ -111,7 +111,6 @@ export default {
} }
badges { badges {
id id
key
icon icon
} }
} }

View File

@ -206,7 +206,7 @@
:key="post.id" :key="post.id"
:post="post" :post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }" :width="{ base: '100%', md: '100%', xl: '50%' }"
@removePostFromList="activePosts.splice(index, 1)" @removePostFromList="removePostFromList(index)"
/> />
</template> </template>
<template v-else-if="$apollo.loading"> <template v-else-if="$apollo.loading">
@ -331,6 +331,10 @@ export default {
}, },
}, },
methods: { methods: {
removePostFromList(index) {
this.activePosts.splice(index, 1)
this.$apollo.queries.User.refetch()
},
handleTab(tab) { handleTab(tab) {
this.tabActive = tab this.tabActive = tab
this.Post = null this.Post = null

View File

@ -1,6 +1,7 @@
export const state = () => { export const state = () => {
return { return {
placeholder: null, placeholder: null,
editPending: false,
} }
} }
@ -8,10 +9,16 @@ export const getters = {
placeholder(state) { placeholder(state) {
return state.placeholder return state.placeholder
}, },
editPending(state) {
return state.editPending
},
} }
export const mutations = { export const mutations = {
SET_PLACEHOLDER_TEXT(state, text) { SET_PLACEHOLDER_TEXT(state, text) {
state.placeholder = text state.placeholder = text
}, },
SET_EDIT_PENDING(state, boolean) {
state.editPending = boolean
},
} }

76
webapp/store/posts.js Normal file
View File

@ -0,0 +1,76 @@
import gql from 'graphql-tag'
export const state = () => {
return {
posts: [],
}
}
export const mutations = {
SET_POSTS(state, posts) {
state.posts = posts || null
},
}
export const getters = {
posts(state) {
return state.posts || []
},
}
export const actions = {
async fetchPosts({ commit, dispatch }, { i18n, filter }) {
const client = this.app.apolloProvider.defaultClient
const {
data: { Post },
} = await client.query({
query: gql(`
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
Post(filter: $filter, first: $first, offset: $offset) {
id
title
contentExcerpt
createdAt
disabled
deleted
slug
image
author {
id
avatar
slug
name
disabled
deleted
contributionsCount
shoutedCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${i18n.locale().toUpperCase()}
}
badges {
id
icon
}
}
commentsCount
categories {
id
name
icon
}
shoutedCount
}
}`),
variables: {
filter,
first: 12,
offset: 0,
},
})
commit('SET_POSTS', Post)
return Post
},
}

View File

@ -1,5 +0,0 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>bold</title>
<path d="M16 7h-9v18h11c2.8 0 5-2.2 5-5 0-2.2-1.4-4-3.3-4.7 0.8-0.9 1.3-2 1.3-3.3 0-2.8-2.2-5-5-5zM9 15v-6h7c1.7 0 3 1.3 3 3s-1.3 3-3 3h-7zM9 23v-6h9c1.7 0 3 1.3 3 3s-1.3 3-3 3h-9zM16 5v0c3.9 0 7 3.1 7 7 0 0.9-0.2 1.8-0.5 2.6 1.5 1.3 2.5 3.3 2.5 5.4 0 3.9-3.1 7-7 7h-13v-22h11zM11 11v0 2h5c0.6 0 1-0.4 1-1s-0.4-1-1-1h-5zM11 19v0 2h7c0.6 0 1-0.4 1-1s-0.4-1-1-1h-7z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 531 B

View File

@ -1,5 +0,0 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>italic</title>
<path d="M11.75 5h10.031l-0.094 1.063-0.188 3-0.063 0.938h-2l-0.875 12h2l-0.063 1.063-0.188 3-0.063 0.938h-10.031l0.094-1.063 0.188-3 0.063-0.938h2l0.875-12h-2l0.063-1.063 0.188-3zM13.625 7l-0.063 1h2l-0.063 1.063-1 14-0.063 0.938h-2l-0.063 1h6l0.063-1h-2l0.063-1.063 1-14 0.063-0.938h2l0.063-1h-6z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 468 B

View File

@ -1,5 +0,0 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>list-ol</title>
<path d="M5.969 3h2.031v7h-2v-4.531c-0.444 0.255-0.913 0.531-1.594 0.531v-2c0.494 0 1.25-0.656 1.25-0.656zM11 6h17v2h-17v-2zM6.5 12c1.383 0 2.5 1.117 2.5 2.5 0 0.481-0.248 1.090-0.75 1.5l0.031 0.031-0.125 0.094-0.875 0.875h1.719v2h-5v-1.625l0.313-0.281 2.688-2.594c0-0.217-0.283-0.5-0.5-0.5s-0.5 0.283-0.5 0.5v0.5h-2v-0.5c0-1.383 1.117-2.5 2.5-2.5zM11 15h17v2h-17v-2zM4 21h4v1.469l-0.125 0.25-0.406 0.688c0.853 0.398 1.531 1.089 1.531 2.094 0 1.383-1.117 2.5-2.5 2.5h-2.5v-2h2.5c0.217 0 0.5-0.283 0.5-0.5s-0.283-0.5-0.5-0.5h-1.5v-1.375l0.125-0.219 0.25-0.406h-1.375v-2zM11 24h17v2h-17v-2z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 759 B

View File

@ -1,5 +0,0 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>list-ul</title>
<path d="M4 5h6v6h-6v-6zM6 7v2h2v-2h-2zM12 7h15v2h-15v-2zM4 13h6v6h-6v-6zM6 15v2h2v-2h-2zM12 15h15v2h-15v-2zM4 21h6v6h-6v-6zM6 23v2h2v-2h-2zM12 23h15v2h-15v-2z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

Some files were not shown because too many files have changed in this diff Show More