Merge branch 'master' into 2019/kw15/User_can_change_its_username_to_emptystring

# Conflicts:
#	webapp/components/_mixins/seo.js
#	webapp/components/mixins/seo.js
#	webapp/layouts/blank.vue
#	webapp/layouts/default.vue
#	webapp/mixins/seo.js
This commit is contained in:
Ulf Gebhardt 2019-04-19 12:09:24 +02:00
commit 6de253fd32
No known key found for this signature in database
GPG Key ID: 44C888923CC8E7F3
66 changed files with 1670 additions and 546 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@ -4,12 +4,8 @@
* [Edit this Documentation](edit-this-documentation.md) * [Edit this Documentation](edit-this-documentation.md)
* [Installation](installation.md) * [Installation](installation.md)
* [Backend](backend/README.md) * [Backend](backend/README.md)
* [graphql-with-apollo](backend/graphql-with-apollo/README.md) * [GraphQL](backend/graphql.md)
* [GraphQL with Apollo](backend/graphql-with-apollo/graphql-with-apollo/README.md) * [Legacy Migration](backend/db-migration-worker/README.md)
* [Mocking](backend/graphql-with-apollo/graphql-with-apollo/mocking.md)
* [Seeding](backend/graphql-with-apollo/graphql-with-apollo/seeding.md)
* [Import](backend/data-import.md)
* [Middleware](backend/middleware.md)
* [Webapp](webapp/README.md) * [Webapp](webapp/README.md)
* [COMPONENTS](webapp/components.md) * [COMPONENTS](webapp/components.md)
* [PLUGINS](webapp/plugins.md) * [PLUGINS](webapp/plugins.md)

View File

@ -1,137 +1,152 @@
# Backend # Backend
## Installation
{% tabs %}
{% tab title="Docker" %}
## Installation with Docker Run the following command to install everything through docker.
Make sure you are on a [node](https://nodejs.org/en/) version >= `v10.12.0`: The installation takes a bit longer on the first pass or on rebuild ...
```text
node --version
```
Run:
```bash ```bash
docker-compose up $ docker-compose up
# create indices etc. # rebuild the containers for a cleanup
docker-compose exec neo4j migrate $ docker-compose up --build
# if you want seed data
# open another terminal and run
docker-compose exec backend yarn run db:seed
``` ```
Open another terminal and create unique indices with:
App is [running on port 4000](http://localhost:4000/)
To wipe out your neo4j database run:
```bash ```bash
docker-compose down -v $ docker-compose exec neo4j migrate
``` ```
## Installation without Docker {% endtab %}
Install dependencies: {% tab title="Without Docker" %}
For the local installation you need a recent version of [node](https://nodejs.org/en/)
(>= `v10.12.0`) and [Neo4J](https://neo4j.com/) along with
[Apoc](https://github.com/neo4j-contrib/neo4j-apoc-procedures) plugin installed
on your system.
Download [Neo4j Community Edition](https://neo4j.com/download-center/#releases) and unpack the files. Download [Neo4j Community Edition](https://neo4j.com/download-center/#releases) and unpack the files.
Download [Neo4j Apoc](https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases) and drop the file into the `plugins` folder of the just extracted Neo4j-Server Download [Neo4j Apoc](https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases) and drop the file into the `plugins` folder of the just extracted Neo4j-Server
Note that grand-stack-starter does not currently bundle a distribution of Neo4j. You can download [Neo4j Desktop](https://neo4j.com/download/) and run locally for development, spin up a [hosted Neo4j Sandbox instance](https://neo4j.com/download/), run Neo4j in one of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/), [spin up Neo4j in a Docker container](https://neo4j.com/developer/docker/) or on Debian-based systems install [Neo4j from the Debian Repository](http://debian.neo4j.org/). Just be sure to update the Neo4j connection string and credentials accordingly in `.env`.
Start Neo4J and confirm the database is running at [http://localhost:7474](http://localhost:7474).
Start Neo4j Now install node dependencies with [yarn](https://yarnpkg.com/en/):
```bash
```text $ cd backend
neo4j\bin\neo4j start $ yarn install
``` ```
and confirm it's running [here](http://localhost:7474) Copy Environment Variables:
```bash
# in backend/
$ cp .env.template .env
```
Configure the new files according to your needs and your local setup.
Create unique indices with:
```bash ```bash
yarn install $ ./neo4j/migrate.sh
# -or-
npm install
``` ```
Copy: Start the backend for development with:
```text
cp .env.template .env
```
Configure the file `.env` according to your needs and your local setup.
Start the GraphQL service:
```bash ```bash
yarn dev $ yarn run dev
# -or-
npm dev
``` ```
And on the production machine run following: or start the backend in production environment with:
```bash ```bash
yarn start yarn run start
# -or-
npm start
``` ```
This will start the GraphQL service \(by default on localhost:4000\) where you can issue GraphQL requests or access GraphQL Playground in the browser: {% endtab %}
{% endtabs %}
Your backend is up and running at [http://localhost:4000/](http://localhost:4000/)
This will start the GraphQL service \(by default on localhost:4000\) where you can issue GraphQL requests or access GraphQL Playground in the browser.
![GraphQL Playground](../.gitbook/assets/graphql-playground.png) ![GraphQL Playground](../.gitbook/assets/graphql-playground.png)
## Configure You can access Neo4J through [http://localhost:7474/](http://localhost:7474/)
for an interactive `cypher` shell and a visualization of the graph.
Set your Neo4j connection string and credentials in `.env`. For example:
_.env_ #### Seed Database
```yaml If you want your backend to return anything else than an empty response, you
NEO4J_URI=bolt://localhost:7687 need to seed your database:
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=letmein
```
> You need to install APOC as a plugin for the graph you create in the neo4j desktop app! {% tabs %}
{% tab title="Docker" %}
Note that grand-stack-starter does not currently bundle a distribution of Neo4j. You can download [Neo4j Desktop](https://neo4j.com/download/) and run locally for development, spin up a [hosted Neo4j Sandbox instance](https://neo4j.com/download/), run Neo4j in one of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/), [spin up Neo4j in a Docker container](https://neo4j.com/developer/docker/) or on Debian-based systems install [Neo4j from the Debian Repository](http://debian.neo4j.org/). Just be sure to update the Neo4j connection string and credentials accordingly in `.env`.
# Seed and Reset the Database
Optionally you can seed the GraphQL service by executing mutations that will write sample data to the database:
In another terminal run:
```bash ```bash
yarn run db:seed $ docker-compose exec backend yarn run db:seed
# -or-
npm run db:seed
``` ```
For a reset you can use the reset script: To reset the database run:
```bash ```bash
yarn db:reset $ docker-compose exec backend yarn run db:reset
# -or- # you could also wipe out your neo4j database and delete all volumes with:
npm run db:reset $ docker-compose down -v
``` ```
{% endtab %}
{% tab title="Without Docker" %}
Run:
```bash
$ yarn run db:seed
```
To reset the database run:
```bash
$ yarn run db:reset
```
{% endtab %}
{% endtabs %}
# Testing # Testing
**Beware**: We have no multiple database setup at the moment. We clean the database after each test, running the tests will wipe out all your data! **Beware**: We have no multiple database setup at the moment. We clean the database after each test, running the tests will wipe out all your data!
{% tabs %}
{% tab title="Docker" %}
Run the _**jest**_ tests: Run the _**jest**_ tests:
```bash ```bash
yarn run test $ docker-compose exec backend yarn run test:jest
# -or-
npm run test
``` ```
Run the _**cucumber**_ features: Run the _**cucumber**_ features:
```bash ```bash
yarn run test:cucumber $ docker-compose exec backend yarn run test:cucumber
# -or-
npm run test:cucumber
``` ```
When some tests fail, try `yarn db:reset` and after that `yarn db:seed`. Then run the tests again {% endtab %}
{% tab title="Without Docker" %}
Run the _**jest**_ tests:
```bash
$ yarn run test:jest
```
Run the _**cucumber**_ features:
```bash
$ yarn run test:cucumber
```
{% endtab %}
{% endtabs %}

View File

@ -1,7 +1,9 @@
# Import # Legacy Migration
This guide helps you to import data from our legacy servers, which are using FeathersJS and MongoDB. This guide helps you to import data from our legacy servers, which are using FeathersJS and MongoDB.
**You can skip this if you don't plan to migrate any legacy applications!**
## Prerequisites ## Prerequisites
You need [docker](https://www.docker.com/) installed on your machine. Furthermore you need SSH access to the server and you need to know the following login credentials and server settings: You need [docker](https://www.docker.com/) installed on your machine. Furthermore you need SSH access to the server and you need to know the following login credentials and server settings:

View File

@ -1,2 +0,0 @@
# graphql-with-apollo

View File

@ -1,6 +0,0 @@
# GraphQL with Apollo
GraphQL is a data query language which provides an alternative to REST and ad-hoc web service architectures. It allows clients to define the structure of the data required, and exactly the same structure of the data is returned from the server.
![GraphQL Playground](../../../.gitbook/assets/graphql-playground%20%281%29.png)

View File

@ -1,8 +0,0 @@
# Mocking
## Mocking API Results
Alternatively you can just mock all responses from the api which let you build a frontend application without running a neo4j instance.
Just set `MOCK=true` inside `.env` or pass it on application start.

View File

@ -1,20 +0,0 @@
# Seeding
## Seeding The Database
Optionally you can seed the GraphQL service by executing mutations that will write sample data to the database:
{% tabs %}
{% tab title="Yarn" %}
```bash
yarn db:seed
```
{% endtab %}
{% tab title="NPM" %}
```bash
npm run db:seed
```
{% endtab %}
{% endtabs %}

View File

@ -1,8 +1,13 @@
# Middleware # GraphQL with Apollo
GraphQL is a data query language which provides an alternative to REST and ad-hoc web service architectures. It allows clients to define the structure of the data required, and exactly the same structure of the data is returned from the server.
![GraphQL Playground](../../../.gitbook/assets/graphql-playground%20%281%29.png)
## Middleware keeps resolvers clean
![](../.gitbook/assets/grafik-4.png) ![](../.gitbook/assets/grafik-4.png)
## Middleware keeps resolvers clean
A well-organized codebase is key for the ability to maintain and easily introduce changes into an app. Figuring out the right structure for your code remains a continuous challenge - especially as an application grows and more developers are joining a project. A well-organized codebase is key for the ability to maintain and easily introduce changes into an app. Figuring out the right structure for your code remains a continuous challenge - especially as an application grows and more developers are joining a project.
@ -10,3 +15,4 @@ A common problem in GraphQL servers is that resolvers often get cluttered with b
GraphQL Middleware uses the [_middleware pattern_](https://dzone.com/articles/understanding-middleware-pattern-in-expressjs) \(well-known from Express.js\) to pull out repetitive code from resolvers and execute it before or after one of your resolvers is invoked. This improves code modularity and keeps your resolvers clean and simple. GraphQL Middleware uses the [_middleware pattern_](https://dzone.com/articles/understanding-middleware-pattern-in-expressjs) \(well-known from Express.js\) to pull out repetitive code from resolvers and execute it before or after one of your resolvers is invoked. This improves code modularity and keeps your resolvers clean and simple.

View File

@ -84,7 +84,7 @@
"cucumber": "~5.1.0", "cucumber": "~5.1.0",
"eslint": "~5.16.0", "eslint": "~5.16.0",
"eslint-config-standard": "~12.0.0", "eslint-config-standard": "~12.0.0",
"eslint-plugin-import": "~2.17.1", "eslint-plugin-import": "~2.17.2",
"eslint-plugin-jest": "~22.4.1", "eslint-plugin-jest": "~22.4.1",
"eslint-plugin-node": "~8.0.1", "eslint-plugin-node": "~8.0.1",
"eslint-plugin-promise": "~4.1.1", "eslint-plugin-promise": "~4.1.1",

View File

@ -11,7 +11,7 @@ import userMiddleware from './userMiddleware'
import includedFieldsMiddleware from './includedFieldsMiddleware' import includedFieldsMiddleware from './includedFieldsMiddleware'
import orderByMiddleware from './orderByMiddleware' import orderByMiddleware from './orderByMiddleware'
import validUrlMiddleware from './validUrlMiddleware' import validUrlMiddleware from './validUrlMiddleware'
import notificationsMiddleware from './notificationsMiddleware' import notificationsMiddleware from './notifications'
export default schema => { export default schema => {
let middleware = [ let middleware = [
@ -20,9 +20,9 @@ export default schema => {
validUrlMiddleware, validUrlMiddleware,
sluggifyMiddleware, sluggifyMiddleware,
excerptMiddleware, excerptMiddleware,
notificationsMiddleware,
xssMiddleware, xssMiddleware,
fixImageUrlsMiddleware, fixImageUrlsMiddleware,
notificationsMiddleware,
softDeleteMiddleware, softDeleteMiddleware,
userMiddleware, userMiddleware,
includedFieldsMiddleware, includedFieldsMiddleware,

View File

@ -0,0 +1,17 @@
import cheerio from 'cheerio'
const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g
export default function (content) {
const $ = cheerio.load(content)
const urls = $('.mention').map((_, el) => {
return $(el).attr('href')
}).get()
const ids = []
urls.forEach((url) => {
let match
while ((match = ID_REGEX.exec(url)) != null) {
ids.push(match[1])
}
})
return ids
}

View File

@ -0,0 +1,46 @@
import extractIds from './extractMentions'
describe('extract', () => {
describe('searches through links', () => {
it('ignores links without .mention class', () => {
const content = '<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([])
})
describe('given a link with .mention class', () => {
it('extracts ids', () => {
const content = '<p>Something inspirational about <a href="/profile/u2" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
})
describe('handles links', () => {
it('with slug and id', () => {
const content = '<p>Something inspirational about <a href="/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
})
it('with domains', () => {
const content = '<p>Something inspirational about <a href="http://localhost:3000/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
})
it('special characters', () => {
const content = '<p>Something inspirational about <a href="http://localhost:3000/profile/u!*(),2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u.~-3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u!*(),2', 'u.~-3'])
})
})
describe('does not crash if', () => {
it('`href` contains no user id', () => {
const content = '<p>Something inspirational about <a href="/profile" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([])
})
it('`href` is empty or invalid', () => {
const content = '<p>Something inspirational about <a href="" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([])
})
})
})
})
})

View File

@ -1,20 +1,22 @@
import { extractSlugs } from './notifications/mentions' import extractIds from './extractMentions'
const notify = async (resolve, root, args, context, resolveInfo) => { const notify = async (resolve, root, args, context, resolveInfo) => {
// extract user ids before xss-middleware removes link classes
const ids = extractIds(args.content)
const post = await resolve(root, args, context, resolveInfo) const post = await resolve(root, args, context, resolveInfo)
const session = context.driver.session() const session = context.driver.session()
const { content, id: postId } = post const { id: postId } = post
const slugs = extractSlugs(content)
const createdAt = (new Date()).toISOString() const createdAt = (new Date()).toISOString()
const cypher = ` const cypher = `
match(u:User) where u.slug in $slugs match(u:User) where u.id in $ids
match(p:Post) where p.id = $postId match(p:Post) where p.id = $postId
create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt}) create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt})
merge (n)-[:NOTIFIED]->(u) merge (n)-[:NOTIFIED]->(u)
merge (p)-[:NOTIFIED]->(n) merge (p)-[:NOTIFIED]->(n)
` `
await session.run(cypher, { slugs, createdAt, postId }) await session.run(cypher, { ids, createdAt, postId })
session.close() session.close()
return post return post
@ -22,6 +24,7 @@ const notify = async (resolve, root, args, context, resolveInfo) => {
export default { export default {
Mutation: { Mutation: {
CreatePost: notify CreatePost: notify,
UpdatePost: notify
} }
} }

View File

@ -1,10 +0,0 @@
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

@ -1,30 +0,0 @@
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,124 @@
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../../jest/helpers'
import Factory from '../../seed/factories'
const factory = Factory()
let client
beforeEach(async () => {
await factory.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234'
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('currentUser { notifications }', () => {
const query = `query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
read
post {
content
}
}
}
}`
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
describe('given another user', () => {
let authorClient
let authorParams
let authorHeaders
beforeEach(async () => {
authorParams = {
email: 'author@example.org',
password: '1234',
id: 'author'
}
await factory.create('User', authorParams)
authorHeaders = await login(authorParams)
})
describe('who mentions me in a post', () => {
let post
const title = 'Mentioning Al Capone'
const content = 'Hey <a class="mention" href="/profile/you/al-capone">@al-capone</a> how do you do?'
beforeEach(async () => {
const createPostMutation = `
mutation($title: String!, $content: String!) {
CreatePost(title: $title, content: $content) {
id
title
content
}
}
`
authorClient = new GraphQLClient(host, { headers: authorHeaders })
const { CreatePost } = await authorClient.request(createPostMutation, { title, content })
post = CreatePost
})
it('sends you a notification', async () => {
const expectedContent = 'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
const expected = {
currentUser: {
notifications: [
{ read: false, post: { content: expectedContent } }
]
}
}
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
})
describe('who mentions me again', () => {
beforeEach(async () => {
const updatedContent = `${post.content} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
// The response `post.content` contains a link but the XSSmiddleware
// should have the `mention` CSS class removed. I discovered this
// during development and thought: A feature not a bug! This way we
// can encode a re-mentioning of users when you edit your post or
// comment.
const createPostMutation = `
mutation($id: ID!, $content: String!) {
UpdatePost(id: $id, content: $content) {
title
content
}
}
`
authorClient = new GraphQLClient(host, { headers: authorHeaders })
await authorClient.request(createPostMutation, { id: post.id, content: updatedContent })
})
it('creates exactly one more notification', async () => {
const expectedContent = 'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do? One more mention to <a href="/profile/you" target="_blank">@al-capone</a>'
const expected = {
currentUser: {
notifications: [
{ read: false, post: { content: expectedContent } },
{ read: false, post: { content: expectedContent } }
]
}
}
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
})
})
})
})
})
})

View File

@ -1,85 +0,0 @@
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

@ -3,21 +3,26 @@ import uuid from 'uuid/v4'
export default function (params) { export default function (params) {
const { const {
id = uuid(), id = uuid(),
key, key = '',
type = 'crowdfunding', type = 'crowdfunding',
status = 'permanent', status = 'permanent',
icon icon = '/img/badges/indiegogo_en_panda.svg'
} = params } = params
return ` return {
mutation { mutation: `
CreateBadge( mutation(
id: "${id}", $id: ID
key: "${key}", $key: String!
type: ${type}, $type: BadgeTypeEnum!
status: ${status}, $status: BadgeStatusEnum!
icon: "${icon}" $icon: String!
) { id } ) {
CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) {
id
}
}
`,
variables: { id, key, type, status, icon }
} }
`
} }

View File

@ -8,14 +8,15 @@ export default function (params) {
icon icon
} = params } = params
return ` return {
mutation { mutation: `
CreateCategory( mutation($id: ID, $name: String!, $slug: String, $icon: String!) {
id: "${id}", CreateCategory(id: $id, name: $name, slug: $slug, icon: $icon) {
name: "${name}", id
slug: "${slug}", name
icon: "${icon}" }
) { id, name }
} }
` `,
variables: { id, name, slug, icon }
}
} }

View File

@ -7,19 +7,17 @@ export default function (params) {
content = [ content = [
faker.lorem.sentence(), faker.lorem.sentence(),
faker.lorem.sentence() faker.lorem.sentence()
].join('. '), ].join('. ')
disabled = false,
deleted = false
} = params } = params
return ` return {
mutation { mutation: `
CreateComment( mutation($id: ID!, $content: String!) {
id: "${id}", CreateComment(id: $id, content: $content) {
content: "${content}", id
disabled: ${disabled}, }
deleted: ${deleted} }
) { id } `,
} variables: { id, content }
` }
} }

View File

@ -71,8 +71,8 @@ export default function Factory (options = {}) {
return this return this
}, },
async create (node, properties) { async create (node, properties) {
const mutation = this.factories[node](properties) const { mutation, variables } = this.factories[node](properties)
this.lastResponse = await this.graphQLClient.request(mutation) this.lastResponse = await this.graphQLClient.request(mutation, variables)
return this return this
}, },
async relate (node, relationship, properties) { async relate (node, relationship, properties) {

View File

@ -6,12 +6,15 @@ export default function (params) {
read = false read = false
} = params } = params
return ` return {
mutation { mutation: `
CreateNotification( mutation($id: ID, $read: Boolean) {
id: "${id}", CreateNotification(id: $id, read: $read) {
read: ${read}, id
) { id, read } read
} }
` }
`,
variables: { id, read }
}
} }

View File

@ -5,20 +5,17 @@ export default function create (params) {
const { const {
id = uuid(), id = uuid(),
name = faker.company.companyName(), name = faker.company.companyName(),
description = faker.company.catchPhrase(), description = faker.company.catchPhrase()
disabled = false,
deleted = false
} = params } = params
return ` return {
mutation { mutation: `
CreateOrganization( mutation($id: ID!, $name: String!, $description: String!) {
id: "${id}", CreateOrganization(id: $id, name: $name, description: $description) {
name: "${name}", name
description: "${description}", }
disabled: ${disabled}, }
deleted: ${deleted} `,
) { name } variables: { id, name, description }
} }
`
} }

View File

@ -18,17 +18,31 @@ export default function (params) {
deleted = false deleted = false
} = params } = params
return ` return {
mutation { mutation: `
CreatePost( mutation(
id: "${id}", $id: ID!
slug: "${slug}", $slug: String
title: "${title}", $title: String!
content: "${content}", $content: String!
image: "${image}", $image: String
visibility: ${visibility}, $visibility: VisibilityEnum
deleted: ${deleted} $deleted: Boolean
) { title, content } ) {
} CreatePost(
` id: $id
slug: $slug
title: $title
content: $content
image: $image
visibility: $visibility
deleted: $deleted
) {
title
content
}
}
`,
variables: { id, slug, title, content, image, visibility, deleted }
}
} }

View File

@ -6,15 +6,15 @@ export default function create (params) {
id id
} = params } = params
return ` return {
mutation { mutation: `
report( mutation($id: ID!, $description: String!) {
description: "${description}", report(description: $description, id: $id) {
id: "${id}", id
) { createdAt
id, }
createdAt
} }
} `,
` variables: { id, description }
}
} }

View File

@ -3,15 +3,17 @@ import uuid from 'uuid/v4'
export default function (params) { export default function (params) {
const { const {
id = uuid(), id = uuid(),
name name = '#human-connection'
} = params } = params
return ` return {
mutation { mutation: `
CreateTag( mutation($id: ID!, $name: String!) {
id: "${id}", CreateTag(id: $id, name: $name) {
name: "${name}", name
) { name } }
} }
` `,
variables: { id, name }
}
} }

View File

@ -10,34 +10,42 @@ export default function create (params) {
password = '1234', password = '1234',
role = 'user', role = 'user',
avatar = faker.internet.avatar(), avatar = faker.internet.avatar(),
about = faker.lorem.paragraph(), about = faker.lorem.paragraph()
disabled = false,
deleted = false
} = params } = params
return ` return {
mutation { mutation: `
CreateUser( mutation(
id: "${id}", $id: ID!
name: "${name}", $name: String
slug: "${slug}", $slug: String
password: "${password}", $password: String!
email: "${email}", $email: String
avatar: "${avatar}", $avatar: String
about: "${about}", $about: String
role: ${role}, $role: UserGroupEnum
disabled: ${disabled},
deleted: ${deleted}
) { ) {
id CreateUser(
name id: $id
slug name: $name
email slug: $slug
avatar password: $password
role email: $email
deleted avatar: $avatar
disabled about: $about
role: $role
) {
id
name
slug
email
avatar
role
deleted
disabled
}
} }
} `,
` variables: { id, name, slug, password, email, avatar, about, role }
}
} }

View File

@ -1,3 +1,4 @@
import faker from 'faker'
import Factory from './factories' import Factory from './factories'
/* eslint-disable no-multi-spaces */ /* eslint-disable no-multi-spaces */
@ -88,20 +89,23 @@ import Factory from './factories'
f.create('Tag', { id: 't4', name: 'Freiheit' }) f.create('Tag', { id: 't4', name: 'Freiheit' })
]) ])
const mention1 = 'Hey <a class="mention" href="/profile/u3">@jenny-rostock</a>, what\'s up?'
const mention2 = 'Hey <a class="mention" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!'
await Promise.all([ await Promise.all([
asAdmin.create('Post', { id: 'p0' }), asAdmin.create('Post', { id: 'p0' }),
asModerator.create('Post', { id: 'p1' }), asModerator.create('Post', { id: 'p1' }),
asUser.create('Post', { id: 'p2', deleted: true }), asUser.create('Post', { id: 'p2' }),
asTick.create('Post', { id: 'p3' }), asTick.create('Post', { id: 'p3' }),
asTrick.create('Post', { id: 'p4' }), asTrick.create('Post', { id: 'p4' }),
asTrack.create('Post', { id: 'p5' }), asTrack.create('Post', { id: 'p5' }),
asAdmin.create('Post', { id: 'p6' }), asAdmin.create('Post', { id: 'p6' }),
asModerator.create('Post', { id: 'p7' }), asModerator.create('Post', { id: 'p7', content: `${mention1} ${faker.lorem.paragraph()}` }),
asUser.create('Post', { id: 'p8' }), asUser.create('Post', { id: 'p8' }),
asTick.create('Post', { id: 'p9' }), asTick.create('Post', { id: 'p9' }),
asTrick.create('Post', { id: 'p10' }), asTrick.create('Post', { id: 'p10' }),
asTrack.create('Post', { id: 'p11' }), asTrack.create('Post', { id: 'p11' }),
asAdmin.create('Post', { id: 'p12' }), asAdmin.create('Post', { id: 'p12', content: `${mention2} ${faker.lorem.paragraph()}` }),
asModerator.create('Post', { id: 'p13' }), asModerator.create('Post', { id: 'p13' }),
asUser.create('Post', { id: 'p14' }), asUser.create('Post', { id: 'p14' }),
asTick.create('Post', { id: 'p15' }) asTick.create('Post', { id: 'p15' })

View File

@ -3013,10 +3013,10 @@ eslint-plugin-es@^1.3.1:
eslint-utils "^1.3.0" eslint-utils "^1.3.0"
regexpp "^2.0.1" regexpp "^2.0.1"
eslint-plugin-import@~2.17.1: eslint-plugin-import@~2.17.2:
version "2.17.1" version "2.17.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.17.1.tgz#b888feb4d9b3ee155113c8dccdd4bec5db33bdf4" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.17.2.tgz#d227d5c6dc67eca71eb590d2bb62fb38d86e9fcb"
integrity sha512-lzD9uvRvW4MsHzIOMJEDSb5MOV9LzgxRPBaovvOhJqzgxRHYfGy9QOrMuwHIh5ehKFJ7Z3DcrcGKDQ0IbP0EdQ== integrity sha512-m+cSVxM7oLsIpmwNn2WXTJoReOF9f/CtLMo7qOVmKd1KntBy0hEcuNZ3erTmWjx+DxRO0Zcrm5KwAvI9wHcV5g==
dependencies: dependencies:
array-includes "^3.0.3" array-includes "^3.0.3"
contains-path "^0.1.0" contains-path "^0.1.0"

View File

@ -1,10 +1,23 @@
# End-to-End Testing # End-to-End Testing
## Configure cypress
First, you have to tell cypress how to connect to your local neo4j database
among other things. You can copy our template configuration and change the new
file according to your needs.
Make sure you are at the root level of the project. Then:
```bash
# in the top level folder Human-Connection/
$ cp cypress.env.template.json cypress.env.json
```
## Run Tests ## Run Tests
To run the tests, make sure you are at the root level of the project, in your console and run the following command: To run the tests, do this:
```bash ```bash
# in the top level folder Human-Connection/
$ yarn cypress:setup $ yarn cypress:setup
``` ```

View File

@ -83,6 +83,13 @@ The following features will be implemented. This gets done in three steps:
* Editing Comments * Editing Comments
* Upvote comments of others * Upvote comments of others
### Notifications
[Cucumber features](./integration/notifications)
* User @-mentionings
* Notify authors for comments
* Administrative notifications to all users
### Contribution List ### Contribution List
* Show Posts by Tiles * Show Posts by Tiles

View File

@ -293,3 +293,43 @@ Then('I can login successfully with password {string}', password => {
}) })
cy.get('.iziToast-wrapper').should('contain', "You are logged in!") cy.get('.iziToast-wrapper').should('contain', "You are logged in!")
}) })
When('I log in with the following credentials:', table => {
const { email, password } = table.hashes()[0]
cy.login({ email, password })
})
When('open the notification menu and click on the first item', () => {
cy.get('.notifications-menu').click()
cy.get('.notification-mention-post').first().click()
})
Then('see {int} unread notifications in the top menu', count => {
cy.get('.notifications-menu').should('contain', count)
})
Then('I get to the post page of {string}', path => {
path = path.replace('...', '')
cy.url().should('contain', '/post/')
cy.url().should('contain', path)
})
When('I start to write a new post with the title {string} beginning with:', (title, intro) => {
cy.get('.post-add-button').click()
cy.get('input[name="title"]').type(title)
cy.get('.ProseMirror').type(intro)
})
When('mention {string} in the text', (mention) => {
cy.get('.ProseMirror').type(' @')
cy.get('.suggestion-list__item').contains(mention).click()
cy.debug()
})
Then('the notification gets marked as read', () => {
cy.get('.notification').first().should('have.class', 'read')
})
Then('there are no notifications in the top menu', () => {
cy.get('.notifications-menu').should('contain', '0')
})

View File

@ -0,0 +1,31 @@
Feature: Notifications for a mentions
As a user
I want to be notified if sb. mentions me in a post or comment
In order join conversations about or related to me
Background:
Given we have the following user accounts:
| name | slug | email | password |
| Wolle aus Hamburg | wolle-aus-hamburg | wolle@example.org | 1234 |
| Matt Rider | matt-rider | matt@example.org | 4321 |
Scenario: Mention another user, re-login as this user and see notifications
Given I log in with the following credentials:
| email | password |
| wolle@example.org | 1234 |
And I start to write a new post with the title "Hey Matt" beginning with:
"""
Big shout to our fellow contributor
"""
And mention "@matt-rider" in the text
And I click on "Save"
When I log out
And I log in with the following credentials:
| email | password |
| matt@example.org | 4321 |
And see 1 unread notifications in the top menu
And open the notification menu and click on the first item
Then I get to the post page of ".../hey-matt"
And the notification gets marked as read
But when I refresh the page
Then there are no notifications in the top menu

View File

@ -46,7 +46,8 @@ Cypress.Commands.add('login', ({ email, password }) => {
cy.get('button[name=submit]') cy.get('button[name=submit]')
.as('submitButton') .as('submitButton')
.click() .click()
cy.location('pathname').should('eq', '/') // we're in! cy.get('.iziToast-message').should('contain', 'You are logged in!')
cy.get('.iziToast-close').click()
}) })
Cypress.Commands.add('logout', (email, password) => { Cypress.Commands.add('logout', (email, password) => {

View File

@ -1,41 +1,25 @@
# Installation # Installation
## General Install Instructions
The repository can be found on GitHub. [https://github.com/Human-Connection/Human-Connection](https://github.com/Human-Connection/Human-Connection) The repository can be found on GitHub. [https://github.com/Human-Connection/Human-Connection](https://github.com/Human-Connection/Human-Connection)
{% hint style="info" %} We give write permissions to every developer who asks for it. Just text us on
TODO: Create documentation section for How to Start and Beginners. [Discord](https://discord.gg/6ub73U3).
{% endhint %}
Here are some general informations about our [GitHub Standard Fork & Pull Request Workflow](https://gist.github.com/Chaser324/ce0505fbed06b947d962). ## Clone the Repository
#### Fork the Repository
Click on the fork button. Clone the repository, this will create a new folder called `Human-Connection`:
![Fork screenshot](.gitbook/assets/screenshot-forking-nitro.png)
#### Clone your new Repository
Set the current working folder to the path in which the repository should be cloned \(copied\).
```bash
$ cd PATH-FOR-REPO
```
For cloning your new repository to your local machine modify the following command to add your GitHub user name.
{% tabs %} {% tabs %}
{% tab title="HTTPS" %} {% tab title="HTTPS" %}
```bash ```bash
$ git clone https://github.com/YOUR-GITHUB-USERNAME/Human-Connection.git $ git clone https://github.com/Human-Connection/Human-Connection.git
``` ```
{% endtab %} {% endtab %}
{% tab title="SSH" %} {% tab title="SSH" %}
```bash ```bash
$ git clone git@github.com:YOUR-GITHUB-USERNAME/Human-Connection.git $ git clone git@github.com:Human-Connection/Human-Connection.git
``` ```
{% endtab %} {% endtab %}
{% endtabs %} {% endtabs %}
@ -46,21 +30,21 @@ Change into the new folder.
$ cd Human-Connection $ cd Human-Connection
``` ```
Add the original Human Connection repository as `upstream`. This prepares you to synchronize your local clone with a simple pull command in the future. ## Directory Layout
{% tabs %} There are four important directories:
{% tab title="HTTPS" %} * [Backend](./backend) runs on the server and is a middleware between database and frontend
```bash * [Frontend](./webapp) is a server-side-rendered and client-side-rendered web frontend
$ git remote add upstream https://github.com/Human-Connection/Human-Connection.git * [Deployment](./deployment) configuration for kubernetes
``` * [Cypress](./cypress) contains end-to-end tests and executable feature specifications
{% endtab %}
{% tab title="SSH" %} In order to setup the application and start to develop features you have to
```bash setup **frontend** and **backend**.
$ git remote add upstream git@github.com:Human-Connection/Human-Connection.git
``` There are two approaches:
{% endtab %}
{% endtabs %} 1. Local installation, which means you have to take care of dependencies yourself
2. **Or** Install everything through docker which takes care of dependencies for you
## Docker Installation ## Docker Installation
@ -95,72 +79,4 @@ $ docker-compose --version
docker-compose version 1.23.2 docker-compose version 1.23.2
``` ```
### Install Nitro with Docker
Run the following command to install Nitro as a Docker container. This installation includes Neo4j.
The installation takes a bit longer on the first pass or on rebuild ...
```bash
$ docker-compose up
# rebuild the containers for a cleanup
$ docker-compose up --build
```
#### Seed Neo4j in Docker
To seed the Neo4j database with default data, that GraphQL requests or playing with our GraphQL Playground returns anything else than an empty response, run the command.
Run the following command to seed the Neo4j database with default data requested by Nitro-Web through GraphQL or when you play with our GraphQL playground.
```bash
# open another terminal
# create indices etc.
$ docker-compose exec neo4j migrate
# seed database
$ docker-compose exec backend yarn run db:seed
```
**Wipe out Neo4j database in Docker**
To wipe out your neo4j database and delete the volumes send command:
```bash
# open another terminal and run
$ docker-compose down -v
```
**Video Tutorial**
{% hint style="info" %}
TODO: Link to video
{% endhint %}
#### Development with Kubernetes
For further informations see also our [Kubernetes documentation](https://github.com/Human-Connection/Human-Connection/tree/9bede1913b829a5c2916fc206c1fe4c83c49a4bc/kubernetes.md).
## Local Installation
#### Install the dependencies
```bash
$ yarn install
$ cd backend && yarn install
$ cd ../webapp && yarn install
$ cd ..
```
#### Copy Environment Variables
```bash
$ cp cypress.env.template.json cypress.env.json
$ cp backend/.env.template backend/.env
$ cp webapp/.env.template webapp/.env
```
Configure the new files according to your needs and your local setup.

View File

@ -8,6 +8,8 @@
"nonGlobalStepDefinitions": true "nonGlobalStepDefinitions": true
}, },
"scripts": { "scripts": {
"db:seed": "cd backend && yarn run db:seed",
"db:reset": "cd backend && yarn run db:reset",
"cypress:backend:server": "cd backend && yarn run test:before:server", "cypress:backend:server": "cd backend && yarn run test:before:server",
"cypress:backend:seeder": "cd backend && yarn run test:before:seeder", "cypress:backend:seeder": "cd backend && yarn run test:before:seeder",
"cypress:webapp": "cd webapp && cross-env GRAPHQL_URI=http://localhost:4123 yarn run dev", "cypress:webapp": "cd webapp && cross-env GRAPHQL_URI=http://localhost:4123 yarn run dev",
@ -25,4 +27,4 @@
"neo4j-driver": "^1.7.3", "neo4j-driver": "^1.7.3",
"npm-run-all": "^4.1.5" "npm-run-all": "^4.1.5"
} }
} }

View File

@ -16,6 +16,7 @@
/> />
<no-ssr> <no-ssr>
<hc-editor <hc-editor
:users="users"
:value="form.content" :value="form.content"
@input="updateEditorContent" @input="updateEditorContent"
/> />
@ -48,7 +49,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor.vue' import HcEditor from '~/components/Editor'
export default { export default {
components: { components: {
@ -70,7 +71,8 @@ export default {
id: null, id: null,
loading: false, loading: false,
disabled: false, disabled: false,
slug: null slug: null,
users: []
} }
}, },
watch: { watch: {
@ -125,6 +127,21 @@ export default {
// this.form.content = value // this.form.content = value
this.$refs.contributionForm.update('content', value) this.$refs.contributionForm.update('content', value)
} }
},
apollo: {
User: {
query() {
return gql(`{
User(orderBy: slug_asc) {
id
slug
}
}`)
},
result(result) {
this.users = result.data.User
}
}
} }
} }
</script> </script>

View File

@ -1,5 +1,29 @@
<template> <template>
<div class="editor"> <div class="editor">
<div
v-show="showSuggestions"
ref="suggestions"
class="suggestion-list"
>
<template v-if="hasResults">
<div
v-for="(user, index) in filteredUsers"
:key="user.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedUserIndex === index }"
@click="selectUser(user)"
>
@{{ user.slug }}
</div>
</template>
<div
v-else
class="suggestion-list__item is-empty"
>
No users found
</div>
</div>
<editor-menu-bubble :editor="editor"> <editor-menu-bubble :editor="editor">
<div <div
ref="menu" ref="menu"
@ -137,6 +161,8 @@
<script> <script>
import linkify from 'linkify-it' import linkify from 'linkify-it'
import stringHash from 'string-hash' import stringHash from 'string-hash'
import Fuse from 'fuse.js'
import tippy from 'tippy.js'
import { import {
Editor, Editor,
EditorContent, EditorContent,
@ -160,6 +186,7 @@ import {
Link, Link,
History History
} from 'tiptap-extensions' } from 'tiptap-extensions'
import Mention from './nodes/Mention.js'
let throttleInputEvent let throttleInputEvent
@ -170,6 +197,7 @@ export default {
EditorMenuBubble EditorMenuBubble
}, },
props: { props: {
users: { type: Array, default: () => [] },
value: { type: String, default: '' }, value: { type: String, default: '' },
doc: { type: Object, default: () => {} } doc: { type: Object, default: () => {} }
}, },
@ -198,7 +226,72 @@ export default {
emptyNodeClass: 'is-empty', emptyNodeClass: 'is-empty',
emptyNodeText: 'Schreib etwas inspirerendes…' emptyNodeText: 'Schreib etwas inspirerendes…'
}), }),
new History() new History(),
new Mention({
items: () => {
return this.users
},
onEnter: ({ items, query, range, command, virtualNode }) => {
this.query = query
this.filteredUsers = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMention = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = query
this.filteredUsers = items
this.suggestionRange = range
this.navigatedUserIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
// reset all saved values
this.query = null
this.filteredUsers = []
this.suggestionRange = null
this.navigatedUserIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
if (!query) {
return items
}
const fuse = new Fuse(items, {
threshold: 0.2,
keys: ['slug']
})
return fuse.search(query)
}
})
], ],
onUpdate: e => { onUpdate: e => {
clearTimeout(throttleInputEvent) clearTimeout(throttleInputEvent)
@ -206,7 +299,21 @@ export default {
} }
}), }),
linkUrl: null, linkUrl: null,
linkMenuIsActive: false linkMenuIsActive: false,
query: null,
suggestionRange: null,
filteredUsers: [],
navigatedUserIndex: 0,
insertMention: () => {},
observer: null
}
},
computed: {
hasResults() {
return this.filteredUsers.length
},
showSuggestions() {
return this.query || this.hasResults
} }
}, },
watch: { watch: {
@ -226,6 +333,77 @@ export default {
this.editor.destroy() this.editor.destroy()
}, },
methods: { methods: {
// navigate to the previous item
// if it's the first item, navigate to the last one
upHandler() {
this.navigatedUserIndex =
(this.navigatedUserIndex + this.filteredUsers.length - 1) %
this.filteredUsers.length
},
// navigate to the next item
// if it's the last item, navigate to the first one
downHandler() {
this.navigatedUserIndex =
(this.navigatedUserIndex + 1) % this.filteredUsers.length
},
enterHandler() {
const user = this.filteredUsers[this.navigatedUserIndex]
if (user) {
this.selectUser(user)
}
},
// we have to replace our suggestion text with a mention
// so it's important to pass also the position of your suggestion text
selectUser(user) {
this.insertMention({
range: this.suggestionRange,
attrs: {
// TODO: use router here
url: `/profile/${user.id}`,
label: user.slug
}
})
this.editor.focus()
},
// renders a popup with suggestions
// tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
renderPopup(node) {
if (this.popup) {
return
}
this.popup = tippy(node, {
content: this.$refs.suggestions,
trigger: 'mouseenter',
interactive: true,
theme: 'dark',
placement: 'top-start',
inertia: true,
duration: [400, 200],
showOnInit: true,
arrow: true,
arrowType: 'round'
})
// we have to update tippy whenever the DOM is updated
if (MutationObserver) {
this.observer = new MutationObserver(() => {
this.popup.popperInstance.scheduleUpdate()
})
this.observer.observe(this.$refs.suggestions, {
childList: true,
subtree: true,
characterData: true
})
}
},
destroyPopup() {
if (this.popup) {
this.popup.destroy()
this.popup = null
}
if (this.observer) {
this.observer.disconnect()
}
},
onUpdate(e) { onUpdate(e) {
const content = e.getHTML() const content = e.getHTML()
const contentHash = stringHash(content) const contentHash = stringHash(content)
@ -273,6 +451,60 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
.suggestion-list {
padding: 0.2rem;
border: 2px solid rgba($color-neutral-0, 0.1);
font-size: 0.8rem;
font-weight: bold;
&__no-results {
padding: 0.2rem 0.5rem;
}
&__item {
border-radius: 5px;
padding: 0.2rem 0.5rem;
margin-bottom: 0.2rem;
cursor: pointer;
&:last-child {
margin-bottom: 0;
}
&.is-selected,
&:hover {
background-color: rgba($color-neutral-100, 0.2);
}
&.is-empty {
opacity: 0.5;
}
}
}
.tippy-tooltip.dark-theme {
background-color: $color-neutral-0;
padding: 0;
font-size: 1rem;
text-align: inherit;
color: $color-neutral-100;
border-radius: 5px;
.tippy-backdrop {
display: none;
}
.tippy-roundarrow {
fill: $color-neutral-0;
}
.tippy-popper[x-placement^='top'] & .tippy-arrow {
border-top-color: $color-neutral-0;
}
.tippy-popper[x-placement^='bottom'] & .tippy-arrow {
border-bottom-color: $color-neutral-0;
}
.tippy-popper[x-placement^='left'] & .tippy-arrow {
border-left-color: $color-neutral-0;
}
.tippy-popper[x-placement^='right'] & .tippy-arrow {
border-right-color: $color-neutral-0;
}
}
.ProseMirror { .ProseMirror {
padding: $space-base; padding: $space-base;
margin: -$space-base; margin: -$space-base;
@ -302,6 +534,9 @@ li > p {
} }
.editor { .editor {
.mention-suggestion {
color: $color-primary;
}
&__floating-menu { &__floating-menu {
position: absolute; position: absolute;
margin-top: -0.25rem; margin-top: -0.25rem;

View File

@ -0,0 +1,29 @@
import { Node } from 'tiptap'
import { replaceText } from 'tiptap-commands'
import { Mention as TipTapMention } from 'tiptap-extensions'
export default class Mention extends TipTapMention {
get schema() {
const patchedSchema = super.schema
patchedSchema.attrs = {
url: {},
label: {}
}
patchedSchema.toDOM = node => {
return [
'a',
{
class: this.options.mentionClass,
href: node.attrs.url,
target: '_blank'
},
`${this.options.matcher.char}${node.attrs.label}`
]
}
patchedSchema.parseDOM = [
// this is not implemented
]
return patchedSchema
}
}

View File

@ -0,0 +1,44 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Editor from './'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('Editor.vue', () => {
let wrapper
let propsData
beforeEach(() => {
propsData = {}
})
describe('mount', () => {
let Wrapper = () => {
return (wrapper = mount(Editor, {
propsData,
localVue,
sync: false,
stubs: { transition: false }
}))
}
it('renders', () => {
expect(Wrapper().is('div')).toBe(true)
})
describe('given a piece of text', () => {
beforeEach(() => {
propsData.value = 'I am a piece of text'
})
it.skip('renders', () => {
wrapper = Wrapper()
expect(wrapper.find('.ProseMirror').text()).toContain(
'I am a piece of text'
)
})
})
})
})

View File

@ -4,11 +4,12 @@
:image="post.image" :image="post.image"
:class="{'post-card': true, 'disabled-content': post.disabled}" :class="{'post-card': true, 'disabled-content': post.disabled}"
> >
<a <nuxt-link
v-router-link
class="post-link" class="post-link"
:href="href(post)" :to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
>{{ post.title }}</a> >
{{ post.title }}
</nuxt-link>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view --> <!-- TODO: replace editor content with tiptap render view -->
<ds-space margin-bottom="large"> <ds-space margin-bottom="large">
@ -75,6 +76,7 @@
import HcUser from '~/components/User' import HcUser from '~/components/User'
import ContentMenu from '~/components/ContentMenu' import ContentMenu from '~/components/ContentMenu'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { mapGetters } from 'vuex'
export default { export default {
name: 'HcPostCard', name: 'HcPostCard',
@ -89,26 +91,16 @@ export default {
} }
}, },
computed: { computed: {
...mapGetters({
user: 'auth/user'
}),
excerpt() { excerpt() {
// remove all links from excerpt to prevent issues with the serounding link return this.$filters.removeLinks(this.post.contentExcerpt)
let excerpt = this.post.contentExcerpt.replace(/<a.*>(.+)<\/a>/gim, '$1')
// do not display content that is only linebreaks
if (excerpt.replace(/<br>/gim, '').trim() === '') {
excerpt = ''
}
return excerpt
}, },
isAuthor() { isAuthor() {
return this.$store.getters['auth/user'].id === this.post.author.id const { author } = this.post
} if (!author) return false
}, return this.user.id === this.post.author.id
methods: {
href(post) {
return this.$router.resolve({
name: 'post-id-slug',
params: { id: post.id, slug: post.slug }
}).href
} }
} }
} }
@ -130,6 +122,7 @@ export default {
} }
.post-link { .post-link {
margin: 15px;
display: block; display: block;
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -0,0 +1,62 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import PostCard from '.'
import Styleguide from '@human-connection/styleguide'
import Vuex from 'vuex'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('PostCard', () => {
let wrapper
let stubs
let mocks
let propsData
let getters
beforeEach(() => {
propsData = {}
stubs = {
NuxtLink: RouterLinkStub
}
mocks = {
$t: jest.fn()
}
getters = {
'auth/user': () => {
return {}
}
}
})
const Wrapper = () => {
const store = new Vuex.Store({
getters
})
return mount(PostCard, {
stubs,
mocks,
propsData,
store,
localVue
})
}
describe('given a post', () => {
beforeEach(() => {
propsData.post = {
title: "It's a title"
}
})
it('renders title', () => {
expect(Wrapper().text()).toContain("It's a title")
})
})
})

View File

@ -0,0 +1,71 @@
<template>
<ds-space
:class="{'notification': true, 'read': notification.read}"
margin-bottom="x-small"
>
<no-ssr>
<ds-space margin-bottom="x-small">
<hc-user
:user="post.author"
:date-time="post.createdAt"
:trunc="35"
/>
</ds-space>
<ds-text color="soft">
{{ $t("notifications.menu.mentioned") }}
</ds-text>
</no-ssr>
<ds-space margin-bottom="x-small" />
<nuxt-link
class="notification-mention-post"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
@click.native="$emit('read')"
>
<ds-space margin-bottom="x-small">
<ds-card
:header="post.title"
:image="post.image"
hover
space="x-small"
>
<ds-space margin-bottom="x-small" />
<!-- eslint-disable vue/no-v-html -->
<div v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
</ds-card>
</ds-space>
</nuxt-link>
</ds-space>
</template>
<script>
import HcUser from '~/components/User'
export default {
name: 'Notification',
components: {
HcUser
},
props: {
notification: {
type: Object,
required: true
}
},
computed: {
excerpt() {
return this.$filters.removeLinks(this.post.contentExcerpt)
},
post() {
return this.notification.post || {}
}
}
}
</script>
<style>
.notification.read {
opacity: 0.6; /* Real browsers */
filter: alpha(opacity = 60); /* MSIE */
}
</style>

View File

@ -0,0 +1,64 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import Notification from '.'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
describe('Notification', () => {
let wrapper
let stubs
let mocks
let propsData
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn()
}
stubs = {
NuxtLink: RouterLinkStub
}
})
const Wrapper = () => {
return mount(Notification, {
stubs,
mocks,
propsData,
localVue
})
}
describe('given a notification', () => {
beforeEach(() => {
propsData.notification = {
post: {
title: "It's a title"
}
}
})
it('renders title', () => {
expect(Wrapper().text()).toContain("It's a title")
})
it('has no class "read"', () => {
expect(Wrapper().classes()).not.toContain('read')
})
describe('that is read', () => {
beforeEach(() => {
propsData.notification.read = true
})
it('has class "read"', () => {
expect(Wrapper().classes()).toContain('read')
})
})
})
})

View File

@ -0,0 +1,32 @@
<template>
<div>
<notification
v-for="notification in notifications"
:key="notification.id"
:notification="notification"
@read="markAsRead(notification.id)"
/>
</div>
</template>
<script>
import Notification from '../Notification'
export default {
name: 'NotificationList',
components: {
Notification
},
props: {
notifications: {
type: Array,
required: true
}
},
methods: {
markAsRead(notificationId) {
this.$emit('markAsRead', notificationId)
}
}
}
</script>

View File

@ -0,0 +1,130 @@
import {
config,
shallowMount,
mount,
createLocalVue,
RouterLinkStub
} from '@vue/test-utils'
import NotificationList from '.'
import Notification from '../Notification'
import Vue from 'vue'
import Vuex from 'vuex'
import Filters from '~/plugins/vue-filters'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
localVue.filter('truncate', string => string)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('NotificationList.vue', () => {
let wrapper
let Wrapper
let mocks
let stubs
let store
let propsData
beforeEach(() => {
store = new Vuex.Store({
getters: {
'auth/user': () => {
return {}
}
}
})
mocks = {
$t: jest.fn()
}
stubs = {
NuxtLink: RouterLinkStub
}
propsData = {
notifications: [
{
id: 'notification-41',
read: false,
post: {
id: 'post-1',
title: 'some post title',
contentExcerpt: 'this is a post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe'
}
}
},
{
id: 'notification-42',
read: false,
post: {
id: 'post-2',
title: 'another post title',
contentExcerpt: 'this is yet another post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe'
}
}
}
]
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(NotificationList, {
propsData,
mocks,
store,
localVue
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders Notification.vue for each notification of the user', () => {
expect(wrapper.findAll(Notification)).toHaveLength(2)
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(NotificationList, {
propsData,
mocks,
stubs,
store,
localVue
})
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('click on a notification', () => {
beforeEach(() => {
wrapper
.findAll('.notification-mention-post')
.at(1)
.trigger('click')
})
it("emits 'markAsRead' with the notificationId", () => {
expect(wrapper.emitted('markAsRead')).toBeTruthy()
expect(wrapper.emitted('markAsRead')[0]).toEqual(['notification-42'])
})
})
})
})

View File

@ -0,0 +1,112 @@
<template>
<ds-button
v-if="totalNotifications <= 0"
class="notifications-menu"
disabled
icon="bell"
>
{{ totalNotifications }}
</ds-button>
<dropdown
v-else
class="notifications-menu"
>
<template
slot="default"
slot-scope="{toggleMenu}"
>
<ds-button
primary
icon="bell"
@click.prevent="toggleMenu"
>
{{ totalNotifications }}
</ds-button>
</template>
<template
slot="popover"
>
<div class="notifications-menu-popover">
<notification-list
:notifications="notifications"
@markAsRead="markAsRead"
/>
</div>
</template>
</dropdown>
</template>
<script>
import NotificationList from '../NotificationList'
import Dropdown from '~/components/Dropdown'
import gql from 'graphql-tag'
const MARK_AS_READ = gql(`
mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) {
id
read
}
}`)
const NOTIFICATIONS = gql(`{
currentUser {
id
notifications(read: false, orderBy: createdAt_desc) {
id read createdAt
post {
id createdAt disabled deleted title contentExcerpt slug
author { id slug name disabled deleted }
}
}
}
}`)
export default {
name: 'NotificationMenu',
components: {
NotificationList,
Dropdown
},
computed: {
totalNotifications() {
return (this.notifications || []).length
}
},
methods: {
async markAsRead(notificationId) {
const variables = { id: notificationId, read: true }
try {
await this.$apollo.mutate({
mutation: MARK_AS_READ,
variables
})
} catch (err) {
throw new Error(err)
}
}
},
apollo: {
notifications: {
query: NOTIFICATIONS,
update: data => {
const {
currentUser: { notifications }
} = data
return notifications
}
}
}
}
</script>
<style>
.notifications-menu {
display: flex;
align-items: center;
}
.notifications-menu-popover {
max-width: 500px;
}
</style>

View File

@ -0,0 +1,94 @@
import { config, shallowMount, createLocalVue } from '@vue/test-utils'
import NotificationMenu from '.'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Filters)
localVue.filter('truncate', string => string)
config.stubs['dropdown'] = '<span class="dropdown"><slot /></span>'
describe('NotificationMenu.vue', () => {
let wrapper
let Wrapper
let mocks
let data
beforeEach(() => {
mocks = {
$t: jest.fn()
}
data = () => {
return {
notifications: []
}
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(NotificationMenu, {
data,
mocks,
localVue
})
}
it('counter displays 0', () => {
wrapper = Wrapper()
expect(wrapper.find('ds-button-stub').text()).toEqual('0')
})
it('no dropdown is rendered', () => {
wrapper = Wrapper()
expect(wrapper.contains('.dropdown')).toBe(false)
})
describe('given some notifications', () => {
beforeEach(() => {
data = () => {
return {
notifications: [
{
id: 'notification-41',
read: false,
post: {
id: 'post-1',
title: 'some post title',
contentExcerpt: 'this is a post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe'
}
}
},
{
id: 'notification-42',
read: false,
post: {
id: 'post-2',
title: 'another post title',
contentExcerpt: 'this is yet another post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe'
}
}
}
]
}
}
})
it('displays the total number of notifications', () => {
wrapper = Wrapper()
expect(wrapper.find('ds-button-stub').text()).toEqual('2')
})
})
})
})

View File

@ -10,7 +10,7 @@
</template> </template>
<script> <script>
import seo from '~/components/_mixins/seo' import seo from '~/mixins/seo'
export default { export default {
mixins: [seo] mixins: [seo]

View File

@ -31,6 +31,9 @@
/> />
</no-ssr> </no-ssr>
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<no-ssr>
<notification-menu />
</no-ssr>
<no-ssr> <no-ssr>
<dropdown class="avatar-menu"> <dropdown class="avatar-menu">
<template <template
@ -116,11 +119,12 @@
<script> <script>
import { mapGetters, mapActions } from 'vuex' import { mapGetters, mapActions } from 'vuex'
import LocaleSwitch from '~/components/LocaleSwitch' import LocaleSwitch from '~/components/LocaleSwitch'
import Dropdown from '~/components/Dropdown'
import SearchInput from '~/components/SearchInput.vue' import SearchInput from '~/components/SearchInput.vue'
import Modal from '~/components/Modal' import Modal from '~/components/Modal'
import seo from '~/components/_mixins/seo' import NotificationMenu from '~/components/notifications/NotificationMenu'
import userName from '~/components/_mixins/userName' import Dropdown from '~/components/Dropdown'
import seo from '~/mixins/seo'
import userName from '~/mixins/userName'
export default { export default {
components: { components: {
@ -128,7 +132,8 @@ export default {
LocaleSwitch, LocaleSwitch,
SearchInput, SearchInput,
Modal, Modal,
LocaleSwitch LocaleSwitch,
NotificationMenu
}, },
mixins: [seo, userName], mixins: [seo, userName],
data() { data() {

View File

@ -19,6 +19,11 @@
"userAnonym": "Anonymus", "userAnonym": "Anonymus",
"socialMedia": "Wo sonst finde ich" "socialMedia": "Wo sonst finde ich"
}, },
"notifications": {
"menu": {
"mentioned": "hat dich in einem Beitrag erwähnt"
}
},
"search": { "search": {
"placeholder": "Suchen", "placeholder": "Suchen",
"hint": "Wonach suchst du?", "hint": "Wonach suchst du?",

View File

@ -19,6 +19,11 @@
"userAnonym": "Anonymous", "userAnonym": "Anonymous",
"socialMedia": "Where else can I find" "socialMedia": "Where else can I find"
}, },
"notifications": {
"menu": {
"mentioned": "has mentioned you in a post"
}
},
"search": { "search": {
"placeholder": "Search", "placeholder": "Search",
"hint": "What are you searching for?", "hint": "What are you searching for?",

View File

@ -196,15 +196,6 @@ module.exports = {
** You can extend webpack config here ** You can extend webpack config here
*/ */
extend(config, ctx) { extend(config, ctx) {
// Run ESLint on save
if (ctx.isDev && ctx.isClient) {
config.module.rules.push({
enforce: 'pre',
test: /\.(js|vue)$/,
loader: 'eslint-loader',
exclude: /(node_modules)/
})
}
if (process.env.STYLEGUIDE_DEV) { if (process.env.STYLEGUIDE_DEV) {
const path = require('path') const path = require('path')
config.resolve.alias['@@'] = path.resolve( config.resolve.alias['@@'] = path.resolve(

View File

@ -74,11 +74,13 @@
"eslint-loader": "~2.1.2", "eslint-loader": "~2.1.2",
"eslint-plugin-prettier": "~3.0.1", "eslint-plugin-prettier": "~3.0.1",
"eslint-plugin-vue": "~5.2.2", "eslint-plugin-vue": "~5.2.2",
"fuse.js": "^3.4.4",
"jest": "~24.7.1", "jest": "~24.7.1",
"node-sass": "~4.11.0", "node-sass": "~4.11.0",
"nodemon": "~1.18.11", "nodemon": "~1.18.11",
"prettier": "~1.14.3", "prettier": "~1.14.3",
"sass-loader": "~7.1.0", "sass-loader": "~7.1.0",
"tippy.js": "^4.2.1",
"vue-jest": "~3.0.4", "vue-jest": "~3.0.4",
"vue-svg-loader": "~0.12.0" "vue-svg-loader": "~0.12.0"
} }

View File

@ -34,7 +34,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import uniqBy from 'lodash/uniqBy' import uniqBy from 'lodash/uniqBy'
import HcPostCard from '~/components/PostCard.vue' import HcPostCard from '~/components/PostCard'
import HcLoadMore from '~/components/LoadMore.vue' import HcLoadMore from '~/components/LoadMore.vue'
export default { export default {

View File

@ -56,7 +56,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import HcPostCard from '~/components/PostCard.vue' import HcPostCard from '~/components/PostCard'
import HcEmpty from '~/components/Empty.vue' import HcEmpty from '~/components/Empty.vue'
export default { export default {

View File

@ -14,7 +14,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import HcContributionForm from '~/components/ContributionForm.vue' import HcContributionForm from '~/components/ContributionForm'
export default { export default {
components: { components: {

View File

@ -14,7 +14,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import HcContributionForm from '~/components/ContributionForm.vue' import HcContributionForm from '~/components/ContributionForm'
export default { export default {
components: { components: {

View File

@ -319,7 +319,7 @@
import uniqBy from 'lodash/uniqBy' import uniqBy from 'lodash/uniqBy'
import User from '~/components/User' import User from '~/components/User'
import HcPostCard from '~/components/PostCard.vue' import HcPostCard from '~/components/PostCard'
import HcFollowButton from '~/components/FollowButton.vue' import HcFollowButton from '~/components/FollowButton.vue'
import HcCountTo from '~/components/CountTo.vue' import HcCountTo from '~/components/CountTo.vue'
import HcBadges from '~/components/Badges.vue' import HcBadges from '~/components/Badges.vue'

View File

@ -6,7 +6,7 @@ import formatRelative from 'date-fns/formatRelative'
import addSeconds from 'date-fns/addSeconds' import addSeconds from 'date-fns/addSeconds'
import accounting from 'accounting' import accounting from 'accounting'
export default ({ app }) => { export default ({ app = {} }) => {
const locales = { const locales = {
en: enUS, en: enUS,
de: de, de: de,
@ -88,6 +88,17 @@ export default ({ app }) => {
return index === 0 ? letter.toUpperCase() : letter.toLowerCase() return index === 0 ? letter.toUpperCase() : letter.toLowerCase()
}) })
.replace(/\s+/g, '') .replace(/\s+/g, '')
},
removeLinks: content => {
if (!content) return ''
// remove all links from excerpt to prevent issues with the surrounding link
let excerpt = content.replace(/<a.*>(.+)<\/a>/gim, '$1')
// do not display content that is only linebreaks
if (excerpt.replace(/<br>/gim, '').trim() === '') {
excerpt = ''
}
return excerpt
} }
}) })

View File

@ -74,21 +74,38 @@ export const actions = {
data: { currentUser } data: { currentUser }
} = await client.query({ } = await client.query({
query: gql(`{ query: gql(`{
currentUser { currentUser {
id
name
slug
email
avatar
role
about
locationName
socialMedia {
id id
name url
slug }
email notifications(read: false, orderBy: createdAt_desc) {
avatar id
role read
about createdAt
locationName post {
socialMedia { author {
id id
url slug
name
disabled
deleted
}
title
contentExcerpt
slug
} }
} }
}`) }
}`)
}) })
if (!currentUser) return dispatch('logout') if (!currentUser) return dispatch('logout')
commit('SET_USER', currentUser) commit('SET_USER', currentUser)

View File

@ -0,0 +1,89 @@
import gql from 'graphql-tag'
export const state = () => {
return {
notifications: null,
pending: false
}
}
export const mutations = {
SET_NOTIFICATIONS(state, notifications) {
state.notifications = notifications
},
SET_PENDING(state, pending) {
state.pending = pending
},
UPDATE_NOTIFICATIONS(state, notification) {
const notifications = state.notifications
const toBeUpdated = notifications.find(n => {
return n.id === notification.id
})
toBeUpdated = { ...toBeUpdated, ...notification }
}
}
export const getters = {
notifications(state) {
return !!state.notifications
}
}
export const actions = {
async init({ getters, commit }) {
if (getters.notifications) return
commit('SET_PENDING', true)
const client = this.app.apolloProvider.defaultClient
let notifications
try {
const {
data: { currentUser }
} = await client.query({
query: gql(`{
currentUser {
id
notifications(orderBy: createdAt_desc) {
id
read
createdAt
post {
author {
id
slug
name
disabled
deleted
}
title
contentExcerpt
slug
}
}
}
}`)
})
notifications = currentUser.notifications
console.log(notifications)
commit('SET_NOTIFICATIONS', notifications)
} finally {
commit('SET_PENDING', false)
}
return notifications
},
async markAsRead({ commit, rootGetters }, notificationId) {
const client = this.app.apolloProvider.defaultClient
const mutation = gql(`
mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) {
id
read
}
}
`)
const variables = { id: notificationId, read: true }
const {
data: { UpdateNotification }
} = await client.mutate({ mutation, variables })
commit('UPDATE_NOTIFICATIONS', UpdateNotification)
}
}

View File

@ -1381,6 +1381,11 @@
"@types/express-serve-static-core" "*" "@types/express-serve-static-core" "*"
"@types/mime" "*" "@types/mime" "*"
"@types/stack-utils@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
"@types/strip-bom@^3.0.0": "@types/strip-bom@^3.0.0":
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2"
@ -4878,6 +4883,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
fuse.js@^3.4.4:
version "3.4.4"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.4.tgz#f98f55fcb3b595cf6a3e629c5ffaf10982103e95"
integrity sha512-pyLQo/1oR5Ywf+a/tY8z4JygnIglmRxVUOiyFAbd11o9keUDpUJSMGRWJngcnkURj30kDHPmhoKY8ChJiz3EpQ==
gauge@~2.7.3: gauge@~2.7.3:
version "2.7.4" version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@ -6170,6 +6180,15 @@ jest-message-util@^24.7.1:
version "24.7.1" version "24.7.1"
resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.7.1.tgz#f1dc3a6c195647096a99d0f1dadbc447ae547018" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.7.1.tgz#f1dc3a6c195647096a99d0f1dadbc447ae547018"
integrity sha512-dk0gqVtyqezCHbcbk60CdIf+8UHgD+lmRHifeH3JRcnAqh4nEyPytSc9/L1+cQyxC+ceaeP696N4ATe7L+omcg== integrity sha512-dk0gqVtyqezCHbcbk60CdIf+8UHgD+lmRHifeH3JRcnAqh4nEyPytSc9/L1+cQyxC+ceaeP696N4ATe7L+omcg==
dependencies:
"@babel/code-frame" "^7.0.0"
"@jest/test-result" "^24.7.1"
"@jest/types" "^24.7.0"
"@types/stack-utils" "^1.0.1"
chalk "^2.0.1"
micromatch "^3.1.10"
slash "^2.0.0"
stack-utils "^1.0.1"
jest-mock@^24.7.0: jest-mock@^24.7.0:
version "24.7.0" version "24.7.0"
@ -8003,7 +8022,7 @@ pn@^1.1.0:
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
popper.js@^1.15.0: popper.js@^1.14.7, popper.js@^1.15.0:
version "1.15.0" version "1.15.0"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2"
integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA==
@ -9871,7 +9890,7 @@ stack-trace@0.0.10:
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
stack-utils@^1.0.2: stack-utils@^1.0.1, stack-utils@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8"
integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA== integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA==
@ -10336,6 +10355,13 @@ timsort@^0.3.0:
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
tippy.js@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-4.2.1.tgz#9e4939d976465f77229b05a3cb233b5dc28cf850"
integrity sha512-xEE7zYNgQxCDdPcuT6T04f0frPh0wO7CcIqJKMFazU/NqusyjCgYSkLRosIHoiRkZMRzSPOudC8wRN5GjvAyOQ==
dependencies:
popper.js "^1.14.7"
tiptap-commands@^1.7.0: tiptap-commands@^1.7.0:
version "1.7.0" version "1.7.0"
resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.7.0.tgz#d15cec2cb09264b5c1f6f712dab8819bb9ab7e13" resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.7.0.tgz#d15cec2cb09264b5c1f6f712dab8819bb9ab7e13"