mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
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:
commit
6de253fd32
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@ -4,12 +4,8 @@
|
||||
* [Edit this Documentation](edit-this-documentation.md)
|
||||
* [Installation](installation.md)
|
||||
* [Backend](backend/README.md)
|
||||
* [graphql-with-apollo](backend/graphql-with-apollo/README.md)
|
||||
* [GraphQL with Apollo](backend/graphql-with-apollo/graphql-with-apollo/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)
|
||||
* [GraphQL](backend/graphql.md)
|
||||
* [Legacy Migration](backend/db-migration-worker/README.md)
|
||||
* [Webapp](webapp/README.md)
|
||||
* [COMPONENTS](webapp/components.md)
|
||||
* [PLUGINS](webapp/plugins.md)
|
||||
|
||||
@ -1,137 +1,152 @@
|
||||
# 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`:
|
||||
|
||||
```text
|
||||
node --version
|
||||
```
|
||||
|
||||
Run:
|
||||
The installation takes a bit longer on the first pass or on rebuild ...
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
$ docker-compose up
|
||||
|
||||
# create indices etc.
|
||||
docker-compose exec neo4j migrate
|
||||
|
||||
# if you want seed data
|
||||
# open another terminal and run
|
||||
docker-compose exec backend yarn run db:seed
|
||||
# rebuild the containers for a cleanup
|
||||
$ docker-compose up --build
|
||||
```
|
||||
|
||||
App is [running on port 4000](http://localhost:4000/)
|
||||
|
||||
To wipe out your neo4j database run:
|
||||
Open another terminal and create unique indices with:
|
||||
|
||||
```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 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
|
||||
|
||||
```text
|
||||
neo4j\bin\neo4j start
|
||||
Now install node dependencies with [yarn](https://yarnpkg.com/en/):
|
||||
```bash
|
||||
$ cd backend
|
||||
$ 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
|
||||
yarn install
|
||||
# -or-
|
||||
npm install
|
||||
$ ./neo4j/migrate.sh
|
||||
```
|
||||
|
||||
Copy:
|
||||
|
||||
```text
|
||||
cp .env.template .env
|
||||
```
|
||||
|
||||
Configure the file `.env` according to your needs and your local setup.
|
||||
|
||||
Start the GraphQL service:
|
||||
|
||||
Start the backend for development with:
|
||||
```bash
|
||||
yarn dev
|
||||
# -or-
|
||||
npm dev
|
||||
$ yarn run dev
|
||||
```
|
||||
|
||||
And on the production machine run following:
|
||||
|
||||
or start the backend in production environment with:
|
||||
```bash
|
||||
yarn start
|
||||
# -or-
|
||||
npm start
|
||||
yarn run 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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
NEO4J_URI=bolt://localhost:7687
|
||||
NEO4J_USERNAME=neo4j
|
||||
NEO4J_PASSWORD=letmein
|
||||
```
|
||||
If you want your backend to return anything else than an empty response, you
|
||||
need to seed your database:
|
||||
|
||||
> You need to install APOC as a plugin for the graph you create in the neo4j desktop app!
|
||||
|
||||
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:
|
||||
{% tabs %}
|
||||
{% tab title="Docker" %}
|
||||
|
||||
In another terminal run:
|
||||
```bash
|
||||
yarn run db:seed
|
||||
# -or-
|
||||
npm run db:seed
|
||||
$ docker-compose exec backend yarn run db:seed
|
||||
```
|
||||
|
||||
For a reset you can use the reset script:
|
||||
|
||||
To reset the database run:
|
||||
```bash
|
||||
yarn db:reset
|
||||
# -or-
|
||||
npm run db:reset
|
||||
$ docker-compose exec backend yarn run db:reset
|
||||
# you could also wipe out your neo4j database and delete all volumes with:
|
||||
$ 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
|
||||
|
||||
**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:
|
||||
|
||||
```bash
|
||||
yarn run test
|
||||
# -or-
|
||||
npm run test
|
||||
$ docker-compose exec backend yarn run test:jest
|
||||
```
|
||||
|
||||
Run the _**cucumber**_ features:
|
||||
|
||||
```bash
|
||||
yarn run test:cucumber
|
||||
# -or-
|
||||
npm run test:cucumber
|
||||
$ docker-compose exec backend yarn 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 %}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
# Import
|
||||
# Legacy Migration
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
@ -1,2 +0,0 @@
|
||||
# graphql-with-apollo
|
||||
|
||||
@ -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.
|
||||
|
||||

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

|
||||
|
||||
## Middleware keeps resolvers clean
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -84,7 +84,7 @@
|
||||
"cucumber": "~5.1.0",
|
||||
"eslint": "~5.16.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-node": "~8.0.1",
|
||||
"eslint-plugin-promise": "~4.1.1",
|
||||
|
||||
@ -11,7 +11,7 @@ import userMiddleware from './userMiddleware'
|
||||
import includedFieldsMiddleware from './includedFieldsMiddleware'
|
||||
import orderByMiddleware from './orderByMiddleware'
|
||||
import validUrlMiddleware from './validUrlMiddleware'
|
||||
import notificationsMiddleware from './notificationsMiddleware'
|
||||
import notificationsMiddleware from './notifications'
|
||||
|
||||
export default schema => {
|
||||
let middleware = [
|
||||
@ -20,9 +20,9 @@ export default schema => {
|
||||
validUrlMiddleware,
|
||||
sluggifyMiddleware,
|
||||
excerptMiddleware,
|
||||
notificationsMiddleware,
|
||||
xssMiddleware,
|
||||
fixImageUrlsMiddleware,
|
||||
notificationsMiddleware,
|
||||
softDeleteMiddleware,
|
||||
userMiddleware,
|
||||
includedFieldsMiddleware,
|
||||
|
||||
17
backend/src/middleware/notifications/extractMentions.js
Normal file
17
backend/src/middleware/notifications/extractMentions.js
Normal 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
|
||||
}
|
||||
46
backend/src/middleware/notifications/extractMentions.spec.js
Normal file
46
backend/src/middleware/notifications/extractMentions.spec.js
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,20 +1,22 @@
|
||||
import { extractSlugs } from './notifications/mentions'
|
||||
import extractIds from './extractMentions'
|
||||
|
||||
const notify = async (resolve, root, args, context, resolveInfo) => {
|
||||
// extract user ids before xss-middleware removes link classes
|
||||
const ids = extractIds(args.content)
|
||||
|
||||
const post = await resolve(root, args, context, resolveInfo)
|
||||
|
||||
const session = context.driver.session()
|
||||
const { content, id: postId } = post
|
||||
const slugs = extractSlugs(content)
|
||||
const { id: postId } = post
|
||||
const createdAt = (new Date()).toISOString()
|
||||
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
|
||||
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 })
|
||||
await session.run(cypher, { ids, createdAt, postId })
|
||||
session.close()
|
||||
|
||||
return post
|
||||
@ -22,6 +24,7 @@ const notify = async (resolve, root, args, context, resolveInfo) => {
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreatePost: notify
|
||||
CreatePost: notify,
|
||||
UpdatePost: notify
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
124
backend/src/middleware/notifications/spec.js
Normal file
124
backend/src/middleware/notifications/spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -3,21 +3,26 @@ import uuid from 'uuid/v4'
|
||||
export default function (params) {
|
||||
const {
|
||||
id = uuid(),
|
||||
key,
|
||||
key = '',
|
||||
type = 'crowdfunding',
|
||||
status = 'permanent',
|
||||
icon
|
||||
icon = '/img/badges/indiegogo_en_panda.svg'
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateBadge(
|
||||
id: "${id}",
|
||||
key: "${key}",
|
||||
type: ${type},
|
||||
status: ${status},
|
||||
icon: "${icon}"
|
||||
) { id }
|
||||
return {
|
||||
mutation: `
|
||||
mutation(
|
||||
$id: ID
|
||||
$key: String!
|
||||
$type: BadgeTypeEnum!
|
||||
$status: BadgeStatusEnum!
|
||||
$icon: String!
|
||||
) {
|
||||
CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, key, type, status, icon }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -8,14 +8,15 @@ export default function (params) {
|
||||
icon
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateCategory(
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
slug: "${slug}",
|
||||
icon: "${icon}"
|
||||
) { id, name }
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID, $name: String!, $slug: String, $icon: String!) {
|
||||
CreateCategory(id: $id, name: $name, slug: $slug, icon: $icon) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, name, slug, icon }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -7,19 +7,17 @@ export default function (params) {
|
||||
content = [
|
||||
faker.lorem.sentence(),
|
||||
faker.lorem.sentence()
|
||||
].join('. '),
|
||||
disabled = false,
|
||||
deleted = false
|
||||
].join('. ')
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateComment(
|
||||
id: "${id}",
|
||||
content: "${content}",
|
||||
disabled: ${disabled},
|
||||
deleted: ${deleted}
|
||||
) { id }
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID!, $content: String!) {
|
||||
CreateComment(id: $id, content: $content) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, content }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -71,8 +71,8 @@ export default function Factory (options = {}) {
|
||||
return this
|
||||
},
|
||||
async create (node, properties) {
|
||||
const mutation = this.factories[node](properties)
|
||||
this.lastResponse = await this.graphQLClient.request(mutation)
|
||||
const { mutation, variables } = this.factories[node](properties)
|
||||
this.lastResponse = await this.graphQLClient.request(mutation, variables)
|
||||
return this
|
||||
},
|
||||
async relate (node, relationship, properties) {
|
||||
|
||||
@ -6,12 +6,15 @@ export default function (params) {
|
||||
read = false
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateNotification(
|
||||
id: "${id}",
|
||||
read: ${read},
|
||||
) { id, read }
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID, $read: Boolean) {
|
||||
CreateNotification(id: $id, read: $read) {
|
||||
id
|
||||
read
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, read }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -5,20 +5,17 @@ export default function create (params) {
|
||||
const {
|
||||
id = uuid(),
|
||||
name = faker.company.companyName(),
|
||||
description = faker.company.catchPhrase(),
|
||||
disabled = false,
|
||||
deleted = false
|
||||
description = faker.company.catchPhrase()
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateOrganization(
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
description: "${description}",
|
||||
disabled: ${disabled},
|
||||
deleted: ${deleted}
|
||||
) { name }
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID!, $name: String!, $description: String!) {
|
||||
CreateOrganization(id: $id, name: $name, description: $description) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, name, description }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -18,17 +18,31 @@ export default function (params) {
|
||||
deleted = false
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
return {
|
||||
mutation: `
|
||||
mutation(
|
||||
$id: ID!
|
||||
$slug: String
|
||||
$title: String!
|
||||
$content: String!
|
||||
$image: String
|
||||
$visibility: VisibilityEnum
|
||||
$deleted: Boolean
|
||||
) {
|
||||
CreatePost(
|
||||
id: "${id}",
|
||||
slug: "${slug}",
|
||||
title: "${title}",
|
||||
content: "${content}",
|
||||
image: "${image}",
|
||||
visibility: ${visibility},
|
||||
deleted: ${deleted}
|
||||
) { title, content }
|
||||
id: $id
|
||||
slug: $slug
|
||||
title: $title
|
||||
content: $content
|
||||
image: $image
|
||||
visibility: $visibility
|
||||
deleted: $deleted
|
||||
) {
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, slug, title, content, image, visibility, deleted }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -6,15 +6,15 @@ export default function create (params) {
|
||||
id
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
report(
|
||||
description: "${description}",
|
||||
id: "${id}",
|
||||
) {
|
||||
id,
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID!, $description: String!) {
|
||||
report(description: $description, id: $id) {
|
||||
id
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
variables: { id, description }
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,15 +3,17 @@ import uuid from 'uuid/v4'
|
||||
export default function (params) {
|
||||
const {
|
||||
id = uuid(),
|
||||
name
|
||||
name = '#human-connection'
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateTag(
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
) { name }
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID!, $name: String!) {
|
||||
CreateTag(id: $id, name: $name) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, name }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -10,24 +10,30 @@ export default function create (params) {
|
||||
password = '1234',
|
||||
role = 'user',
|
||||
avatar = faker.internet.avatar(),
|
||||
about = faker.lorem.paragraph(),
|
||||
disabled = false,
|
||||
deleted = false
|
||||
about = faker.lorem.paragraph()
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
return {
|
||||
mutation: `
|
||||
mutation(
|
||||
$id: ID!
|
||||
$name: String
|
||||
$slug: String
|
||||
$password: String!
|
||||
$email: String
|
||||
$avatar: String
|
||||
$about: String
|
||||
$role: UserGroupEnum
|
||||
) {
|
||||
CreateUser(
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
slug: "${slug}",
|
||||
password: "${password}",
|
||||
email: "${email}",
|
||||
avatar: "${avatar}",
|
||||
about: "${about}",
|
||||
role: ${role},
|
||||
disabled: ${disabled},
|
||||
deleted: ${deleted}
|
||||
id: $id
|
||||
name: $name
|
||||
slug: $slug
|
||||
password: $password
|
||||
email: $email
|
||||
avatar: $avatar
|
||||
about: $about
|
||||
role: $role
|
||||
) {
|
||||
id
|
||||
name
|
||||
@ -39,5 +45,7 @@ export default function create (params) {
|
||||
disabled
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
variables: { id, name, slug, password, email, avatar, about, role }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import faker from 'faker'
|
||||
import Factory from './factories'
|
||||
|
||||
/* eslint-disable no-multi-spaces */
|
||||
@ -88,20 +89,23 @@ import Factory from './factories'
|
||||
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([
|
||||
asAdmin.create('Post', { id: 'p0' }),
|
||||
asModerator.create('Post', { id: 'p1' }),
|
||||
asUser.create('Post', { id: 'p2', deleted: true }),
|
||||
asUser.create('Post', { id: 'p2' }),
|
||||
asTick.create('Post', { id: 'p3' }),
|
||||
asTrick.create('Post', { id: 'p4' }),
|
||||
asTrack.create('Post', { id: 'p5' }),
|
||||
asAdmin.create('Post', { id: 'p6' }),
|
||||
asModerator.create('Post', { id: 'p7' }),
|
||||
asModerator.create('Post', { id: 'p7', content: `${mention1} ${faker.lorem.paragraph()}` }),
|
||||
asUser.create('Post', { id: 'p8' }),
|
||||
asTick.create('Post', { id: 'p9' }),
|
||||
asTrick.create('Post', { id: 'p10' }),
|
||||
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' }),
|
||||
asUser.create('Post', { id: 'p14' }),
|
||||
asTick.create('Post', { id: 'p15' })
|
||||
|
||||
@ -3013,10 +3013,10 @@ eslint-plugin-es@^1.3.1:
|
||||
eslint-utils "^1.3.0"
|
||||
regexpp "^2.0.1"
|
||||
|
||||
eslint-plugin-import@~2.17.1:
|
||||
version "2.17.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.17.1.tgz#b888feb4d9b3ee155113c8dccdd4bec5db33bdf4"
|
||||
integrity sha512-lzD9uvRvW4MsHzIOMJEDSb5MOV9LzgxRPBaovvOhJqzgxRHYfGy9QOrMuwHIh5ehKFJ7Z3DcrcGKDQ0IbP0EdQ==
|
||||
eslint-plugin-import@~2.17.2:
|
||||
version "2.17.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.17.2.tgz#d227d5c6dc67eca71eb590d2bb62fb38d86e9fcb"
|
||||
integrity sha512-m+cSVxM7oLsIpmwNn2WXTJoReOF9f/CtLMo7qOVmKd1KntBy0hEcuNZ3erTmWjx+DxRO0Zcrm5KwAvI9wHcV5g==
|
||||
dependencies:
|
||||
array-includes "^3.0.3"
|
||||
contains-path "^0.1.0"
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
# in the top level folder Human-Connection/
|
||||
$ yarn cypress:setup
|
||||
```
|
||||
|
||||
|
||||
@ -83,6 +83,13 @@ The following features will be implemented. This gets done in three steps:
|
||||
* Editing Comments
|
||||
* Upvote comments of others
|
||||
|
||||
### Notifications
|
||||
[Cucumber features](./integration/notifications)
|
||||
|
||||
* User @-mentionings
|
||||
* Notify authors for comments
|
||||
* Administrative notifications to all users
|
||||
|
||||
### Contribution List
|
||||
|
||||
* Show Posts by Tiles
|
||||
|
||||
@ -293,3 +293,43 @@ Then('I can login successfully with password {string}', password => {
|
||||
})
|
||||
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')
|
||||
})
|
||||
|
||||
31
cypress/integration/notifications/Mentions.feature
Normal file
31
cypress/integration/notifications/Mentions.feature
Normal 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
|
||||
@ -46,7 +46,8 @@ Cypress.Commands.add('login', ({ email, password }) => {
|
||||
cy.get('button[name=submit]')
|
||||
.as('submitButton')
|
||||
.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) => {
|
||||
|
||||
122
installation.md
122
installation.md
@ -1,41 +1,25 @@
|
||||
# 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)
|
||||
|
||||
{% hint style="info" %}
|
||||
TODO: Create documentation section for How to Start and Beginners.
|
||||
{% endhint %}
|
||||
We give write permissions to every developer who asks for it. Just text us on
|
||||
[Discord](https://discord.gg/6ub73U3).
|
||||
|
||||
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 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.
|
||||
Clone the repository, this will create a new folder called `Human-Connection`:
|
||||
|
||||
{% tabs %}
|
||||
{% tab title="HTTPS" %}
|
||||
```bash
|
||||
$ git clone https://github.com/YOUR-GITHUB-USERNAME/Human-Connection.git
|
||||
$ git clone https://github.com/Human-Connection/Human-Connection.git
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="SSH" %}
|
||||
```bash
|
||||
$ git clone git@github.com:YOUR-GITHUB-USERNAME/Human-Connection.git
|
||||
$ git clone git@github.com:Human-Connection/Human-Connection.git
|
||||
```
|
||||
{% endtab %}
|
||||
{% endtabs %}
|
||||
@ -46,21 +30,21 @@ Change into the new folder.
|
||||
$ 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 %}
|
||||
{% tab title="HTTPS" %}
|
||||
```bash
|
||||
$ git remote add upstream https://github.com/Human-Connection/Human-Connection.git
|
||||
```
|
||||
{% endtab %}
|
||||
There are four important directories:
|
||||
* [Backend](./backend) runs on the server and is a middleware between database and frontend
|
||||
* [Frontend](./webapp) is a server-side-rendered and client-side-rendered web frontend
|
||||
* [Deployment](./deployment) configuration for kubernetes
|
||||
* [Cypress](./cypress) contains end-to-end tests and executable feature specifications
|
||||
|
||||
{% tab title="SSH" %}
|
||||
```bash
|
||||
$ git remote add upstream git@github.com:Human-Connection/Human-Connection.git
|
||||
```
|
||||
{% endtab %}
|
||||
{% endtabs %}
|
||||
In order to setup the application and start to develop features you have to
|
||||
setup **frontend** and **backend**.
|
||||
|
||||
There are two approaches:
|
||||
|
||||
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
|
||||
|
||||
@ -95,72 +79,4 @@ $ docker-compose --version
|
||||
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.
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
"nonGlobalStepDefinitions": true
|
||||
},
|
||||
"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:seeder": "cd backend && yarn run test:before:seeder",
|
||||
"cypress:webapp": "cd webapp && cross-env GRAPHQL_URI=http://localhost:4123 yarn run dev",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
/>
|
||||
<no-ssr>
|
||||
<hc-editor
|
||||
:users="users"
|
||||
:value="form.content"
|
||||
@input="updateEditorContent"
|
||||
/>
|
||||
@ -48,7 +49,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcEditor from '~/components/Editor/Editor.vue'
|
||||
import HcEditor from '~/components/Editor'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -70,7 +71,8 @@ export default {
|
||||
id: null,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
slug: null
|
||||
slug: null,
|
||||
users: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -125,6 +127,21 @@ export default {
|
||||
// this.form.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>
|
||||
@ -1,5 +1,29 @@
|
||||
<template>
|
||||
<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">
|
||||
<div
|
||||
ref="menu"
|
||||
@ -137,6 +161,8 @@
|
||||
<script>
|
||||
import linkify from 'linkify-it'
|
||||
import stringHash from 'string-hash'
|
||||
import Fuse from 'fuse.js'
|
||||
import tippy from 'tippy.js'
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
@ -160,6 +186,7 @@ import {
|
||||
Link,
|
||||
History
|
||||
} from 'tiptap-extensions'
|
||||
import Mention from './nodes/Mention.js'
|
||||
|
||||
let throttleInputEvent
|
||||
|
||||
@ -170,6 +197,7 @@ export default {
|
||||
EditorMenuBubble
|
||||
},
|
||||
props: {
|
||||
users: { type: Array, default: () => [] },
|
||||
value: { type: String, default: '' },
|
||||
doc: { type: Object, default: () => {} }
|
||||
},
|
||||
@ -198,7 +226,72 @@ export default {
|
||||
emptyNodeClass: 'is-empty',
|
||||
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 => {
|
||||
clearTimeout(throttleInputEvent)
|
||||
@ -206,7 +299,21 @@ export default {
|
||||
}
|
||||
}),
|
||||
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: {
|
||||
@ -226,6 +333,77 @@ export default {
|
||||
this.editor.destroy()
|
||||
},
|
||||
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) {
|
||||
const content = e.getHTML()
|
||||
const contentHash = stringHash(content)
|
||||
@ -273,6 +451,60 @@ export default {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
padding: $space-base;
|
||||
margin: -$space-base;
|
||||
@ -302,6 +534,9 @@ li > p {
|
||||
}
|
||||
|
||||
.editor {
|
||||
.mention-suggestion {
|
||||
color: $color-primary;
|
||||
}
|
||||
&__floating-menu {
|
||||
position: absolute;
|
||||
margin-top: -0.25rem;
|
||||
29
webapp/components/Editor/nodes/Mention.js
Normal file
29
webapp/components/Editor/nodes/Mention.js
Normal 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
|
||||
}
|
||||
}
|
||||
44
webapp/components/Editor/spec.js
Normal file
44
webapp/components/Editor/spec.js
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -4,11 +4,12 @@
|
||||
:image="post.image"
|
||||
:class="{'post-card': true, 'disabled-content': post.disabled}"
|
||||
>
|
||||
<a
|
||||
v-router-link
|
||||
<nuxt-link
|
||||
class="post-link"
|
||||
:href="href(post)"
|
||||
>{{ post.title }}</a>
|
||||
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
|
||||
>
|
||||
{{ post.title }}
|
||||
</nuxt-link>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<!-- TODO: replace editor content with tiptap render view -->
|
||||
<ds-space margin-bottom="large">
|
||||
@ -75,6 +76,7 @@
|
||||
import HcUser from '~/components/User'
|
||||
import ContentMenu from '~/components/ContentMenu'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'HcPostCard',
|
||||
@ -89,26 +91,16 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
user: 'auth/user'
|
||||
}),
|
||||
excerpt() {
|
||||
// remove all links from excerpt to prevent issues with the serounding link
|
||||
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
|
||||
return this.$filters.removeLinks(this.post.contentExcerpt)
|
||||
},
|
||||
isAuthor() {
|
||||
return this.$store.getters['auth/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
|
||||
const { author } = this.post
|
||||
if (!author) return false
|
||||
return this.user.id === this.post.author.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -130,6 +122,7 @@ export default {
|
||||
}
|
||||
|
||||
.post-link {
|
||||
margin: 15px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
62
webapp/components/PostCard/spec.js
Normal file
62
webapp/components/PostCard/spec.js
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
71
webapp/components/notifications/Notification/index.vue
Normal file
71
webapp/components/notifications/Notification/index.vue
Normal 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>
|
||||
64
webapp/components/notifications/Notification/spec.js
Normal file
64
webapp/components/notifications/Notification/spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
32
webapp/components/notifications/NotificationList/index.vue
Normal file
32
webapp/components/notifications/NotificationList/index.vue
Normal 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>
|
||||
130
webapp/components/notifications/NotificationList/spec.js
Normal file
130
webapp/components/notifications/NotificationList/spec.js
Normal 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
112
webapp/components/notifications/NotificationMenu/index.vue
Normal file
112
webapp/components/notifications/NotificationMenu/index.vue
Normal 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>
|
||||
94
webapp/components/notifications/NotificationMenu/spec.js
Normal file
94
webapp/components/notifications/NotificationMenu/spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -10,7 +10,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import seo from '~/components/_mixins/seo'
|
||||
import seo from '~/mixins/seo'
|
||||
|
||||
export default {
|
||||
mixins: [seo]
|
||||
|
||||
@ -31,6 +31,9 @@
|
||||
/>
|
||||
</no-ssr>
|
||||
<template v-if="isLoggedIn">
|
||||
<no-ssr>
|
||||
<notification-menu />
|
||||
</no-ssr>
|
||||
<no-ssr>
|
||||
<dropdown class="avatar-menu">
|
||||
<template
|
||||
@ -116,11 +119,12 @@
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
import LocaleSwitch from '~/components/LocaleSwitch'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import SearchInput from '~/components/SearchInput.vue'
|
||||
import Modal from '~/components/Modal'
|
||||
import seo from '~/components/_mixins/seo'
|
||||
import userName from '~/components/_mixins/userName'
|
||||
import NotificationMenu from '~/components/notifications/NotificationMenu'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import seo from '~/mixins/seo'
|
||||
import userName from '~/mixins/userName'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -128,7 +132,8 @@ export default {
|
||||
LocaleSwitch,
|
||||
SearchInput,
|
||||
Modal,
|
||||
LocaleSwitch
|
||||
LocaleSwitch,
|
||||
NotificationMenu
|
||||
},
|
||||
mixins: [seo, userName],
|
||||
data() {
|
||||
|
||||
@ -19,6 +19,11 @@
|
||||
"userAnonym": "Anonymus",
|
||||
"socialMedia": "Wo sonst finde ich"
|
||||
},
|
||||
"notifications": {
|
||||
"menu": {
|
||||
"mentioned": "hat dich in einem Beitrag erwähnt"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Suchen",
|
||||
"hint": "Wonach suchst du?",
|
||||
|
||||
@ -19,6 +19,11 @@
|
||||
"userAnonym": "Anonymous",
|
||||
"socialMedia": "Where else can I find"
|
||||
},
|
||||
"notifications": {
|
||||
"menu": {
|
||||
"mentioned": "has mentioned you in a post"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search",
|
||||
"hint": "What are you searching for?",
|
||||
|
||||
@ -196,15 +196,6 @@ module.exports = {
|
||||
** You can extend webpack config here
|
||||
*/
|
||||
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) {
|
||||
const path = require('path')
|
||||
config.resolve.alias['@@'] = path.resolve(
|
||||
|
||||
@ -74,11 +74,13 @@
|
||||
"eslint-loader": "~2.1.2",
|
||||
"eslint-plugin-prettier": "~3.0.1",
|
||||
"eslint-plugin-vue": "~5.2.2",
|
||||
"fuse.js": "^3.4.4",
|
||||
"jest": "~24.7.1",
|
||||
"node-sass": "~4.11.0",
|
||||
"nodemon": "~1.18.11",
|
||||
"prettier": "~1.14.3",
|
||||
"sass-loader": "~7.1.0",
|
||||
"tippy.js": "^4.2.1",
|
||||
"vue-jest": "~3.0.4",
|
||||
"vue-svg-loader": "~0.12.0"
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import uniqBy from 'lodash/uniqBy'
|
||||
import HcPostCard from '~/components/PostCard.vue'
|
||||
import HcPostCard from '~/components/PostCard'
|
||||
import HcLoadMore from '~/components/LoadMore.vue'
|
||||
|
||||
export default {
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcPostCard from '~/components/PostCard.vue'
|
||||
import HcPostCard from '~/components/PostCard'
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
|
||||
export default {
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcContributionForm from '~/components/ContributionForm.vue'
|
||||
import HcContributionForm from '~/components/ContributionForm'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcContributionForm from '~/components/ContributionForm.vue'
|
||||
import HcContributionForm from '~/components/ContributionForm'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -319,7 +319,7 @@
|
||||
import uniqBy from 'lodash/uniqBy'
|
||||
|
||||
import User from '~/components/User'
|
||||
import HcPostCard from '~/components/PostCard.vue'
|
||||
import HcPostCard from '~/components/PostCard'
|
||||
import HcFollowButton from '~/components/FollowButton.vue'
|
||||
import HcCountTo from '~/components/CountTo.vue'
|
||||
import HcBadges from '~/components/Badges.vue'
|
||||
|
||||
@ -6,7 +6,7 @@ import formatRelative from 'date-fns/formatRelative'
|
||||
import addSeconds from 'date-fns/addSeconds'
|
||||
import accounting from 'accounting'
|
||||
|
||||
export default ({ app }) => {
|
||||
export default ({ app = {} }) => {
|
||||
const locales = {
|
||||
en: enUS,
|
||||
de: de,
|
||||
@ -88,6 +88,17 @@ export default ({ app }) => {
|
||||
return index === 0 ? letter.toUpperCase() : letter.toLowerCase()
|
||||
})
|
||||
.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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -87,6 +87,23 @@ export const actions = {
|
||||
id
|
||||
url
|
||||
}
|
||||
notifications(read: false, orderBy: createdAt_desc) {
|
||||
id
|
||||
read
|
||||
createdAt
|
||||
post {
|
||||
author {
|
||||
id
|
||||
slug
|
||||
name
|
||||
disabled
|
||||
deleted
|
||||
}
|
||||
title
|
||||
contentExcerpt
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
})
|
||||
|
||||
89
webapp/store/notifications.js
Normal file
89
webapp/store/notifications.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -1381,6 +1381,11 @@
|
||||
"@types/express-serve-static-core" "*"
|
||||
"@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":
|
||||
version "3.0.0"
|
||||
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"
|
||||
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:
|
||||
version "2.7.4"
|
||||
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"
|
||||
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==
|
||||
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:
|
||||
version "24.7.0"
|
||||
@ -8003,7 +8022,7 @@ pn@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2"
|
||||
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"
|
||||
integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
|
||||
|
||||
stack-utils@^1.0.2:
|
||||
stack-utils@^1.0.1, stack-utils@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8"
|
||||
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"
|
||||
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:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.7.0.tgz#d15cec2cb09264b5c1f6f712dab8819bb9ab7e13"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user