Merge branch 'master' into 2019/kw15/design_differences_post

This commit is contained in:
Robert Schäfer 2019-04-10 14:22:13 +02:00 committed by GitHub
commit 87fbdb576d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 365 additions and 78 deletions

View File

@ -16,8 +16,7 @@
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
"test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand",
"test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --",
"test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:server test:cucumber:before:seeder 'test:cucumber:cmd {@}' --",
"test:cucumber:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions yarn run dev",
"test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --",
"test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --",
"db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js",
"db:reset": "babel-node src/seed/reset-db.js",

View File

@ -7,6 +7,7 @@ import reports from './resolvers/reports.js'
import posts from './resolvers/posts.js'
import moderation from './resolvers/moderation.js'
import rewards from './resolvers/rewards.js'
import notifications from './resolvers/notifications'
export const typeDefs = fs
.readFileSync(
@ -17,13 +18,15 @@ export const typeDefs = fs
export const resolvers = {
Query: {
...statistics.Query,
...userManagement.Query
...userManagement.Query,
...notifications.Query
},
Mutation: {
...userManagement.Mutation,
...reports.Mutation,
...posts.Mutation,
...moderation.Mutation,
...rewards.Mutation
...rewards.Mutation,
...notifications.Mutation
}
}

View File

@ -10,6 +10,7 @@ import permissionsMiddleware from './permissionsMiddleware'
import userMiddleware from './userMiddleware'
import includedFieldsMiddleware from './includedFieldsMiddleware'
import orderByMiddleware from './orderByMiddleware'
import notificationsMiddleware from './notificationsMiddleware'
export default schema => {
let middleware = [
@ -19,6 +20,7 @@ export default schema => {
excerptMiddleware,
xssMiddleware,
fixImageUrlsMiddleware,
notificationsMiddleware,
softDeleteMiddleware,
userMiddleware,
includedFieldsMiddleware,

View File

@ -0,0 +1,10 @@
const MENTION_REGEX = /\s@([\w_-]+)/g
export function extractSlugs (content) {
let slugs = []
let match
while ((match = MENTION_REGEX.exec(content)) != null) {
slugs.push(match[1])
}
return slugs
}

View File

@ -0,0 +1,30 @@
import { extractSlugs } from './mentions'
describe('extract', () => {
describe('finds mentions in the form of', () => {
it('@user', () => {
const content = 'Hello @user'
expect(extractSlugs(content)).toEqual(['user'])
})
it('@user-with-dash', () => {
const content = 'Hello @user-with-dash'
expect(extractSlugs(content)).toEqual(['user-with-dash'])
})
it('@user.', () => {
const content = 'Hello @user.'
expect(extractSlugs(content)).toEqual(['user'])
})
it('@user-With-Capital-LETTERS', () => {
const content = 'Hello @user-With-Capital-LETTERS'
expect(extractSlugs(content)).toEqual(['user-With-Capital-LETTERS'])
})
})
it('ignores email addresses', () => {
const content = 'Hello somebody@example.org'
expect(extractSlugs(content)).toEqual([])
})
})

View File

@ -0,0 +1,27 @@
import { extractSlugs } from './notifications/mentions'
const notify = async (resolve, root, args, context, resolveInfo) => {
const post = await resolve(root, args, context, resolveInfo)
const session = context.driver.session()
const { content, id: postId } = post
const slugs = extractSlugs(content)
const createdAt = (new Date()).toISOString()
const cypher = `
match(u:User) where u.slug in $slugs
match(p:Post) where p.id = $postId
create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt})
merge (n)-[:NOTIFIED]->(u)
merge (p)-[:NOTIFIED]->(n)
`
await session.run(cypher, { slugs, createdAt, postId })
session.close()
return post
}
export default {
Mutation: {
CreatePost: notify
}
}

View File

@ -0,0 +1,85 @@
import Factory from '../seed/factories'
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../jest/helpers'
const factory = Factory()
let client
beforeEach(async () => {
await factory.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234'
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('currentUser { notifications }', () => {
const query = `query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
read
post {
content
}
}
}
}`
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
describe('given another user', () => {
let authorClient
let authorParams
let authorHeaders
beforeEach(async () => {
authorParams = {
email: 'author@example.org',
password: '1234',
id: 'author'
}
await factory.create('User', authorParams)
authorHeaders = await login(authorParams)
})
describe('who mentions me in a post', () => {
beforeEach(async () => {
const content = 'Hey @al-capone how do you do?'
const title = 'Mentioning Al Capone'
const createPostMutation = `
mutation($title: String!, $content: String!) {
CreatePost(title: $title, content: $content) {
title
content
}
}
`
authorClient = new GraphQLClient(host, { headers: authorHeaders })
await authorClient.request(createPostMutation, { title, content })
})
it('sends you a notification', async () => {
const expected = {
currentUser: {
notifications: [
{ read: false, post: { content: 'Hey @al-capone how do you do?' } }
]
}
}
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
})
})
})
})
})

View File

@ -20,6 +20,21 @@ const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, context, info)
return context.user.id === parent.id
})
const belongsToMe = rule({ cache: 'no_cache' })(async (_, args, context) => {
const { driver, user: { id: userId } } = context
const { id: notificationId } = args
const session = driver.session()
const result = await session.run(`
MATCH (u:User {id: $userId})<-[:NOTIFIED]-(n:Notification {id: $notificationId})
RETURN n
`, { userId, notificationId })
const [notification] = result.records.map((record) => {
return record.get('n')
})
session.close()
return Boolean(notification)
})
const onlyEnabledContent = rule({ cache: 'strict' })(async (parent, args, ctx, info) => {
const { disabled, deleted } = args
return !(disabled || deleted)
@ -50,6 +65,7 @@ const permissions = shield({
Post: or(onlyEnabledContent, isModerator)
},
Mutation: {
UpdateNotification: belongsToMe,
CreatePost: isAuthenticated,
UpdatePost: isAuthor,
DeletePost: isAuthor,

View File

@ -0,0 +1,14 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
export default {
Query: {
Notification: (object, params, context, resolveInfo) => {
return neo4jgraphql(object, params, context, resolveInfo, false)
}
},
Mutation: {
UpdateNotification: (object, params, context, resolveInfo) => {
return neo4jgraphql(object, params, context, resolveInfo, false)
}
}
}

View File

@ -5,13 +5,14 @@ import { host, login } from '../jest/helpers'
const factory = Factory()
let client
let userParams = {
id: 'you',
email: 'test@example.org',
password: '1234'
}
beforeEach(async () => {
await factory.create('User', {
id: 'you',
email: 'test@example.org',
password: '1234'
})
await factory.create('User', userParams)
})
afterEach(async () => {
@ -118,3 +119,63 @@ describe('currentUser { notifications }', () => {
})
})
})
describe('UpdateNotification', () => {
const mutation = `mutation($id: ID!, $read: Boolean){
UpdateNotification(id: $id, read: $read) {
id read
}
}`
const variables = { id: 'to-be-updated', read: true }
describe('given a notifications', () => {
let headers
beforeEach(async () => {
const mentionedParams = {
id: 'mentioned-1',
email: 'mentioned@example.org',
password: '1234',
slug: 'mentioned'
}
await factory.create('User', mentionedParams)
await factory.create('Notification', { id: 'to-be-updated' })
await factory.authenticateAs(userParams)
await factory.create('Post', { id: 'p1' })
await Promise.all([
factory.relate('Notification', 'User', { from: 'to-be-updated', to: 'mentioned-1' }),
factory.relate('Notification', 'Post', { from: 'p1', to: 'to-be-updated' })
])
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
})
})
describe('authenticated', () => {
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(mutation, variables)).rejects.toThrow('Not Authorised')
})
describe('and owner', () => {
beforeEach(async () => {
headers = await login({ email: 'mentioned@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
it('updates notification', async () => {
const expected = { UpdateNotification: { id: 'to-be-updated', read: true } }
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
})
})
})
})
})

View File

@ -28,10 +28,10 @@ let schema = makeAugmentedSchema({
resolvers,
config: {
query: {
exclude: ['Statistics', 'LoggedInUser']
exclude: ['Notfication', 'Statistics', 'LoggedInUser']
},
mutation: {
exclude: ['Statistics', 'LoggedInUser']
exclude: ['Notfication', 'Statistics', 'LoggedInUser']
},
debug: debug
}

View File

@ -76,7 +76,7 @@
"eslint-plugin-vue": "~5.2.2",
"jest": "~24.7.1",
"node-sass": "~4.11.0",
"nodemon": "~1.18.10",
"nodemon": "~1.18.11",
"prettier": "~1.14.3",
"sass-loader": "~7.1.0",
"vue-jest": "~3.0.4",

View File

@ -32,22 +32,26 @@ export default {
name: this.$t('admin.dashboard.name'),
path: `/admin`
},
{
// TODO implement
/* {
name: this.$t('admin.users.name'),
path: `/admin/users`
},
{
}, */
// TODO implement
/* {
name: this.$t('admin.organizations.name'),
path: `/admin/organizations`
},
{
}, */
// TODO implement
/* {
name: this.$t('admin.pages.name'),
path: `/admin/pages`
},
{
}, */
// TODO implement
/* {
name: this.$t('admin.notifications.name'),
path: `/admin/notifications`
},
}, */
{
name: this.$t('admin.categories.name'),
path: `/admin/categories`
@ -55,11 +59,12 @@ export default {
{
name: this.$t('admin.tags.name'),
path: `/admin/tags`
},
{
}
// TODO implement
/* {
name: this.$t('admin.settings.name'),
path: `/admin/settings`
}
} */
]
}
}

View File

@ -58,25 +58,28 @@ export default {
{
name: this.$t('common.comment', null, 2),
path: `/post/${id}/${slug}#comments`
},
{
}
// TODO implement
/* {
name: this.$t('common.letsTalk'),
path: `/post/${id}/${slug}#lets-talk`
},
{
}, */
// TODO implement
/* {
name: this.$t('common.versus'),
path: `/post/${id}/${slug}#versus`
}
} */
]
},
{
name: this.$t('common.moreInfo'),
path: `/post/${id}/${slug}/more-info`
},
{
}
// TODO implement
/* {
name: this.$t('common.takeAction'),
path: `/post/${id}/${slug}/take-action`
}
} */
]
}
}

View File

@ -70,6 +70,37 @@
:is-shouted="post.shoutedByCurrentUser"
:post-id="post.id"
/>
<!-- Categories -->
<ds-icon
v-for="category in post.categories"
:key="category.id"
v-tooltip="{content: category.name, placement: 'top-start', delay: { show: 300 }}"
:name="category.icon"
size="large"
/>&nbsp;
<ds-space margin-bottom="small" />
<!--<div class="tags">
<ds-icon name="compass" /> <ds-tag
v-for="category in post.categories"
:key="category.id"
>
{{ category.name }}
</ds-tag>
</div>-->
<!-- Tags -->
<template v-if="post.tags && post.tags.length">
<ds-space margin="xx-small" />
<div class="tags">
<ds-icon name="tags" />
<ds-tag
v-for="tag in post.tags"
:key="tag.id"
>
<ds-icon name="tag" />
{{ tag.name }}
</ds-tag>
</div>
</template>
<ds-space margin="small" />
<!-- Comments -->
<ds-section slot="footer">

View File

@ -42,7 +42,8 @@
color="soft"
size="small"
>
<ds-icon name="map-marker" /> {{ user.location.name }}
<ds-icon name="map-marker" />
{{ user.location.name }}
</ds-text>
<ds-text
align="center"
@ -56,9 +57,7 @@
v-if="user.badges && user.badges.length"
margin="x-small"
>
<hc-badges
:badges="user.badges"
/>
<hc-badges :badges="user.badges" />
</ds-space>
<ds-flex>
<ds-flex-item>
@ -82,9 +81,7 @@
</no-ssr>
</ds-flex-item>
</ds-flex>
<ds-space
margin="small"
>
<ds-space margin="small">
<hc-follow-button
v-if="!myProfile"
:follow-id="user.id"
@ -227,32 +224,28 @@
</no-ssr>
</ds-space>
</ds-flex-item>
<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">
<!-- TODO: find better solution for rendering errors -->
<!--<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">-->
<!-- TODO: find better solution for rendering errors -->
<!--
<no-ssr>
<ds-number :label="$t('profile.commented')">
<hc-count-to
slot="count"
:end-val="user.commentsCount"
/>
<hc-count-to slot="count" :end-val="user.commentsCount"/>
</ds-number>
</no-ssr>
</ds-space>
</ds-flex-item>
<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
-->
<!--<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">-->
<!-- TODO: find better solution for rendering errors -->
<!--<no-ssr>
<ds-number :label="$t('profile.shouted')">
<hc-count-to
slot="count"
:end-val="user.shoutedCount"
/>
<hc-count-to slot="count" :end-val="user.shoutedCount"/>
</ds-number>
</no-ssr>
</ds-space>
</ds-flex-item>
</ds-flex-item>-->
</ds-flex>
</ds-card>
</ds-flex-item>
@ -273,9 +266,7 @@
:key="post.id"
:width="{ base: '100%', md: '100%', xl: '50%' }"
>
<hc-post-card
:post="post"
/>
<hc-post-card :post="post" />
</ds-flex-item>
</template>
<template v-else>

View File

@ -34,27 +34,32 @@ export default {
{
name: this.$t('settings.security.name'),
path: `/settings/security`
},
{
}
// TODO implement
/* {
name: this.$t('settings.invites.name'),
path: `/settings/invites`
},
{
}, */
// TODO implement
/* {
name: this.$t('settings.download.name'),
path: `/settings/data-download`
},
{
}, */
// TODO implement
/* {
name: this.$t('settings.delete.name'),
path: `/settings/delete-account`
},
{
}, */
// TODO implement
/* {
name: this.$t('settings.organizations.name'),
path: `/settings/my-organizations`
},
{
}, */
// TODO implement
/* {
name: this.$t('settings.languages.name'),
path: `/settings/languages`
}
} */
]
}
}

View File

@ -2867,10 +2867,10 @@ cheerio@^1.0.0-rc.2:
lodash "^4.15.0"
parse5 "^3.0.1"
chokidar@^2.0.2, chokidar@^2.0.4, chokidar@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.1.tgz#adc39ad55a2adf26548bd2afa048f611091f9184"
integrity sha512-gfw3p2oQV2wEt+8VuMlNsPjCxDxvvgnm/kz+uATu805mWVF8IJN7uz9DN7iBz+RMJISmiVbCOBFs9qBGMjtPfQ==
chokidar@^2.0.2, chokidar@^2.0.4, chokidar@^2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.5.tgz#0ae8434d962281a5f56c72869e79cb6d9d86ad4d"
integrity sha512-i0TprVWp+Kj4WRPtInjexJ8Q+BqTE909VpH8xVhXrJkoc5QC8VO9TryGOqTr+2hljzc1sC62t22h5tZePodM/A==
dependencies:
anymatch "^2.0.0"
async-each "^1.0.1"
@ -2882,7 +2882,7 @@ chokidar@^2.0.2, chokidar@^2.0.4, chokidar@^2.1.0:
normalize-path "^3.0.0"
path-is-absolute "^1.0.0"
readdirp "^2.2.1"
upath "^1.1.0"
upath "^1.1.1"
optionalDependencies:
fsevents "^1.2.7"
@ -7350,12 +7350,12 @@ node-sass@~4.11.0:
stdout-stream "^1.4.0"
"true-case-path" "^1.0.2"
nodemon@^1.18.9, nodemon@~1.18.10:
version "1.18.10"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.18.10.tgz#3ba63f64eb4c283cf3e4f75f30817e9d4f393afe"
integrity sha512-we51yBb1TfEvZamFchRgcfLbVYgg0xlGbyXmOtbBzDwxwgewYS/YbZ5tnlnsH51+AoSTTsT3A2E/FloUbtH8cQ==
nodemon@^1.18.9, nodemon@~1.18.11:
version "1.18.11"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.18.11.tgz#d836ab663776e7995570b963da5bfc807e53f6b8"
integrity sha512-KdN3tm1zkarlqNo4+W9raU3ihM4H15MVMSE/f9rYDZmFgDHAfAJsomYrHhApAkuUemYjFyEeXlpCOQ2v5gtBEw==
dependencies:
chokidar "^2.1.0"
chokidar "^2.1.5"
debug "^3.1.0"
ignore-by-default "^1.0.1"
minimatch "^3.0.4"
@ -10626,6 +10626,11 @@ upath@^1.1.0:
resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd"
integrity sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw==
upath@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068"
integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q==
update-notifier@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"