Merge branch 'master' of github.com:Human-Connection/Human-Connection into 2119_Create_Post_consistent_form_input_validation

This commit is contained in:
mattwr18 2019-11-18 15:57:58 +01:00
commit 6685ff3df0
161 changed files with 6048 additions and 15161 deletions

View File

@ -27,7 +27,7 @@ script:
- echo "TRAVIS_BRANCH=$TRAVIS_BRANCH, PR=$PR, BRANCH=$BRANCH" - echo "TRAVIS_BRANCH=$TRAVIS_BRANCH, PR=$PR, BRANCH=$BRANCH"
# Backend # Backend
- docker-compose exec backend yarn run lint - docker-compose exec backend yarn run lint
- docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage - docker-compose exec backend yarn run test --ci --verbose=false --coverage
- docker-compose exec backend yarn run db:seed - docker-compose exec backend yarn run db:seed
- docker-compose exec backend yarn run db:reset - docker-compose exec backend yarn run db:reset
# ActivityPub cucumber testing temporarily disabled because it's too buggy # ActivityPub cucumber testing temporarily disabled because it's too buggy
@ -37,7 +37,6 @@ script:
# Frontend # Frontend
- docker-compose exec webapp yarn run lint - docker-compose exec webapp yarn run lint
- docker-compose exec webapp yarn run test --ci --verbose=false --coverage - docker-compose exec webapp yarn run test --ci --verbose=false --coverage
- docker-compose exec -d backend yarn run test:before:seeder
# Fullstack # Fullstack
- docker-compose down - docker-compose down
- docker-compose -f docker-compose.yml up -d - docker-compose -f docker-compose.yml up -d

View File

@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.1.10](https://github.com/Human-Connection/Human-Connection/compare/v0.1.9...v0.1.10)
> 13 November 2019
- Update contribution guidelines [`#2127`](https://github.com/Human-Connection/Human-Connection/pull/2127)
- fix: return `null` for missig translations [`#2218`](https://github.com/Human-Connection/Human-Connection/pull/2218)
- Add donation status and button [`#2194`](https://github.com/Human-Connection/Human-Connection/pull/2194)
- Added Empty Definitions For Missing Getters And Mutations [`#2197`](https://github.com/Human-Connection/Human-Connection/pull/2197)
- build(deps): bump @sentry/node from 5.7.1 to 5.8.0 in /backend [`#2209`](https://github.com/Human-Connection/Human-Connection/pull/2209)
- build(deps-dev): bump eslint-plugin-jest from 23.0.2 to 23.0.3 in /backend [`#2206`](https://github.com/Human-Connection/Human-Connection/pull/2206)
- build(deps-dev): bump vue-svg-loader from 0.14.0 to 0.15.0 in /webapp [`#2204`](https://github.com/Human-Connection/Human-Connection/pull/2204)
- build(deps-dev): bump async-validator from 3.2.1 to 3.2.2 in /webapp [`#2203`](https://github.com/Human-Connection/Human-Connection/pull/2203)
- Update deployment names in deploy script [`#2216`](https://github.com/Human-Connection/Human-Connection/pull/2216)
- Fix: Delete Block Button in MainPage PostCards [`#2193`](https://github.com/Human-Connection/Human-Connection/pull/2193)
- Update doctl to use default context [`#2199`](https://github.com/Human-Connection/Human-Connection/pull/2199)
- console.error in Hashtag.spec.js #2161 [`#2201`](https://github.com/Human-Connection/Human-Connection/pull/2201)
- build(deps-dev): bump @storybook/addon-actions from 5.2.5 to 5.2.6 in /webapp [`#2186`](https://github.com/Human-Connection/Human-Connection/pull/2186)
- build(deps-dev): bump style-resources-loader from 1.2.1 to 1.3.2 in /webapp [`#2188`](https://github.com/Human-Connection/Human-Connection/pull/2188)
- build(deps-dev): bump @storybook/vue from 5.2.5 to 5.2.6 in /webapp [`#2183`](https://github.com/Human-Connection/Human-Connection/pull/2183)
- build(deps-dev): bump @storybook/addon-a11y from 5.2.5 to 5.2.6 in /webapp [`#2176`](https://github.com/Human-Connection/Human-Connection/pull/2176)
- build(deps-dev): bump cypress from 3.6.0 to 3.6.1 [`#2173`](https://github.com/Human-Connection/Human-Connection/pull/2173)
- build(deps): bump date-fns from 2.6.0 to 2.7.0 in /webapp [`#2164`](https://github.com/Human-Connection/Human-Connection/pull/2164)
- Update docs for deploying new server, env variables [`#2191`](https://github.com/Human-Connection/Human-Connection/pull/2191)
- Remove unintended comma [`#2192`](https://github.com/Human-Connection/Human-Connection/pull/2192)
- added Russian to locales [`#2111`](https://github.com/Human-Connection/Human-Connection/pull/2111)
- Add notifications page with All Notifications [`#1975`](https://github.com/Human-Connection/Human-Connection/pull/1975)
- 1931 - after successful login the saved language of the user is set [`#2073`](https://github.com/Human-Connection/Human-Connection/pull/2073)
- build(deps-dev): bump cypress-file-upload from 3.4.0 to 3.5.0 [`#2167`](https://github.com/Human-Connection/Human-Connection/pull/2167)
- build(deps): bump date-fns from 2.6.0 to 2.7.0 in /backend [`#2166`](https://github.com/Human-Connection/Human-Connection/pull/2166)
- build(deps-dev): bump date-fns from 2.6.0 to 2.7.0 [`#2165`](https://github.com/Human-Connection/Human-Connection/pull/2165)
- build(deps-dev): bump eslint-plugin-vue from 5.2.3 to 6.0.0 in /webapp [`#2156`](https://github.com/Human-Connection/Human-Connection/pull/2156)
- build(deps): bump merge-graphql-schemas from 1.7.2 to 1.7.3 in /backend [`#2155`](https://github.com/Human-Connection/Human-Connection/pull/2155)
- build(deps-dev): bump @babel/core from 7.6.4 to 7.7.2 in /backend [`#2154`](https://github.com/Human-Connection/Human-Connection/pull/2154)
- Add missing portuguese translation [`#1909`](https://github.com/Human-Connection/Human-Connection/pull/1909)
- Migrate design tokens [`#2159`](https://github.com/Human-Connection/Human-Connection/pull/2159)
- build(deps-dev): bump @babel/core from 7.7.0 to 7.7.2 in /webapp [`#2158`](https://github.com/Human-Connection/Human-Connection/pull/2158)
- build(deps-dev): bump vue-svg-loader from 0.12.0 to 0.14.0 in /webapp [`#2157`](https://github.com/Human-Connection/Human-Connection/pull/2157)
- Remove graphql-requests [`#2151`](https://github.com/Human-Connection/Human-Connection/pull/2151)
- close all open sessions [`#2148`](https://github.com/Human-Connection/Human-Connection/pull/2148)
- Implement refresh posts, fix duplicate posts bug [`#2126`](https://github.com/Human-Connection/Human-Connection/pull/2126)
- Fix: Email is Case-Sensitive [`#2118`](https://github.com/Human-Connection/Human-Connection/pull/2118)
- build(deps-dev): bump @babel/preset-env from 7.6.3 to 7.7.1 in /backend [`#2135`](https://github.com/Human-Connection/Human-Connection/pull/2135)
- build(deps): bump graphql-shield from 7.0.1 to 7.0.2 in /backend [`#2136`](https://github.com/Human-Connection/Human-Connection/pull/2136)
- build(deps-dev): bump @babel/node from 7.6.3 to 7.7.0 in /backend [`#2134`](https://github.com/Human-Connection/Human-Connection/pull/2134)
- build(deps-dev): bump @babel/core from 7.6.4 to 7.7.0 in /webapp [`#2132`](https://github.com/Human-Connection/Human-Connection/pull/2132)
- build(deps-dev): bump @babel/preset-env from 7.6.3 to 7.7.1 in /webapp [`#2133`](https://github.com/Human-Connection/Human-Connection/pull/2133)
- build(deps-dev): bump @babel/register from 7.6.2 to 7.7.0 in /backend [`#2131`](https://github.com/Human-Connection/Human-Connection/pull/2131)
- build(deps): bump graphql-middleware from 4.0.1 to 4.0.2 in /backend [`#2130`](https://github.com/Human-Connection/Human-Connection/pull/2130)
- build(deps-dev): bump @babel/cli from 7.6.4 to 7.7.0 in /backend [`#2129`](https://github.com/Human-Connection/Human-Connection/pull/2129)
- 1851 tags clickable [`#2091`](https://github.com/Human-Connection/Human-Connection/pull/2091)
- build(deps): bump graphql-shield from 7.0.0 to 7.0.1 in /backend [`#2123`](https://github.com/Human-Connection/Human-Connection/pull/2123)
- build(deps): bump merge-graphql-schemas from 1.7.0 to 1.7.2 in /backend [`#2121`](https://github.com/Human-Connection/Human-Connection/pull/2121)
- build(deps-dev): bump vue-loader from 15.7.1 to 15.7.2 in /webapp [`#2122`](https://github.com/Human-Connection/Human-Connection/pull/2122)
- build(deps-dev): bump async-validator from 3.2.0 to 3.2.1 in /webapp [`#2120`](https://github.com/Human-Connection/Human-Connection/pull/2120)
- 🍰 Add migration plan and frontend code guidelines to our docs [`#2075`](https://github.com/Human-Connection/Human-Connection/pull/2075)
- Update feature template [`#2116`](https://github.com/Human-Connection/Human-Connection/pull/2116)
- Update to version 0.1.9 [`#2114`](https://github.com/Human-Connection/Human-Connection/pull/2114)
- add current file [`26c0d4d`](https://github.com/Human-Connection/Human-Connection/commit/26c0d4d83e4418a2378e05b66b6b47461f82735f)
- Finish portuguese translations [`15c671c`](https://github.com/Human-Connection/Human-Connection/commit/15c671c4a8aae86317896ca30601389504bce9e1)
- add design token addon to storybook [`fc387f6`](https://github.com/Human-Connection/Human-Connection/commit/fc387f63e2cd4aef0964c81a13b892bdba952e12)
#### [v0.1.9](https://github.com/Human-Connection/Human-Connection/compare/v0.1.8...v0.1.9) #### [v0.1.9](https://github.com/Human-Connection/Human-Connection/compare/v0.1.8...v0.1.9)
> 4 November 2019 > 4 November 2019
@ -67,7 +128,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- fix #1993 [`#1993`](https://github.com/Human-Connection/Human-Connection/issues/1993) - fix #1993 [`#1993`](https://github.com/Human-Connection/Human-Connection/issues/1993)
- first implementation [`aeae72f`](https://github.com/Human-Connection/Human-Connection/commit/aeae72f6918861aa2a4c64d0b32c847d9e857e93) - first implementation [`aeae72f`](https://github.com/Human-Connection/Human-Connection/commit/aeae72f6918861aa2a4c64d0b32c847d9e857e93)
- build(deps-dev): bump eslint-plugin-jest in /backend [`6c1bd53`](https://github.com/Human-Connection/Human-Connection/commit/6c1bd535ac482eb0a05d21e227a476800717a19e) - build(deps-dev): bump eslint-plugin-jest in /backend [`6c1bd53`](https://github.com/Human-Connection/Human-Connection/commit/6c1bd535ac482eb0a05d21e227a476800717a19e)
- Add auto changelog [`6f4517b`](https://github.com/Human-Connection/Human-Connection/commit/6f4517b0e9d832abab271471cedeea0aa00f4d43) - add migration plan to webapp readme [`8816f7b`](https://github.com/Human-Connection/Human-Connection/commit/8816f7be2a9662bc1333e37b306dee6b964fc2e0)
#### [v0.1.8](https://github.com/Human-Connection/Human-Connection/compare/0.1.7...v0.1.8) #### [v0.1.8](https://github.com/Human-Connection/Human-Connection/compare/0.1.7...v0.1.8)
@ -196,8 +257,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Implement public registration [`#1814`](https://github.com/Human-Connection/Human-Connection/pull/1814) - Implement public registration [`#1814`](https://github.com/Human-Connection/Human-Connection/pull/1814)
- Refactor embed settings page [`#1861`](https://github.com/Human-Connection/Human-Connection/pull/1861) - Refactor embed settings page [`#1861`](https://github.com/Human-Connection/Human-Connection/pull/1861)
- fixed lint errors [`f73ff99`](https://github.com/Human-Connection/Human-Connection/commit/f73ff995e18240192904693416a866fc7a8ddb7a) - fixed lint errors [`f73ff99`](https://github.com/Human-Connection/Human-Connection/commit/f73ff995e18240192904693416a866fc7a8ddb7a)
- Start adding missing portuguese translation [`33eb000`](https://github.com/Human-Connection/Human-Connection/commit/33eb000ee33e5aa513083450f0a00abd7240efb0)
- refactor: restructure translations and components [`bb5d581`](https://github.com/Human-Connection/Human-Connection/commit/bb5d581906b5e6e723966c3dc687c7f309356841) - refactor: restructure translations and components [`bb5d581`](https://github.com/Human-Connection/Human-Connection/commit/bb5d581906b5e6e723966c3dc687c7f309356841)
- Refactored backend database to a single `REPORTED` relation [`82228c6`](https://github.com/Human-Connection/Human-Connection/commit/82228c6c99c4b33ab20ddfbc13cce6ac6f95792c)
#### [0.1.4](https://github.com/Human-Connection/Human-Connection/compare/0.1.3...0.1.4) #### [0.1.4](https://github.com/Human-Connection/Human-Connection/compare/0.1.3...0.1.4)

View File

@ -1,79 +1,101 @@
# CONTRIBUTING # CONTRIBUTING
Thanks so much for thinking of contributing to the Human Connection project, we really appreciate it! :-\) Thank you so much for thinking of contributing to the Human Connection project! It's awesome you're here, we really appreciate it. :-\)
## Getting Set Up ## Getting Set Up
Instructions for how to install all the necessary software can be found in our [documentation](https://docs.human-connection.org/human-connection/). Instructions for how to install all the necessary software and some code guidelines can be found in our [documentation](https://docs.human-connection.org/human-connection/).
We recommend that new folks should ideally work together with an existing developer. Please join our [discord](https://discord.gg/6ub73U3) instance to chat with developers or just ask them in tickets in [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f/boards?repos=152252353): To get you started we recommend that you join forces with a regular contributor. Please join [our discord instance](https://human-connection.org/discord) to chat with developers or just get in touch directly on an issue on either [Github](https://github.com/Human-Connection/Human-Connection/issues) or [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f/boards?repos=152252353):
![](https://dl.dropbox.com/s/vbmcihkduy9dhko/Screenshot%202019-01-03%2015.50.11.png?dl=0) ![](https://dl.dropbox.com/s/vbmcihkduy9dhko/Screenshot%202019-01-03%2015.50.11.png?dl=0)
Here are some general notes on our development flow: We also have regular pair programming sessions that you are very welcome to join! We feel this is often the best way to get to know both the project and the team. Most developers are also available for spontaneous sessions if the times listed below don't work for you just ping us on discord.
## Development ## Development Flow
* Currently operating in two week sprints We operate in two week sprints that are planned, estimated and prioritised on [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f). All issues are also linked to and synced with [Github](https://github.com/Human-Connection/Human-Connection/issues). Look for the `good first issue` label if you're not sure where to start!
* We are using ZenHub to coordinate
* estimating time per issue is the crucial feature of [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f) that Github does not have We try to discuss all questions directly related to a feature or bug in the respective issue, in order to preserve it for the future and for other developers. We use discord for real-time communication.
* "up-for-grabs" links to [Github project](https://github.com/Human-Connection/Human-Connection/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
* ordering on ZenHub not necessarily reflected on github projects This is how we solve bugs and implement features, step by step:
* AgileVentures run open pairing sessions at 10:30am UTC each week on Tuesdays and Thursdays 1. We find an issue we want to work on, usually during the sprint planning but as an open source contributor this can happen at any time.
* Core team 2. We communicate with the team to see if the issue is still available. (When you comment on an issue but don't get an answer there within 1-2 days try to mention @Human-Connection/hc-dev-team to make sure we check in.)
* all the people who are hired by HC non-profit corporation 3. We make sure we understand the issue in detail what problem is it solving and how should it be implemented?
* you can Meet-the-team [every two weeks in German](https://human-connection.org/veranstaltungen/) and [every month in English](https://human-connection.org/en/events/). 4. We assign ourselves to the issue and move it to `In Progress` on [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f).
* 9 people 5. We start working on it in a `new branch` and open a `pull request` prefixed with `[WIP]` (work in progress) to which we regularly push our changes.
* 2 core developers \(Robert [@roschaefer](https://github.com/roschaefer) and Greg [@appinteractive](https://github.com/appinteractive)\) 6. When questions come up we clarify them with the team (directly in the issue on Github).
* 3 marketeers Jasi, Dennis and Sensi 7. When we are happy with our work and our PR is passing all tests we remove the `[WIP]` from the PR description and ask for reviews (if you're not sure who to ask there is @Human-Connection/hc-dev-team which pings all core developers).
* Hardy doing business development 8. We then incorporate the suggestions from the reviews into our work and once it has been approved it can be merged into master!
* Martin head of IT and previously data protection officer
* Victor doing accounting and controlling Every pull request needs to:
* Nicolas is the community manager \(reviews content in the network\) reflects community opinion back to the core team * fix an issue (if there is something you want to work on but there is no issue for it, create one first and discuss it with the team)
* when can folks pair with Robert * include tests for the code that is added or changed
* 10am UTC until 5pm UTC every working day * pass all tests (linter, backend, frontend, end-to-end)
* be approved by at least 1 developer who is not the owner of the PR (when more than 10 files were changed it needs 2 approvals)
## The Team
There are many volunteers all around the world helping us build this network and without their contributions we wouldn't be where we are today. Big thank you to all of you!
You can see the core team behind Human Connection [on our website](https://human-connection.org/en/the-team/). On Github you will mostly run into our developers:
* Robert (@roschaefer)
* Matt (@mattwr18)
* Wolle (@Tirokk)
* Alex (@ogerly)
* Alina (@alina-beck)
* Martin (@datenbrei), our head of IT
* and sometimes Dennis (@DennisHack), the founder of Human Connection
## Meetings and Pair Programming Sessions
Times below refer to **German Time** that's CET (GMT+1) in winter and CEST (GMT+2) in summer because most Human Connection core team members are living in Germany.
Daily standup
* every MondayFriday 11:30
* in the discord `Conference Room`
* all contributors welcome!
* everybody shares what they are working on and asks for help if they are blocked
Regular pair programming sessions
* every Monday, Wednesday and Thursday 15:00
* the link will be posted in the [discord chat](https://discord.gg/6ub73U3) and on the [Agile Ventures website](https://www.agileventures.org/events?utf8=%E2%9C%93&project_id=220&commit=Filter+by+Project)
* all contributors welcome!
* we team up and work on an issue together (often using Visual Studio live sharing sessions)
Open-Source Community Meeting
* every Thursday 13:00
* the link will be posted in the [discord chat](https://discord.gg/6ub73U3) and on the [Agile Ventures website](https://www.agileventures.org/events?utf8=%E2%9C%93&project_id=220&commit=Filter+by+Project)
* all contributors welcome!
Meet the team
* every Monday 21:00 (at the moment only in German)
* details here https://human-connection.org/veranstaltungen/
* via this [zoom link](https://zoom.us/j/936943532)
* all contributors and users of the network welcome!
* users of the network chat with the Human Connection team and discuss current questions and issues
Sprint planning
* bi-weekly on Tuesday 13:00
* via this [zoom link](https://zoom.us/j/7743582385)
* all contributors welcome (recommended for those who want to work on an issue in this sprint)
* we select and prioritise the issues we will work on in the following two weeks
Sprint retrospective
* bi-weekly on Monday 13:00
* via this [zoom link](https://zoom.us/j/7743582385)
* all contributors welcome (most interesting for those who participated in the sprint)
* we review the past sprint and talk about what went well and what we could improve
## Philosophy ## Philosophy
We practise [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that: We practise [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that:
* developers can make contributions to other people's PRs (after checking in with them)
* anyone can start working on anyone elses code * we avoid blocking because someone else isn't working, so we sometimes take over PRs from other developers
* we avoid blocking because someone else isn't working on something
* however it's sometimes good to leave something in order to create successful education experience
* everyone should always push their code to branches so others can see it * everyone should always push their code to branches so others can see it
Everyone feel free to request merges or answers to issues from the project managers We believe in open source contributions as a learning experience everyone is welcome to join our team of volunteers and to contribute to the project, no matter their background or level of experience.
But what do we do when waiting for merge into master \(wanting to keep PRs small\) --> Robert recommends creating a pull request for each step We use pair programming sessions as a tool for knowledge sharing. We can learn a lot from each other and only by sharing what we know and overcoming challenges together can we grow as a team and truly own this project collectively.
* programming is also about thinking about other people - empathy for your co-workers As a volunteeer you have no commitment except your own self development and your awesomeness by contributing to this free and open-source software project. Cheers to you!
* but what about when you are waiting for merge?
* solutions
* 1\) put 2nd PR into branch that the first PR is hitting - but requires update after merging
* 2\) prefer to leave existing PR until it can be reviewed, and instead go and work on some other part of the codebase that is not impacted by the first PR
### Code Review
* Github setting in place - at least one review is required to merge
- in principle anyone (who is not the PR owner) can review
- but often it will be the core developers (Robert, Wolfgang, Matt, Alina, Alex)
- once there is a review, and presuming no requested changes, PR opener can merge
* CI/tests
- the CI needs to pass
- linting (yarn lint --fix)
- tests (unit, feature) (backend, frontend)
- codecoverage
## Notes
question: when you want to pick a task - \(find out priority\) - is it in discord? is it in AV slack? --> Robert says you can always ask in discord - group channels are the best
Robert shares: [Zenhub board](https://app.zenhub.com/workspaces/nitro-embed-5c0154ecc699f60fc92cf11f/boards?repos=112590397,152252353,152252578,157710732,163305928) Robert says the order of tickets are preserved in ZenHub and reflect their priority \(most important at the top\) and so check out the current milestones
Matt - question about who can work on [ticket 100](https://app.zenhub.com/workspaces/nitro-embed-5c0154ecc699f60fc92cf11f/issues/human-connection/human-connection/100) --> Robert - in rare occasions it might be exclusive to someone with admin permissions Robert: notes greg just pushed this today: [https://github.com/Human-Connection/Nitro-Deployment](https://github.com/Human-Connection/Nitro-Deployment)
Matt makes point that new stories will have to be taken off the "New Issues" and Robert says that's fine, if you don't like the first one, then you can take the next one. Volunteeers have no commitment except their own self development and their awesomeness by contributing to free and open-source software projects.
Robert notes that everyone is invited to join the kickoff meetings
Robert - difference between "important" \(creates a lot of value\) and "beginner friendly" \(easy to implement\)

View File

@ -6,6 +6,7 @@
* [Neo4J](neo4j/README.md) * [Neo4J](neo4j/README.md)
* [Backend](backend/README.md) * [Backend](backend/README.md)
* [GraphQL](backend/graphql.md) * [GraphQL](backend/graphql.md)
* [neo4j-graphql-js](backend/neo4j-graphql-js.md)
* [Webapp](webapp/README.md) * [Webapp](webapp/README.md)
* [Components](webapp/components.md) * [Components](webapp/components.md)
* [HTML](webapp/html.md) * [HTML](webapp/html.md)

View File

@ -1 +1 @@
0.1.9 0.1.10

View File

@ -1,7 +1,6 @@
NEO4J_URI=bolt://localhost:7687 NEO4J_URI=bolt://localhost:7687
NEO4J_USERNAME=neo4j NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=letmein NEO4J_PASSWORD=letmein
GRAPHQL_PORT=4000
GRAPHQL_URI=http://localhost:4000 GRAPHQL_URI=http://localhost:4000
CLIENT_URI=http://localhost:3000 CLIENT_URI=http://localhost:3000
SMTP_HOST= SMTP_HOST=

View File

@ -0,0 +1,16 @@
# neo4j-graphql.js
We use an npm package called `neo4j-graphql-js` as a cypher query builder. This
library also generates resolvers for graphql queries, unless we implement them
ourselves.
## Debugging
As you can see in their [documentation](https://github.com/neo4j-graphql/neo4j-graphql-js)
it is possible to log out the generated cypher statements. To do so, run the
backend like this:
```sh
DEBUG=neo4j-graphql-js yarn run dev
```

13220
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,16 +9,9 @@
"dev": "nodemon --exec babel-node src/ -e js,gql", "dev": "nodemon --exec babel-node src/ -e js,gql",
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql",
"lint": "eslint src --config .eslintrc.js", "lint": "eslint src --config .eslintrc.js",
"jest": "jest --forceExit --detectOpenHandles --runInBand", "test": "jest --forceExit --detectOpenHandles --runInBand",
"test": "run-s test:jest test:cucumber",
"test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null",
"test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null",
"test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand",
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/", "test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
"test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand",
"test:jest": "run-p --race test:before:* \"test:jest:cmd {@}\" --",
"test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --", "test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --",
"test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --",
"db:reset": "babel-node src/seed/reset-db.js", "db:reset": "babel-node src/seed/reset-db.js",
"db:seed": "babel-node src/seed/seed-db.js" "db:seed": "babel-node src/seed/seed-db.js"
}, },
@ -42,7 +35,7 @@
}, },
"dependencies": { "dependencies": {
"@hapi/joi": "^16.1.7", "@hapi/joi": "^16.1.7",
"@sentry/node": "^5.7.1", "@sentry/node": "^5.8.0",
"apollo-cache-inmemory": "~1.6.3", "apollo-cache-inmemory": "~1.6.3",
"apollo-client": "~2.6.4", "apollo-client": "~2.6.4",
"apollo-link-context": "~1.0.19", "apollo-link-context": "~1.0.19",
@ -54,7 +47,7 @@
"cheerio": "~1.0.0-rc.3", "cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5", "cors": "~2.8.5",
"cross-env": "~6.0.3", "cross-env": "~6.0.3",
"date-fns": "2.6.0", "date-fns": "2.7.0",
"debug": "~4.1.1", "debug": "~4.1.1",
"dotenv": "~8.2.0", "dotenv": "~8.2.0",
"express": "^4.17.1", "express": "^4.17.1",
@ -62,15 +55,15 @@
"graphql": "^14.5.8", "graphql": "^14.5.8",
"graphql-custom-directives": "~0.2.14", "graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1", "graphql-iso-date": "~3.6.1",
"graphql-middleware": "~4.0.1", "graphql-middleware": "~4.0.2",
"graphql-middleware-sentry": "^3.2.1", "graphql-middleware-sentry": "^3.2.1",
"graphql-shield": "~7.0.0", "graphql-shield": "~7.0.2",
"graphql-tag": "~2.10.1", "graphql-tag": "~2.10.1",
"helmet": "~3.21.2", "helmet": "~3.21.2",
"jsonwebtoken": "~8.5.1", "jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8", "linkifyjs": "~2.1.8",
"lodash": "~4.17.14", "lodash": "~4.17.14",
"merge-graphql-schemas": "^1.7.2", "merge-graphql-schemas": "^1.7.3",
"metascraper": "^4.10.3", "metascraper": "^4.10.3",
"metascraper-audio": "^5.7.17", "metascraper-audio": "^5.7.17",
"metascraper-author": "^5.7.17", "metascraper-author": "^5.7.17",
@ -90,7 +83,7 @@
"minimatch": "^3.0.4", "minimatch": "^3.0.4",
"mustache": "^3.1.0", "mustache": "^3.1.0",
"neo4j-driver": "~1.7.6", "neo4j-driver": "~1.7.6",
"neo4j-graphql-js": "^2.8.0", "neo4j-graphql-js": "^2.9.0",
"neode": "^0.3.3", "neode": "^0.3.3",
"node-fetch": "~2.6.0", "node-fetch": "~2.6.0",
"nodemailer": "^6.3.1", "nodemailer": "^6.3.1",
@ -101,32 +94,32 @@
"slug": "~1.1.0", "slug": "~1.1.0",
"trunc-html": "~1.1.2", "trunc-html": "~1.1.2",
"uuid": "~3.3.3", "uuid": "~3.3.3",
"validator": "^12.0.0",
"wait-on": "~3.3.0", "wait-on": "~3.3.0",
"xregexp": "^4.2.4" "xregexp": "^4.2.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "~7.6.4", "@babel/cli": "~7.7.0",
"@babel/core": "~7.6.4", "@babel/core": "~7.7.2",
"@babel/node": "~7.6.3", "@babel/node": "~7.7.0",
"@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.6.3", "@babel/preset-env": "~7.7.1",
"@babel/register": "~7.6.2", "@babel/register": "~7.7.0",
"apollo-server-testing": "~2.9.7", "apollo-server-testing": "~2.9.7",
"babel-core": "~7.0.0-0", "babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.3", "babel-eslint": "~10.0.3",
"babel-jest": "~24.9.0", "babel-jest": "~24.9.0",
"chai": "~4.2.0", "chai": "~4.2.0",
"cucumber": "~6.0.3", "cucumber": "~6.0.5",
"eslint": "~6.6.0", "eslint": "~6.6.0",
"eslint-config-prettier": "~6.5.0", "eslint-config-prettier": "~6.5.0",
"eslint-config-standard": "~14.1.0", "eslint-config-standard": "~14.1.0",
"eslint-plugin-import": "~2.18.2", "eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~23.0.2", "eslint-plugin-jest": "~23.0.3",
"eslint-plugin-node": "~10.0.0", "eslint-plugin-node": "~10.0.0",
"eslint-plugin-prettier": "~3.1.1", "eslint-plugin-prettier": "~3.1.1",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.1", "eslint-plugin-standard": "~4.0.1",
"graphql-request": "~1.8.2",
"jest": "~24.9.0", "jest": "~24.9.0",
"nodemon": "~1.19.4", "nodemon": "~1.19.4",
"prettier": "~1.18.2", "prettier": "~1.18.2",

View File

@ -16,7 +16,6 @@ const {
NEO4J_URI = 'bolt://localhost:7687', NEO4J_URI = 'bolt://localhost:7687',
NEO4J_USERNAME = 'neo4j', NEO4J_USERNAME = 'neo4j',
NEO4J_PASSWORD = 'neo4j', NEO4J_PASSWORD = 'neo4j',
GRAPHQL_PORT = 4000,
CLIENT_URI = 'http://localhost:3000', CLIENT_URI = 'http://localhost:3000',
GRAPHQL_URI = 'http://localhost:4000', GRAPHQL_URI = 'http://localhost:4000',
} = process.env } = process.env
@ -36,7 +35,6 @@ export const smtpConfigs = {
} }
export const neo4jConfigs = { NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD } export const neo4jConfigs = { NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD }
export const serverConfigs = { export const serverConfigs = {
GRAPHQL_PORT,
CLIENT_URI, CLIENT_URI,
GRAPHQL_URI, GRAPHQL_URI,
PUBLIC_REGISTRATION: process.env.PUBLIC_REGISTRATION === 'true', PUBLIC_REGISTRATION: process.env.PUBLIC_REGISTRATION === 'true',

View File

@ -2,7 +2,8 @@ import createServer from './server'
import CONFIG from './config' import CONFIG from './config'
const { app } = createServer() const { app } = createServer()
app.listen({ port: CONFIG.GRAPHQL_PORT }, () => { const url = new URL(CONFIG.GRAPHQL_URI)
app.listen({ port: url.port }, () => {
/* eslint-disable-next-line no-console */ /* eslint-disable-next-line no-console */
console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`) console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`)
}) })

View File

@ -1,21 +1,3 @@
import { request } from 'graphql-request'
// this is the to-be-tested server host
// not to be confused with the seeder host
export const host = 'http://127.0.0.1:4123'
export async function login(variables) {
const mutation = `
mutation($email: String!, $password: String!) {
login(email: $email, password: $password)
}
`
const response = await request(host, mutation, variables)
return {
authorization: `Bearer ${response.login}`,
}
}
//* This is a fake ES2015 template string, just to benefit of syntax //* This is a fake ES2015 template string, just to benefit of syntax
// highlighting of `gql` template strings in certain editors. // highlighting of `gql` template strings in certain editors.
export function gql(strings) { export function gql(strings) {

View File

@ -19,7 +19,7 @@ const fetch = url => {
}) })
} }
const locales = ['en', 'de', 'fr', 'nl', 'it', 'es', 'pt', 'pl'] const locales = ['en', 'de', 'fr', 'nl', 'it', 'es', 'pt', 'pl', 'ru']
const createLocation = async (session, mapboxData) => { const createLocation = async (session, mapboxData) => {
const data = { const data = {
@ -32,6 +32,7 @@ const createLocation = async (session, mapboxData) => {
nameES: mapboxData.text_es, nameES: mapboxData.text_es,
namePT: mapboxData.text_pt, namePT: mapboxData.text_pt,
namePL: mapboxData.text_pl, namePL: mapboxData.text_pl,
nameRU: mapboxData.text_ru,
type: mapboxData.id.split('.')[0].toLowerCase(), type: mapboxData.id.split('.')[0].toLowerCase(),
lat: mapboxData.center && mapboxData.center.length ? mapboxData.center[0] : null, lat: mapboxData.center && mapboxData.center.length ? mapboxData.center[0] : null,
lng: mapboxData.center && mapboxData.center.length ? mapboxData.center[1] : null, lng: mapboxData.center && mapboxData.center.length ? mapboxData.center[1] : null,
@ -48,6 +49,7 @@ const createLocation = async (session, mapboxData) => {
'l.nameES = $nameES, ' + 'l.nameES = $nameES, ' +
'l.namePT = $namePT, ' + 'l.namePT = $namePT, ' +
'l.namePL = $namePL, ' + 'l.namePL = $namePL, ' +
'l.nameRU = $nameRU, ' +
'l.type = $type' 'l.type = $type'
if (data.lat && data.lng) { if (data.lat && data.lng) {
@ -56,6 +58,7 @@ const createLocation = async (session, mapboxData) => {
query += ' RETURN l.id' query += ' RETURN l.id'
await session.run(query, data) await session.run(query, data)
session.close()
} }
const createOrUpdateLocations = async (userId, locationName, driver) => { const createOrUpdateLocations = async (userId, locationName, driver) => {

View File

@ -135,6 +135,7 @@ const permissions = shield(
blockedUsers: isAuthenticated, blockedUsers: isAuthenticated,
notifications: isAuthenticated, notifications: isAuthenticated,
profilePagePosts: or(onlyEnabledContent, isModerator), profilePagePosts: or(onlyEnabledContent, isModerator),
Donations: isAuthenticated,
}, },
Mutation: { Mutation: {
'*': deny, '*': deny,
@ -177,6 +178,7 @@ const permissions = shield(
VerifyEmailAddress: isAuthenticated, VerifyEmailAddress: isAuthenticated,
pinPost: isAdmin, pinPost: isAdmin,
unpinPost: isAdmin, unpinPost: isAdmin,
UpdateDonations: isAdmin,
}, },
User: { User: {
email: or(isMyOwn, isAdmin), email: or(isMyOwn, isAdmin),

View File

@ -72,6 +72,7 @@ const validateReport = async (resolve, root, args, context, info) => {
submitterId: user.id, submitterId: user.id,
}, },
) )
session.close()
const [existingReportedResource] = reportQueryRes.records.map(record => { const [existingReportedResource] = reportQueryRes.records.map(record => {
return { return {
label: record.get('label'), label: record.get('label'),

View File

@ -0,0 +1,14 @@
import uuid from 'uuid/v4'
module.exports = {
id: { type: 'string', primary: true, default: uuid },
goal: { type: 'number' },
progress: { type: 'number' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: {
type: 'string',
isoDate: true,
required: true,
default: () => new Date().toISOString(),
},
}

View File

@ -12,6 +12,7 @@ module.exports = {
nameDE: { type: 'string' }, nameDE: { type: 'string' },
nameNL: { type: 'string' }, nameNL: { type: 'string' },
namePL: { type: 'string' }, namePL: { type: 'string' },
nameRU: { type: 'string' },
isIn: { isIn: {
type: 'relationship', type: 'relationship',
relationship: 'IS_IN', relationship: 'IS_IN',

View File

@ -12,4 +12,5 @@ export default {
Category: require('./Category.js'), Category: require('./Category.js'),
Tag: require('./Tag.js'), Tag: require('./Tag.js'),
Location: require('./Location.js'), Location: require('./Location.js'),
Donations: require('./Donations.js'),
} }

View File

@ -24,6 +24,7 @@ export default applyScalars(
'SocialMedia', 'SocialMedia',
'NOTIFIED', 'NOTIFIED',
'REPORTED', 'REPORTED',
'Donations',
], ],
// add 'User' here as soon as possible // add 'User' here as soon as possible
}, },
@ -44,6 +45,7 @@ export default applyScalars(
'EMOTED', 'EMOTED',
'NOTIFIED', 'NOTIFIED',
'REPORTED', 'REPORTED',
'Donations',
], ],
// add 'User' here as soon as possible // add 'User' here as soon as possible
}, },

View File

@ -3,7 +3,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
export default { export default {
Query: { Query: {
Badge: async (object, args, context, resolveInfo) => { Badge: async (object, args, context, resolveInfo) => {
return neo4jgraphql(object, args, context, resolveInfo, false) return neo4jgraphql(object, args, context, resolveInfo)
}, },
}, },
} }

View File

@ -59,6 +59,7 @@ export default {
`, `,
{ commentId: args.id }, { commentId: args.id },
) )
session.close()
const [comment] = transactionRes.records.map(record => record.get('comment').properties) const [comment] = transactionRes.records.map(record => record.get('comment').properties)
return comment return comment
}, },

View File

@ -0,0 +1,32 @@
export default {
Mutation: {
UpdateDonations: async (_parent, params, context, _resolveInfo) => {
const { driver } = context
const session = driver.session()
let donations
const writeTxResultPromise = session.writeTransaction(async txc => {
const updateDonationsTransactionResponse = await txc.run(
`
MATCH (donations:Donations)
WITH donations LIMIT 1
SET donations += $params
SET donations.updatedAt = toString(datetime())
RETURN donations
`,
{ params },
)
return updateDonationsTransactionResponse.records.map(
record => record.get('donations').properties,
)
})
try {
const txResult = await writeTxResultPromise
if (!txResult[0]) return null
donations = txResult[0]
} finally {
session.close()
}
return donations
},
},
}

View File

@ -0,0 +1,174 @@
import { createTestClient } from 'apollo-server-testing'
import Factory from '../../seed/factories'
import { gql } from '../../jest/helpers'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
let mutate, query, authenticatedUser, variables
const factory = Factory()
const instance = getNeode()
const driver = getDriver()
const updateDonationsMutation = gql`
mutation($goal: Int, $progress: Int) {
UpdateDonations(goal: $goal, progress: $progress) {
id
goal
progress
createdAt
updatedAt
}
}
`
const donationsQuery = gql`
query {
Donations {
id
goal
progress
}
}
`
describe('donations', () => {
let currentUser, newlyCreatedDonations
beforeAll(async () => {
await factory.cleanDatabase()
authenticatedUser = undefined
const { server } = createServer({
context: () => {
return {
driver,
neode: instance,
user: authenticatedUser,
}
},
})
mutate = createTestClient(server).mutate
query = createTestClient(server).query
})
beforeEach(async () => {
variables = {}
newlyCreatedDonations = await factory.create('Donations')
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('query for donations', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = undefined
await expect(query({ query: donationsQuery, variables })).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
})
})
describe('authenticated', () => {
beforeEach(async () => {
currentUser = await factory.create('User', {
id: 'normal-user',
role: 'user',
})
authenticatedUser = await currentUser.toJson()
})
it('returns the current Donations info', async () => {
await expect(query({ query: donationsQuery, variables })).resolves.toMatchObject({
data: { Donations: [{ goal: 15000, progress: 0 }] },
})
})
})
})
})
describe('update donations', () => {
beforeEach(() => {
variables = { goal: 20000, progress: 3000 }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = undefined
await expect(
mutate({ mutation: updateDonationsMutation, variables }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
})
})
describe('authenticated', () => {
describe('as a normal user', () => {
beforeEach(async () => {
currentUser = await factory.create('User', {
id: 'normal-user',
role: 'user',
})
authenticatedUser = await currentUser.toJson()
})
it('throws authorization error', async () => {
await expect(
mutate({ mutation: updateDonationsMutation, variables }),
).resolves.toMatchObject({
data: { UpdateDonations: null },
errors: [{ message: 'Not Authorised!' }],
})
})
})
describe('as a moderator', () => {
beforeEach(async () => {
currentUser = await factory.create('User', {
id: 'moderator',
role: 'moderator',
})
authenticatedUser = await currentUser.toJson()
})
it('throws authorization error', async () => {
await expect(
mutate({ mutation: updateDonationsMutation, variables }),
).resolves.toMatchObject({
data: { UpdateDonations: null },
errors: [{ message: 'Not Authorised!' }],
})
})
})
describe('as an admin', () => {
beforeEach(async () => {
currentUser = await factory.create('User', {
id: 'admin',
role: 'admin',
})
authenticatedUser = await currentUser.toJson()
})
it('updates Donations info', async () => {
await expect(
mutate({ mutation: updateDonationsMutation, variables }),
).resolves.toMatchObject({
data: { UpdateDonations: { goal: 20000, progress: 3000 } },
errors: undefined,
})
})
it('updates the updatedAt attribute', async () => {
newlyCreatedDonations = await newlyCreatedDonations.toJson()
const {
data: { UpdateDonations },
} = await mutate({ mutation: updateDonationsMutation, variables })
expect(newlyCreatedDonations.updatedAt).toBeTruthy()
expect(Date.parse(newlyCreatedDonations.updatedAt)).toEqual(expect.any(Number))
expect(UpdateDonations.updatedAt).toBeTruthy()
expect(Date.parse(UpdateDonations.updatedAt)).toEqual(expect.any(Number))
expect(newlyCreatedDonations.updatedAt).not.toEqual(UpdateDonations.updatedAt)
})
})
})
})
})
})

View File

@ -3,11 +3,14 @@ import Resolver from './helpers/Resolver'
import existingEmailAddress from './helpers/existingEmailAddress' import existingEmailAddress from './helpers/existingEmailAddress'
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import Validator from 'neode/build/Services/Validator.js' import Validator from 'neode/build/Services/Validator.js'
import { normalizeEmail } from 'validator'
export default { export default {
Mutation: { Mutation: {
AddEmailAddress: async (_parent, args, context, _resolveInfo) => { AddEmailAddress: async (_parent, args, context, _resolveInfo) => {
let response let response
args.email = normalizeEmail(args.email)
try { try {
const { neode } = context const { neode } = context
await new Validator(neode, neode.model('UnverifiedEmailAddress'), args) await new Validator(neode, neode.model('UnverifiedEmailAddress'), args)
@ -16,13 +19,13 @@ export default {
} }
// check email does not belong to anybody // check email does not belong to anybody
await existingEmailAddress(_parent, args, context) await existingEmailAddress({ args, context })
const nonce = generateNonce() const nonce = generateNonce()
const { const {
user: { id: userId }, user: { id: userId },
} = context } = context
const { email } = args
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async txc => { const writeTxResultPromise = session.writeTransaction(async txc => {
const result = await txc.run( const result = await txc.run(
@ -32,7 +35,7 @@ export default {
SET email.createdAt = toString(datetime()) SET email.createdAt = toString(datetime())
RETURN email, user RETURN email, user
`, `,
{ userId, email, nonce }, { userId, email: args.email, nonce },
) )
return result.records.map(record => ({ return result.records.map(record => ({
name: record.get('user').properties.name, name: record.get('user').properties.name,

View File

@ -0,0 +1,31 @@
import { normalizeEmail } from 'validator'
export default async function createPasswordReset(options) {
const { driver, nonce, email, issuedAt = new Date() } = options
const normalizedEmail = normalizeEmail(email)
const session = driver.session()
let response = {}
try {
const cypher = `
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
CREATE(pr:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (u)-[:REQUESTED]->(pr)
RETURN e, pr, u
`
const transactionRes = await session.run(cypher, {
issuedAt: issuedAt.toISOString(),
nonce,
email: normalizedEmail,
})
const records = transactionRes.records.map(record => {
const { email } = record.get('e').properties
const { nonce } = record.get('pr').properties
const { name } = record.get('u').properties
return { email, nonce, name }
})
response = records[0] || {}
} finally {
session.close()
}
return response
}

View File

@ -0,0 +1,35 @@
import createPasswordReset from './createPasswordReset'
describe('createPasswordReset', () => {
const issuedAt = new Date()
const nonce = 'abcdef'
describe('email lookup', () => {
let driver
let mockSession
beforeEach(() => {
mockSession = {
close() {},
run: jest.fn().mockReturnValue({
records: {
map: jest.fn(() => []),
},
}),
}
driver = { session: () => mockSession }
})
it('lowercases email address', async () => {
const email = 'stRaNGeCaSiNG@ExAmplE.ORG'
await createPasswordReset({ driver, email, issuedAt, nonce })
expect(mockSession.run.mock.calls).toEqual([
[
expect.any(String),
expect.objectContaining({
email: 'strangecasing@example.org',
}),
],
])
})
})
})

View File

@ -1,7 +1,6 @@
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
export default async function alreadyExistingMail(_parent, args, context) {
let { email } = args export default async function alreadyExistingMail({ args, context }) {
email = email.toLowerCase()
const cypher = ` const cypher = `
MATCH (email:EmailAddress {email: $email}) MATCH (email:EmailAddress {email: $email})
OPTIONAL MATCH (email)-[:BELONGS_TO]-(user) OPTIONAL MATCH (email)-[:BELONGS_TO]-(user)
@ -10,7 +9,7 @@ export default async function alreadyExistingMail(_parent, args, context) {
let transactionRes let transactionRes
const session = context.driver.session() const session = context.driver.session()
try { try {
transactionRes = await session.run(cypher, { email }) transactionRes = await session.run(cypher, { email: args.email })
} finally { } finally {
session.close() session.close()
} }

View File

@ -0,0 +1,19 @@
import Resolver from './helpers/Resolver'
export default {
Location: {
...Resolver('Location', {
undefinedToNull: [
'nameEN',
'nameDE',
'nameFR',
'nameNL',
'nameIT',
'nameES',
'namePT',
'namePL',
'nameRU',
],
}),
},
}

View File

@ -0,0 +1,85 @@
import Factory from '../../seed/factories'
import { gql } from '../../jest/helpers'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing'
const factory = Factory()
let mutate, authenticatedUser
const driver = getDriver()
const neode = getNeode()
beforeAll(() => {
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
mutate = createTestClient(server).mutate
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('resolvers', () => {
describe('Location', () => {
describe('custom mutation, not handled by neo4j-graphql-js', () => {
let variables
const updateUserMutation = gql`
mutation($id: ID!, $name: String) {
UpdateUser(id: $id, name: $name) {
name
location {
name: nameRU
nameEN
}
}
}
`
beforeEach(async () => {
variables = {
id: 'u47',
name: 'John Doughnut',
}
const Paris = await factory.create('Location', {
id: 'region.9397217726497330',
name: 'Paris',
type: 'region',
lat: 2.35183,
lng: 48.85658,
nameEN: 'Paris',
})
const user = await factory.create('User', {
id: 'u47',
name: 'John Doe',
})
await user.relateTo(Paris, 'isIn')
authenticatedUser = await user.toJson()
})
it('returns `null` if location translation is not available', async () => {
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: {
UpdateUser: {
name: 'John Doughnut',
location: {
name: null,
nameEN: 'Paris',
},
},
},
errors: undefined,
})
})
})
})
})

View File

@ -18,9 +18,8 @@ export default {
notifications: async (_parent, args, context, _resolveInfo) => { notifications: async (_parent, args, context, _resolveInfo) => {
const { user: currentUser } = context const { user: currentUser } = context
const session = context.driver.session() const session = context.driver.session()
let notifications let notifications, whereClause, orderByClause
let whereClause
let orderByClause
switch (args.read) { switch (args.read) {
case true: case true:
whereClause = 'WHERE notification.read = TRUE' whereClause = 'WHERE notification.read = TRUE'
@ -41,13 +40,15 @@ export default {
default: default:
orderByClause = '' orderByClause = ''
} }
const offset = args.offset && typeof args.offset === 'number' ? `SKIP ${args.offset}` : ''
const limit = args.first && typeof args.first === 'number' ? `LIMIT ${args.first}` : ''
try { try {
const cypher = ` const cypher = `
MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id}) MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id})
${whereClause} ${whereClause}
RETURN resource, notification, user RETURN resource, notification, user
${orderByClause} ${orderByClause}
${offset} ${limit}
` `
const result = await session.run(cypher, { id: currentUser.id }) const result = await session.run(cypher, { id: currentUser.id })
notifications = await result.records.map(transformReturnType) notifications = await result.records.map(transformReturnType)
@ -77,4 +78,10 @@ export default {
return notification return notification
}, },
}, },
NOTIFIED: {
id: async parent => {
// serialize an ID to help the client update the cache
return `${parent.reason}/${parent.from.id}/${parent.to.id}`
},
},
} }

View File

@ -1,34 +1,6 @@
import uuid from 'uuid/v4' import uuid from 'uuid/v4'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import createPasswordReset from './helpers/createPasswordReset'
export async function createPasswordReset(options) {
const { driver, nonce, email, issuedAt = new Date() } = options
const session = driver.session()
let response = {}
try {
const cypher = `
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
CREATE(pr:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (u)-[:REQUESTED]->(pr)
RETURN e, pr, u
`
const transactionRes = await session.run(cypher, {
issuedAt: issuedAt.toISOString(),
nonce,
email,
})
const records = transactionRes.records.map(record => {
const { email } = record.get('e').properties
const { nonce } = record.get('pr').properties
const { name } = record.get('u').properties
return { email, nonce, name }
})
response = records[0] || {}
} finally {
session.close()
}
return response
}
export default { export default {
Mutation: { Mutation: {

View File

@ -1,7 +1,7 @@
import Factory from '../../seed/factories' import Factory from '../../seed/factories'
import { gql } from '../../jest/helpers' import { gql } from '../../jest/helpers'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import { createPasswordReset } from './passwordReset' import createPasswordReset from './helpers/createPasswordReset'
import createServer from '../../server' import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
@ -109,10 +109,7 @@ describe('passwordReset', () => {
describe('resetPassword', () => { describe('resetPassword', () => {
const setup = async (options = {}) => { const setup = async (options = {}) => {
const { email = 'user@example.org', issuedAt = new Date(), nonce = 'abcdef' } = options const { email = 'user@example.org', issuedAt = new Date(), nonce = 'abcdef' } = options
const session = driver.session()
await createPasswordReset({ driver, email, issuedAt, nonce }) await createPasswordReset({ driver, email, issuedAt, nonce })
session.close()
} }
const mutation = gql` const mutation = gql`

View File

@ -43,15 +43,15 @@ export default {
Post: async (object, params, context, resolveInfo) => { Post: async (object, params, context, resolveInfo) => {
params = await filterForBlockedUsers(params, context) params = await filterForBlockedUsers(params, context)
params = await maintainPinnedPosts(params) params = await maintainPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo, false) return neo4jgraphql(object, params, context, resolveInfo)
}, },
findPosts: async (object, params, context, resolveInfo) => { findPosts: async (object, params, context, resolveInfo) => {
params = await filterForBlockedUsers(params, context) params = await filterForBlockedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo, false) return neo4jgraphql(object, params, context, resolveInfo)
}, },
profilePagePosts: async (object, params, context, resolveInfo) => { profilePagePosts: async (object, params, context, resolveInfo) => {
params = await filterForBlockedUsers(params, context) params = await filterForBlockedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo, false) return neo4jgraphql(object, params, context, resolveInfo)
}, },
PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
@ -182,6 +182,7 @@ export default {
`, `,
{ postId: args.id }, { postId: args.id },
) )
session.close()
const [post] = transactionRes.records.map(record => record.get('post').properties) const [post] = transactionRes.records.map(record => record.get('post').properties)
return post return post
}, },

View File

@ -4,6 +4,7 @@ import fileUpload from './fileUpload'
import encryptPassword from '../../helpers/encryptPassword' import encryptPassword from '../../helpers/encryptPassword'
import generateNonce from './helpers/generateNonce' import generateNonce from './helpers/generateNonce'
import existingEmailAddress from './helpers/existingEmailAddress' import existingEmailAddress from './helpers/existingEmailAddress'
import { normalizeEmail } from 'validator'
const instance = neode() const instance = neode()
@ -29,9 +30,9 @@ export default {
return response return response
}, },
Signup: async (_parent, args, context) => { Signup: async (_parent, args, context) => {
const nonce = generateNonce() args.nonce = generateNonce()
args.nonce = nonce args.email = normalizeEmail(args.email)
let emailAddress = await existingEmailAddress(_parent, args, context) let emailAddress = await existingEmailAddress({ args, context })
if (emailAddress) return emailAddress if (emailAddress) return emailAddress
try { try {
emailAddress = await instance.create('EmailAddress', args) emailAddress = await instance.create('EmailAddress', args)
@ -42,9 +43,9 @@ export default {
}, },
SignupByInvitation: async (_parent, args, context) => { SignupByInvitation: async (_parent, args, context) => {
const { token } = args const { token } = args
const nonce = generateNonce() args.nonce = generateNonce()
args.nonce = nonce args.email = normalizeEmail(args.email)
let emailAddress = await existingEmailAddress(_parent, args, context) let emailAddress = await existingEmailAddress({ args, context })
if (emailAddress) return emailAddress if (emailAddress) return emailAddress
try { try {
const result = await instance.cypher( const result = await instance.cypher(
@ -78,7 +79,7 @@ export default {
args.termsAndConditionsAgreedAt = new Date().toISOString() args.termsAndConditionsAgreedAt = new Date().toISOString()
let { nonce, email } = args let { nonce, email } = args
email = email.toLowerCase() email = normalizeEmail(email)
const result = await instance.cypher( const result = await instance.cypher(
` `
MATCH(email:EmailAddress {nonce: {nonce}, email: {email}}) MATCH(email:EmailAddress {nonce: {nonce}, email: {email}})

View File

@ -54,7 +54,7 @@ export default {
user = await user.toJson() user = await user.toJson()
return [user.node] return [user.node]
} }
return neo4jgraphql(object, args, context, resolveInfo, false) return neo4jgraphql(object, args, context, resolveInfo)
}, },
}, },
Mutation: { Mutation: {
@ -177,6 +177,7 @@ export default {
'termsAndConditionsAgreedVersion', 'termsAndConditionsAgreedVersion',
'termsAndConditionsAgreedAt', 'termsAndConditionsAgreedAt',
'allowEmbedIframes', 'allowEmbedIframes',
'locale',
], ],
boolean: { boolean: {
followedByCurrentUser: followedByCurrentUser:

View File

@ -9,6 +9,7 @@ type Location {
nameES: String nameES: String
namePT: String namePT: String
namePL: String namePL: String
nameRU: String
type: String! type: String!
lat: Float lat: Float
lng: Float lng: Float

View File

@ -0,0 +1,15 @@
type Donations {
id: ID!
goal: Int!
progress: Int!
createdAt: String
updatedAt: String
}
type Query {
Donations: [Donations]
}
type Mutation {
UpdateDonations(goal: Int, progress: Int): Donations
}

View File

@ -1,8 +1,9 @@
type NOTIFIED { type NOTIFIED {
id: ID!
from: NotificationSource from: NotificationSource
to: User to: User
createdAt: String createdAt: String!
updatedAt: String updatedAt: String!
read: Boolean read: Boolean
reason: NotificationReason reason: NotificationReason
} }
@ -23,7 +24,7 @@ enum NotificationReason {
} }
type Query { type Query {
notifications(read: Boolean, orderBy: NotificationOrdering): [NOTIFIED] notifications(read: Boolean, orderBy: NotificationOrdering, first: Int, offset: Int): [NOTIFIED]
} }
type Mutation { type Mutation {

View File

@ -0,0 +1,18 @@
import uuid from 'uuid/v4'
export default function create() {
return {
factory: async ({ args, neodeInstance }) => {
const defaults = {
id: uuid(),
goal: 15000,
progress: 0,
}
args = {
...defaults,
...args,
}
return neodeInstance.create('Donations', args)
},
}
}

View File

@ -1,4 +1,3 @@
import { GraphQLClient, request } from 'graphql-request'
import { getDriver, neode } from '../../bootstrap/neo4j' import { getDriver, neode } from '../../bootstrap/neo4j'
import createBadge from './badges.js' import createBadge from './badges.js'
import createUser from './users.js' import createUser from './users.js'
@ -9,20 +8,9 @@ import createTag from './tags.js'
import createSocialMedia from './socialMedia.js' import createSocialMedia from './socialMedia.js'
import createLocation from './locations.js' import createLocation from './locations.js'
import createEmailAddress from './emailAddresses.js' import createEmailAddress from './emailAddresses.js'
import createDonations from './donations.js'
import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js' import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js'
export const seedServerHost = 'http://127.0.0.1:4001'
const authenticatedHeaders = async ({ email, password }, host) => {
const mutation = `
mutation {
login(email:"${email}", password:"${password}")
}`
const response = await request(host, mutation)
return {
authorization: `Bearer ${response.login}`,
}
}
const factories = { const factories = {
Badge: createBadge, Badge: createBadge,
User: createUser, User: createUser,
@ -34,6 +22,7 @@ const factories = {
Location: createLocation, Location: createLocation,
EmailAddress: createEmailAddress, EmailAddress: createEmailAddress,
UnverifiedEmailAddress: createUnverifiedEmailAddresss, UnverifiedEmailAddress: createUnverifiedEmailAddresss,
Donations: createDonations,
} }
export const cleanDatabase = async (options = {}) => { export const cleanDatabase = async (options = {}) => {
@ -48,127 +37,31 @@ export const cleanDatabase = async (options = {}) => {
} }
export default function Factory(options = {}) { export default function Factory(options = {}) {
const { const { neo4jDriver = getDriver(), neodeInstance = neode() } = options
seedServerHost = 'http://127.0.0.1:4001',
neo4jDriver = getDriver(),
neodeInstance = neode(),
} = options
const graphQLClient = new GraphQLClient(seedServerHost)
const result = { const result = {
neo4jDriver, neo4jDriver,
seedServerHost,
graphQLClient,
factories, factories,
lastResponse: null, lastResponse: null,
neodeInstance, neodeInstance,
async authenticateAs({ email, password }) {
const headers = await authenticatedHeaders(
{
email,
password,
},
seedServerHost,
)
this.lastResponse = headers
this.graphQLClient = new GraphQLClient(seedServerHost, {
headers,
})
return this
},
async create(node, args = {}) { async create(node, args = {}) {
const { factory, mutation, variables } = this.factories[node](args) const { factory } = this.factories[node](args)
if (factory) { this.lastResponse = await factory({
this.lastResponse = await factory({ args,
args, neodeInstance,
neodeInstance, factoryInstance: this,
factoryInstance: this,
})
return this.lastResponse
} else {
this.lastResponse = await this.graphQLClient.request(mutation, variables)
}
return this
},
async relate(node, relationship, { from, to }) {
const mutation = `
mutation {
Add${node}${relationship}(
from: { id: "${from}" },
to: { id: "${to}" }
) { from { id } }
}
`
this.lastResponse = await this.graphQLClient.request(mutation)
return this
},
async mutate(mutation, variables) {
this.lastResponse = await this.graphQLClient.request(mutation, variables)
return this
},
async shout(properties) {
const { id, type } = properties
const mutation = `
mutation {
shout(
id: "${id}",
type: ${type}
)
}
`
this.lastResponse = await this.graphQLClient.request(mutation)
return this
},
async followUser(properties) {
const { id } = properties
const mutation = `
mutation {
followUser(
id: "${id}"
)
}
`
this.lastResponse = await this.graphQLClient.request(mutation)
return this
},
async invite({ email }) {
const mutation = ` mutation($email: String!) { invite( email: $email) } `
this.lastResponse = await this.graphQLClient.request(mutation, {
email,
}) })
return this return this.lastResponse
}, },
async cleanDatabase() { async cleanDatabase() {
this.lastResponse = await cleanDatabase({ this.lastResponse = await cleanDatabase({
driver: this.neo4jDriver, driver: this.neo4jDriver,
}) })
return this return this
}, },
async emote({ to, data }) {
const mutation = `
mutation {
AddPostEmotions(
to: { id: "${to}" },
data: { emotion: ${data} }
) {
from { id }
to { id }
emotion
}
}
`
this.lastResponse = await this.graphQLClient.request(mutation)
return this
},
} }
result.authenticateAs.bind(result)
result.create.bind(result) result.create.bind(result)
result.relate.bind(result)
result.mutate.bind(result)
result.shout.bind(result)
result.followUser.bind(result)
result.invite.bind(result)
result.cleanDatabase.bind(result) result.cleanDatabase.bind(result)
return result return result
} }

View File

@ -42,6 +42,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
nameDE: 'Hamburg', nameDE: 'Hamburg',
nameNL: 'Hamburg', nameNL: 'Hamburg',
namePL: 'Hamburg', namePL: 'Hamburg',
nameRU: 'Гамбург',
}), }),
factory.create('Location', { factory.create('Location', {
id: 'region.14880313158564380', id: 'region.14880313158564380',
@ -57,6 +58,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
nameDE: 'Berlin', nameDE: 'Berlin',
nameNL: 'Berlijn', nameNL: 'Berlijn',
namePL: 'Berlin', namePL: 'Berlin',
nameRU: 'Берлин',
}), }),
factory.create('Location', { factory.create('Location', {
id: 'country.10743216036480410', id: 'country.10743216036480410',
@ -70,6 +72,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
nameFR: 'Allemagne', nameFR: 'Allemagne',
nameIT: 'Germania', nameIT: 'Germania',
nameEN: 'Germany', nameEN: 'Germany',
nameRU: 'Германия',
}), }),
factory.create('Location', { factory.create('Location', {
id: 'region.9397217726497330', id: 'region.9397217726497330',
@ -85,6 +88,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
nameDE: 'Paris', nameDE: 'Paris',
nameNL: 'Parijs', nameNL: 'Parijs',
namePL: 'Paryż', namePL: 'Paryż',
nameRU: 'Париж',
}), }),
factory.create('Location', { factory.create('Location', {
id: 'country.9759535382641660', id: 'country.9759535382641660',
@ -98,6 +102,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
nameFR: 'France', nameFR: 'France',
nameIT: 'Francia', nameIT: 'Francia',
nameEN: 'France', nameEN: 'France',
nameRU: 'Франция',
}), }),
]) ])
await Promise.all([ await Promise.all([
@ -924,6 +929,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}), }),
) )
await factory.create('Donations')
/* eslint-disable-next-line no-console */ /* eslint-disable-next-line no-console */
console.log('Seeded Data...') console.log('Seeded Data...')
process.exit(0) process.exit(0)

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ The kubernetes dashboard is optional but very helpful for debugging. If you want
```bash ```bash
# in folder deployment/digital-ocean/ # in folder deployment/digital-ocean/
$ kubectl apply -f dashboard/ $ kubectl apply -f dashboard/
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/master/aio/deploy/recommended/kubernetes-dashboard.yaml $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.0-beta4/aio/deploy/recommended.yaml
``` ```
### Login to your dashboard ### Login to your dashboard
@ -18,7 +18,7 @@ $ kubectl proxy
Visit: Visit:
[http://localhost:8001/api/v1/namespaces/kube-system/services/https:kubernetes-dashboard:/proxy/](http://localhost:8001/api/v1/namespaces/kube-system/services/https:kubernetes-dashboard:/proxy/) [http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/](http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/)
You should see a login screen. You should see a login screen.

View File

@ -1,15 +1,16 @@
# Setup Ingress and HTTPS # Setup Ingress and HTTPS
Follow [this quick start guide](https://docs.cert-manager.io/en/latest/tutorials/acme/quick-start/index.html) and install certmanager via helm and tiller: Follow [this quick start guide](https://docs.cert-manager.io/en/latest/tutorials/acme/quick-start/index.html) and install certmanager via helm and tiller:
[This resource was also helpful](https://docs.cert-manager.io/en/latest/getting-started/install/kubernetes.html#installing-with-helm)
```text ```bash
$ kubectl create serviceaccount tiller --namespace=kube-system $ kubectl create serviceaccount tiller --namespace=kube-system
$ kubectl create clusterrolebinding tiller-admin --serviceaccount=kube-system:tiller --clusterrole=cluster-admin $ kubectl create clusterrolebinding tiller-admin --serviceaccount=kube-system:tiller --clusterrole=cluster-admin
$ helm init --service-account=tiller $ helm init --service-account=tiller
$ helm repo add jetstack https://charts.jetstack.io
$ helm repo update $ helm repo update
$ helm install stable/nginx-ingress $ kubectl apply -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.11/deploy/manifests/00-crds.yaml
$ kubectl apply -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.6/deploy/manifests/00-crds.yaml $ helm install --name cert-manager --namespace cert-manager --version v0.11.0 jetstack/cert-manager
$ helm install --name cert-manager --namespace cert-manager stable/cert-manager
``` ```
## Create Letsencrypt Issuers and Ingress Services ## Create Letsencrypt Issuers and Ingress Services

View File

@ -12,20 +12,20 @@ spec:
tls: tls:
- hosts: - hosts:
# - nitro-mailserver.human-connection.org # - nitro-mailserver.human-connection.org
- nitro-staging.human-connection.org - develop.human-connection.org
secretName: tls secretName: tls
rules: rules:
- host: nitro-staging.human-connection.org - host: develop.human-connection.org
http: http:
paths: paths:
- path: / - path: /
backend: backend:
serviceName: nitro-web serviceName: web
servicePort: 3000 servicePort: 3000
# - host: nitro-mailserver.human-connection.org - host: mailserver.human-connection.org
# http: http:
# paths: paths:
# - path: / - path: /
# backend: backend:
# serviceName: mailserver serviceName: mailserver
# servicePort: 80 servicePort: 80

View File

@ -1,47 +1,60 @@
--- apiVersion: apps/v1
apiVersion: extensions/v1beta1 kind: Deployment
kind: Deployment metadata:
metadata: creationTimestamp: null
name: nitro-backend labels:
namespace: human-connection human-connection.org/commit: COMMIT
spec: human-connection.org/selector: deployment-human-connection-backend
replicas: 1 name: backend
minReadySeconds: 15 namespace: human-connection
progressDeadlineSeconds: 60 spec:
strategy: minReadySeconds: 15
rollingUpdate: progressDeadlineSeconds: 60
maxSurge: 0 replicas: 1
maxUnavailable: "100%" revisionHistoryLimit: 2147483647
selector: selector:
matchLabels: matchLabels:
human-connection.org/selector: deployment-human-connection-backend
strategy:
rollingUpdate:
maxSurge: 0
maxUnavailable: 100%
type: RollingUpdate
template:
metadata:
annotations:
backup.velero.io/backup-volumes: uploads
creationTimestamp: null
labels:
human-connection.org/commit: COMMIT
human-connection.org/selector: deployment-human-connection-backend human-connection.org/selector: deployment-human-connection-backend
template: name: backend
metadata: spec:
annotations: containers:
backup.velero.io/backup-volumes: uploads - envFrom:
labels: - configMapRef:
human-connection.org/commit: COMMIT name: configmap
human-connection.org/selector: deployment-human-connection-backend - secretRef:
name: "nitro-backend" name: human-connection
spec: image: humanconnection/nitro-backend:latest
containers: imagePullPolicy: Always
- name: nitro-backend name: nitro-backend
image: humanconnection/nitro-backend:latest ports:
imagePullPolicy: Always - containerPort: 4000
ports: protocol: TCP
- containerPort: 4000 resources: {}
envFrom: terminationMessagePath: /dev/termination-log
- configMapRef: terminationMessagePolicy: File
name: configmap volumeMounts:
- secretRef: - mountPath: /nitro-backend/public/uploads
name: human-connection name: uploads
volumeMounts: dnsPolicy: ClusterFirst
- mountPath: /nitro-backend/public/uploads restartPolicy: Always
name: uploads schedulerName: default-scheduler
volumes: securityContext: {}
- name: uploads terminationGracePeriodSeconds: 30
persistentVolumeClaim: volumes:
claimName: uploads-claim - name: uploads
restartPolicy: Always persistentVolumeClaim:
terminationGracePeriodSeconds: 30 claimName: uploads-claim
status: {} status: {}

View File

@ -1,47 +1,61 @@
--- apiVersion: apps/v1
apiVersion: extensions/v1beta1 kind: Deployment
kind: Deployment metadata:
metadata: creationTimestamp: null
name: nitro-neo4j labels:
namespace: human-connection human-connection.org/selector: deployment-human-connection-neo4j
spec: name: neo4j
replicas: 1 namespace: human-connection
strategy: spec:
rollingUpdate: progressDeadlineSeconds: 2147483647
maxSurge: 0 replicas: 1
maxUnavailable: "100%" revisionHistoryLimit: 2147483647
selector: selector:
matchLabels: matchLabels:
human-connection.org/selector: deployment-human-connection-neo4j
strategy:
rollingUpdate:
maxSurge: 0
maxUnavailable: 100%
type: RollingUpdate
template:
metadata:
annotations:
backup.velero.io/backup-volumes: neo4j-data
creationTimestamp: null
labels:
human-connection.org/selector: deployment-human-connection-neo4j human-connection.org/selector: deployment-human-connection-neo4j
template: name: neo4j
metadata: spec:
annotations: containers:
backup.velero.io/backup-volumes: neo4j-data - envFrom:
labels: - configMapRef:
human-connection.org/selector: deployment-human-connection-neo4j name: configmap
name: nitro-neo4j image: humanconnection/neo4j:latest
spec: imagePullPolicy: Always
containers: name: neo4j
- name: nitro-neo4j ports:
image: humanconnection/neo4j:latest - containerPort: 7687
imagePullPolicy: Always protocol: TCP
resources: - containerPort: 7474
requests: protocol: TCP
memory: "2G" resources:
limits: limits:
memory: "8G" memory: 2G
envFrom: requests:
- configMapRef: memory: 1G
name: configmap terminationMessagePath: /dev/termination-log
ports: terminationMessagePolicy: File
- containerPort: 7687 volumeMounts:
- containerPort: 7474 - mountPath: /data/
volumeMounts: name: neo4j-data
- mountPath: /data/ dnsPolicy: ClusterFirst
name: neo4j-data restartPolicy: Always
volumes: schedulerName: default-scheduler
- name: neo4j-data securityContext: {}
persistentVolumeClaim: terminationGracePeriodSeconds: 30
claimName: neo4j-data-claim volumes:
restartPolicy: Always - name: neo4j-data
terminationGracePeriodSeconds: 30 persistentVolumeClaim:
claimName: neo4j-data-claim
status: {}

View File

@ -1,37 +1,54 @@
apiVersion: extensions/v1beta1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: nitro-web creationTimestamp: null
labels:
human-connection.org/commit: COMMIT
human-connection.org/selector: deployment-human-connection-web
name: web
namespace: human-connection namespace: human-connection
spec: spec:
replicas: 2
minReadySeconds: 15 minReadySeconds: 15
progressDeadlineSeconds: 60 progressDeadlineSeconds: 60
replicas: 2
revisionHistoryLimit: 2147483647
selector: selector:
matchLabels: matchLabels:
human-connection.org/selector: deployment-human-connection-web human-connection.org/selector: deployment-human-connection-web
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
type: RollingUpdate
template: template:
metadata: metadata:
creationTimestamp: null
labels: labels:
human-connection.org/commit: COMMIT human-connection.org/commit: COMMIT
human-connection.org/selector: deployment-human-connection-web human-connection.org/selector: deployment-human-connection-web
name: nitro-web name: web
spec: spec:
containers: containers:
- name: web - env:
- name: HOST
value: 0.0.0.0
envFrom: envFrom:
- configMapRef: - configMapRef:
name: configmap name: configmap
- secretRef: - secretRef:
name: human-connection name: human-connection
env:
- name: HOST
value: 0.0.0.0
image: humanconnection/nitro-web:latest image: humanconnection/nitro-web:latest
imagePullPolicy: Always
name: web
ports: ports:
- containerPort: 3000 - containerPort: 3000
protocol: TCP
resources: {} resources: {}
imagePullPolicy: Always terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
restartPolicy: Always restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30 terminationGracePeriodSeconds: 30
status: {} status: {}

View File

@ -1,34 +1,51 @@
--- apiVersion: apps/v1
apiVersion: extensions/v1beta1 kind: Deployment
kind: Deployment metadata:
metadata: creationTimestamp: null
name: mailserver labels:
namespace: human-connection human-connection.org/selector: deployment-human-connection-mailserver
spec: name: mailserver
replicas: 1 namespace: human-connection
minReadySeconds: 15 spec:
progressDeadlineSeconds: 60 minReadySeconds: 15
selector: progressDeadlineSeconds: 60
matchLabels: replicas: 1
revisionHistoryLimit: 2147483647
selector:
matchLabels:
human-connection.org/selector: deployment-human-connection-mailserver
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
creationTimestamp: null
labels:
human-connection.org/selector: deployment-human-connection-mailserver human-connection.org/selector: deployment-human-connection-mailserver
template: name: mailserver
metadata: spec:
labels: containers:
human-connection.org/selector: deployment-human-connection-mailserver - envFrom:
name: "mailserver" - configMapRef:
spec: name: configmap
containers: - secretRef:
- name: mailserver name: human-connection
image: djfarrelly/maildev image: djfarrelly/maildev
imagePullPolicy: Always imagePullPolicy: Always
ports: name: mailserver
- containerPort: 80 ports:
- containerPort: 25 - containerPort: 80
envFrom: protocol: TCP
- configMapRef: - containerPort: 25
name: configmap protocol: TCP
- secretRef: resources: {}
name: human-connection terminationMessagePath: /dev/termination-log
restartPolicy: Always terminationMessagePolicy: File
terminationGracePeriodSeconds: 30 dnsPolicy: ClusterFirst
status: {} restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
status: {}

View File

@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: nitro-backend name: backend
namespace: human-connection namespace: human-connection
labels: labels:
human-connection.org/selector: deployment-human-connection-backend human-connection.org/selector: deployment-human-connection-backend

View File

@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: nitro-neo4j name: neo4j
namespace: human-connection namespace: human-connection
labels: labels:
human-connection.org/selector: deployment-human-connection-neo4j human-connection.org/selector: deployment-human-connection-neo4j

View File

@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: nitro-web name: web
namespace: human-connection namespace: human-connection
labels: labels:
human-connection.org/selector: deployment-human-connection-web human-connection.org/selector: deployment-human-connection-web

View File

@ -4,7 +4,6 @@
data: data:
SMTP_HOST: "mailserver.human-connection" SMTP_HOST: "mailserver.human-connection"
SMTP_PORT: "25" SMTP_PORT: "25"
GRAPHQL_PORT: "4000"
GRAPHQL_URI: "http://nitro-backend.human-connection:4000" GRAPHQL_URI: "http://nitro-backend.human-connection:4000"
NEO4J_URI: "bolt://nitro-neo4j.human-connection:7687" NEO4J_URI: "bolt://nitro-neo4j.human-connection:7687"
NEO4J_AUTH: "none" NEO4J_AUTH: "none"

View File

@ -2,11 +2,15 @@ version: "3.4"
services: services:
webapp: webapp:
environment:
- "CI=${CI}"
image: humanconnection/nitro-web:build-and-test image: humanconnection/nitro-web:build-and-test
build: build:
context: webapp context: webapp
target: build-and-test target: build-and-test
backend: backend:
environment:
- "CI=${CI}"
image: humanconnection/nitro-backend:build-and-test image: humanconnection/nitro-backend:build-and-test
build: build:
context: backend context: backend

View File

@ -15,7 +15,6 @@ services:
environment: environment:
- NEO4J_dbms_security_auth__enabled=false - NEO4J_dbms_security_auth__enabled=false
- NEO4J_dbms_memory_heap_max__size=2G - NEO4J_dbms_memory_heap_max__size=2G
- GRAPHQL_PORT=4000
- GRAPHQL_URI=http://localhost:4000 - GRAPHQL_URI=http://localhost:4000
- CLIENT_URI=http://localhost:3000 - CLIENT_URI=http://localhost:3000
- JWT_SECRET=b/&&7b78BF&fv/Vd - JWT_SECRET=b/&&7b78BF&fv/Vd

View File

@ -39,7 +39,6 @@ services:
- uploads:/nitro-backend/public/uploads - uploads:/nitro-backend/public/uploads
environment: environment:
- NEO4J_URI=bolt://neo4j:7687 - NEO4J_URI=bolt://neo4j:7687
- GRAPHQL_PORT=4000
- GRAPHQL_URI=http://backend:4000 - GRAPHQL_URI=http://backend:4000
- CLIENT_URI=http://localhost:3000 - CLIENT_URI=http://localhost:3000
- JWT_SECRET=b/&&7b78BF&fv/Vd - JWT_SECRET=b/&&7b78BF&fv/Vd

View File

@ -1,6 +1,6 @@
{ {
"name": "nitro-cypress", "name": "nitro-cypress",
"version": "0.1.9", "version": "0.1.10",
"description": "Fullstack tests with cypress for Human Connection", "description": "Fullstack tests with cypress for Human Connection",
"author": "Human Connection gGmbh", "author": "Human Connection gGmbh",
"license": "MIT", "license": "MIT",
@ -8,15 +8,14 @@
"nonGlobalStepDefinitions": true "nonGlobalStepDefinitions": true
}, },
"scripts": { "scripts": {
"install:all": "yarn install && cd backend && yarn install && cd ../webapp && yarn install",
"db:seed": "cd backend && yarn run db:seed", "db:seed": "cd backend && yarn run db:seed",
"db:reset": "cd backend && yarn run db:reset", "db:reset": "cd backend && yarn run db:reset",
"cypress:backend:server": "cd backend && yarn run test:before:server", "cypress:backend": "cd backend && yarn run dev",
"cypress:backend:seeder": "cd backend && yarn run test:before:seeder", "cypress:webapp": "cd webapp && yarn run dev",
"cypress:webapp": "cd webapp && cross-env GRAPHQL_URI=http://localhost:4123 yarn run dev", "cypress:setup": "run-p cypress:backend cypress:webapp",
"cypress:setup": "run-p cypress:backend:* cypress:webapp", "cypress:run": "cross-env cypress run --browser chromium",
"cypress:run": "cypress run --browser chromium", "cypress:open": "cross-env cypress open --browser chromium",
"cypress:open": "cypress open --browser chromium",
"test:jest": "cd webapp && yarn test && cd ../backend && yarn test:jest && codecov",
"version": "auto-changelog -p" "version": "auto-changelog -p"
}, },
"devDependencies": { "devDependencies": {
@ -24,11 +23,11 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"codecov": "^3.6.1", "codecov": "^3.6.1",
"cross-env": "^6.0.3", "cross-env": "^6.0.3",
"cypress": "^3.6.0", "cypress": "^3.6.1",
"cypress-cucumber-preprocessor": "^1.16.2", "cypress-cucumber-preprocessor": "^1.16.2",
"cypress-file-upload": "^3.4.0", "cypress-file-upload": "^3.5.0",
"cypress-plugin-retries": "^1.4.0", "cypress-plugin-retries": "^1.4.0",
"date-fns": "^2.6.0", "date-fns": "^2.7.0",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"faker": "Marak/faker.js#master", "faker": "Marak/faker.js#master",
"graphql-request": "^1.8.2", "graphql-request": "^1.8.2",

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
sed -i "s/<COMMIT>/${TRAVIS_COMMIT}/g" $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml sed -i "s/<COMMIT>/${TRAVIS_COMMIT}/g" $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml
sed -i "s/<COMMIT>/${TRAVIS_COMMIT}/g" $TRAVIS_BUILD_DIR/scripts/patches/patch-configmap.yaml sed -i "s/<COMMIT>/${TRAVIS_COMMIT}/g" $TRAVIS_BUILD_DIR/scripts/patches/patch-configmap.yaml
kubectl --namespace=human-connection patch configmap configmap -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-configmap.yaml)" kubectl --namespace=human-connection patch configmap configmap -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-configmap.yaml)"
kubectl --namespace=human-connection patch deployment nitro-backend -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)" kubectl --namespace=human-connection patch deployment backend -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)"
kubectl --namespace=human-connection patch deployment nitro-web -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)" kubectl --namespace=human-connection patch deployment web -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)"

View File

@ -13,6 +13,6 @@ tar xf doctl-1.14.0-linux-amd64.tar.gz
chmod +x ./doctl chmod +x ./doctl
sudo mv ./doctl /usr/local/bin/doctl sudo mv ./doctl /usr/local/bin/doctl
doctl auth init --access-token $DOCTL_ACCESS_TOKEN doctl auth init --access-token $DIGITALOCEAN_ACCESS_TOKEN
mkdir -p ~/.kube/ mkdir -p ~/.kube/
doctl kubernetes cluster kubeconfig show nitro-staging > ~/.kube/config doctl k8s cluster kubeconfig show develop > ~/.kube/config

View File

@ -0,0 +1,168 @@
import { config, mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import VTooltip from 'v-tooltip'
import Styleguide from '@human-connection/styleguide'
import AvatarMenu from './AvatarMenu.vue'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Vuex)
localVue.use(Filters)
localVue.use(VTooltip)
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['router-link'] = '<span><slot /></span>'
describe('AvatarMenu.vue', () => {
let propsData, getters, wrapper, mocks
beforeEach(() => {
propsData = {}
mocks = {
$route: {
path: '',
},
$router: {
resolve: jest.fn(() => {
return { href: '/profile/u343/matt' }
}),
},
$t: jest.fn(a => a),
}
getters = {
'auth/user': () => {
return { id: 'u343', name: 'Matt' }
},
}
})
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(AvatarMenu, { propsData, localVue, store, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the HcAvatar component', () => {
wrapper.find('.avatar-menu-trigger').trigger('click')
expect(wrapper.find('.ds-avatar').exists()).toBe(true)
})
describe('given a userName', () => {
it('displays the userName', () => {
expect(wrapper.find('b').text()).toEqual('Matt')
})
})
describe('no userName', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return { id: 'u343' }
},
}
wrapper = Wrapper()
wrapper.find('.avatar-menu-trigger').trigger('click')
})
it('displays anonymous user', () => {
expect(wrapper.find('b').text()).toEqual('profile.userAnonym')
})
})
describe('menu items', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return { id: 'u343', slug: 'matt' }
},
'auth/isModerator': () => false,
'auth/isAdmin': () => false,
}
wrapper = Wrapper()
wrapper.find('.avatar-menu-trigger').trigger('click')
})
describe('role user', () => {
it('displays a link to user profile', () => {
const profileLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/profile/u343/matt'))
expect(profileLink.exists()).toBe(true)
})
it('displays a link to the notifications page', () => {
const notificationsLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/notifications'))
expect(notificationsLink.exists()).toBe(true)
})
it('displays a link to the settings page', () => {
const settingsLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/settings'))
expect(settingsLink.exists()).toBe(true)
})
})
describe('role moderator', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return { id: 'u343', slug: 'matt' }
},
'auth/isModerator': () => true,
'auth/isAdmin': () => false,
}
wrapper = Wrapper()
wrapper.find('.avatar-menu-trigger').trigger('click')
})
it('displays a link to moderation page', () => {
const moderationLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/moderation'))
expect(moderationLink.exists()).toBe(true)
})
it('displays a total of 4 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(4)
})
})
describe('role admin', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return { id: 'u343', slug: 'matt' }
},
'auth/isModerator': () => true,
'auth/isAdmin': () => true,
}
wrapper = Wrapper()
wrapper.find('.avatar-menu-trigger').trigger('click')
})
it('displays a link to admin page', () => {
const adminLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/admin'))
expect(adminLink.exists()).toBe(true)
})
it('displays a total of 5 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(5)
})
})
})
})
})

View File

@ -0,0 +1,17 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import StoryRouter from 'storybook-vue-router'
import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
import helpers from '~/storybook/helpers'
helpers.init()
storiesOf('AvatarMenu', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.addDecorator(StoryRouter())
.add('dropdown', () => ({
components: { AvatarMenu },
store: helpers.store,
template: '<avatar-menu placement="top" />',
}))

View File

@ -0,0 +1,146 @@
<template>
<dropdown class="avatar-menu" offset="8" :placement="placement">
<template #default="{ toggleMenu }">
<a
class="avatar-menu-trigger"
:href="
$router.resolve({
name: 'profile-id-slug',
params: { id: user.id, slug: user.slug },
}).href
"
@click.prevent="toggleMenu"
>
<hc-avatar :user="user" />
<ds-icon size="xx-small" name="angle-down" />
</a>
</template>
<template #popover="{ closeMenu }">
<div class="avatar-menu-popover">
{{ $t('login.hello') }}
<b>{{ userName }}</b>
<template v-if="user.role !== 'user'">
<ds-text color="softer" size="small" style="margin-bottom: 0">
{{ user.role | camelCase }}
</ds-text>
</template>
<hr />
<ds-menu :routes="routes" :matcher="matcher">
<ds-menu-item
slot="menuitem"
slot-scope="item"
:route="item.route"
:parents="item.parents"
@click.native="closeMenu(false)"
>
<ds-icon :name="item.route.icon" />
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
<hr />
<nuxt-link class="logout-link" :to="{ name: 'logout' }">
<ds-icon name="sign-out" />
{{ $t('login.logout') }}
</nuxt-link>
</div>
</template>
</dropdown>
</template>
<script>
import { mapGetters } from 'vuex'
import Dropdown from '~/components/Dropdown'
import HcAvatar from '~/components/Avatar/Avatar.vue'
export default {
components: {
Dropdown,
HcAvatar,
},
props: {
placement: { type: String, default: 'top-end' },
},
computed: {
...mapGetters({
user: 'auth/user',
isModerator: 'auth/isModerator',
isAdmin: 'auth/isAdmin',
}),
routes() {
if (!this.user.slug) {
return []
}
const routes = [
{
name: this.$t('profile.name'),
path: `/profile/${this.user.id}/${this.user.slug}`,
icon: 'user',
},
{
name: this.$t('notifications.pageLink'),
path: '/notifications',
icon: 'bell',
},
{
name: this.$t('settings.name'),
path: `/settings`,
icon: 'cogs',
},
]
if (this.isModerator) {
routes.push({
name: this.$t('moderation.name'),
path: `/moderation`,
icon: 'balance-scale',
})
}
if (this.isAdmin) {
routes.push({
name: this.$t('admin.name'),
path: `/admin`,
icon: 'shield',
})
}
return routes
},
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
},
methods: {
matcher(url, route) {
if (url.indexOf('/profile') === 0) {
// do only match own profile
return this.$route.path === url
}
return this.$route.path.indexOf(url) === 0
},
},
}
</script>
<style lang="scss">
.avatar-menu {
margin: $space-xxx-small 0px 0px $space-xx-small;
}
.avatar-menu-trigger {
user-select: none;
display: flex;
align-items: center;
padding-left: $space-xx-small;
}
.avatar-menu-popover {
padding-top: $space-x-small;
padding-bottom: $space-x-small;
hr {
color: $color-neutral-90;
background-color: $color-neutral-90;
}
.logout-link {
color: $text-color-base;
padding-top: $space-xx-small;
&:hover {
color: $text-color-link-active;
}
}
}
</style>

View File

@ -9,7 +9,7 @@ describe('Category', () => {
let icon let icon
let name let name
let Wrapper = () => { const Wrapper = () => {
return shallowMount(Category, { return shallowMount(Category, {
localVue, localVue,
propsData: { propsData: {

View File

@ -53,7 +53,7 @@ export default {
}, },
computed: { computed: {
routes() { routes() {
let routes = [] const routes = []
if (this.resourceType === 'contribution') { if (this.resourceType === 'contribution') {
if (this.isOwner) { if (this.isOwner) {

View File

@ -101,7 +101,7 @@ export default {
} }
}, },
handleSubmit() { handleSubmit() {
let resourceArgs = [] const resourceArgs = []
if (this.deleteContributions) { if (this.deleteContributions) {
resourceArgs.push('Post') resourceArgs.push('Post')
} }

View File

@ -0,0 +1,80 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import DonationInfo from './DonationInfo.vue'
const localVue = createLocalVue()
localVue.use(Styleguide)
const mockDate = new Date(2019, 11, 6)
global.Date = jest.fn(() => mockDate)
describe('DonationInfo.vue', () => {
let mocks, wrapper
beforeEach(() => {
mocks = {
$t: jest.fn(string => string),
$i18n: {
locale: () => 'de',
},
}
})
const Wrapper = () => mount(DonationInfo, { mocks, localVue })
it('includes a link to the Human Connection donations website', () => {
expect(
Wrapper()
.find('a')
.attributes('href'),
).toBe('https://human-connection.org/spenden/')
})
it('displays a call to action button', () => {
expect(
Wrapper()
.find('.ds-button')
.text(),
).toBe('donations.donate-now')
})
it('creates a title from the current month and a translation string', () => {
mocks.$t = jest.fn(() => 'Spenden für')
expect(Wrapper().vm.title).toBe('Spenden für Dezember')
})
describe('mount with data', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.setData({ goal: 50000, progress: 10000 })
})
describe('given german locale', () => {
it('creates a label from the given amounts and a translation string', () => {
expect(mocks.$t).toBeCalledWith(
'donations.amount-of-total',
expect.objectContaining({
amount: '10.000',
total: '50.000',
}),
)
})
})
describe('given english locale', () => {
beforeEach(() => {
mocks.$i18n.locale = () => 'en'
})
it('creates a label from the given amounts and a translation string', () => {
expect(mocks.$t).toBeCalledWith(
'donations.amount-of-total',
expect.objectContaining({
amount: '10,000',
total: '50,000',
}),
)
})
})
})
})

View File

@ -0,0 +1,66 @@
<template>
<div class="donation-info">
<progress-bar :title="title" :label="label" :goal="goal" :progress="progress" />
<a target="_blank" href="https://human-connection.org/spenden/">
<ds-button primary>{{ $t('donations.donate-now') }}</ds-button>
</a>
</div>
</template>
<script>
import { DonationsQuery } from '~/graphql/Donations'
import ProgressBar from '~/components/ProgressBar/ProgressBar.vue'
export default {
components: {
ProgressBar,
},
data() {
return {
goal: 15000,
progress: 0,
}
},
computed: {
title() {
const today = new Date()
const month = today.toLocaleString(this.$i18n.locale(), { month: 'long' })
return `${this.$t('donations.donations-for')} ${month}`
},
label() {
return this.$t('donations.amount-of-total', {
amount: this.progress.toLocaleString(this.$i18n.locale()),
total: this.goal.toLocaleString(this.$i18n.locale()),
})
},
},
apollo: {
Donations: {
query() {
return DonationsQuery()
},
update({ Donations }) {
if (!Donations[0]) return
const { goal, progress } = Donations[0]
this.goal = goal
this.progress = progress
},
},
},
}
</script>
<style lang="scss">
.donation-info {
display: flex;
align-items: flex-end;
height: 100%;
@media (max-width: 546px) {
width: 100%;
height: 50%;
justify-content: flex-end;
margin-bottom: $space-x-small;
}
}
</style>

View File

@ -0,0 +1,78 @@
import { mount, createLocalVue } from '@vue/test-utils'
import VTooltip from 'v-tooltip'
import Styleguide from '@human-connection/styleguide'
import DropdownFilter from './DropdownFilter.vue'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(VTooltip)
describe('DropdownFilter.vue', () => {
let propsData, wrapper, mocks
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn(a => a),
}
})
const Wrapper = () => {
return mount(DropdownFilter, { propsData, localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('selected', () => {
it('displays selected filter', () => {
propsData.selected = 'Read'
wrapper = Wrapper()
expect(wrapper.find('.dropdown-filter label').text()).toEqual(propsData.selected)
})
})
describe('menu items', () => {
let allLink
beforeEach(() => {
propsData.filterOptions = [
{ label: 'All', value: null },
{ label: 'Read', value: true },
{ label: 'Unread', value: false },
]
wrapper = Wrapper()
wrapper.find('.dropdown-filter').trigger('click')
allLink = wrapper
.findAll('.dropdown-menu-item')
.at(propsData.filterOptions.findIndex(option => option.label === 'All'))
})
it('displays a link for All', () => {
expect(allLink.text()).toEqual('All')
})
it('displays a link for Read', () => {
const readLink = wrapper
.findAll('.dropdown-menu-item')
.at(propsData.filterOptions.findIndex(option => option.label === 'Read'))
expect(readLink.text()).toEqual('Read')
})
it('displays a link for Unread', () => {
const unreadLink = wrapper
.findAll('.dropdown-menu-item')
.at(propsData.filterOptions.findIndex(option => option.label === 'Unread'))
expect(unreadLink.text()).toEqual('Unread')
})
it('clicking on menu item emits filterNotifications', () => {
allLink.trigger('click')
expect(wrapper.emitted().filterNotifications[0]).toEqual(
propsData.filterOptions.filter(option => option.label === 'All'),
)
})
})
})
})

View File

@ -0,0 +1,30 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import helpers from '~/storybook/helpers'
helpers.init()
const filterOptions = [
{ label: 'All', value: null },
{ label: 'Read', value: true },
{ label: 'Unread', value: false },
]
storiesOf('DropdownFilter', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('filter dropdown', () => ({
components: { DropdownFilter },
data: () => ({
filterOptions,
selected: filterOptions[0].label,
}),
methods: {
filterNotifications: action('filterNotifications'),
},
template: `<dropdown-filter
@filterNotifications="filterNotifications"
:filterOptions="filterOptions"
:selected="selected"
/>`,
}))

View File

@ -0,0 +1,78 @@
<template>
<dropdown offset="8">
<a
:v-model="selected"
slot="default"
slot-scope="{ toggleMenu }"
name="dropdown"
class="dropdown-filter"
href="#"
@click.prevent="toggleMenu()"
>
<ds-icon style="margin-right: 2px;" name="filter" />
<label for="dropdown">{{ selected }}</label>
<ds-icon style="margin-left: 2px" size="xx-small" name="angle-down" />
</a>
<ds-menu
slot="popover"
slot-scope="{ toggleMenu }"
class="dropdown-menu-popover"
:routes="filterOptions"
>
<ds-menu-item
slot="menuitem"
slot-scope="item"
class="dropdown-menu-item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="filterNotifications(item.route, toggleMenu)"
>
{{ item.route.label }}
</ds-menu-item>
</ds-menu>
</dropdown>
</template>
<script>
import Dropdown from '~/components/Dropdown'
export default {
components: {
Dropdown,
},
props: {
selected: { type: String, default: '' },
filterOptions: { type: Array, default: () => [] },
},
methods: {
filterNotifications(option, toggleMenu) {
this.$emit('filterNotifications', option)
toggleMenu()
},
},
}
</script>
<style lang="scss">
.dropdown-filter {
user-select: none;
display: flex;
align-items: center;
height: 100%;
padding: $space-xx-small;
color: $text-color-soft;
}
.dropdown-menu {
user-select: none;
display: flex;
align-items: center;
height: 100%;
padding: $space-xx-small;
color: $text-color-soft;
}
.dropdown-menu-popover {
a {
padding: $space-x-small $space-small;
padding-right: $space-base;
}
}
</style>

View File

@ -79,7 +79,7 @@ describe('Editor.vue', () => {
describe('limists suggestion list to 15 users', () => { describe('limists suggestion list to 15 users', () => {
beforeEach(() => { beforeEach(() => {
let manyUsersList = [] const manyUsersList = []
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
manyUsersList.push({ id: `user${i}` }) manyUsersList.push({ id: `user${i}` })
} }
@ -120,7 +120,7 @@ describe('Editor.vue', () => {
describe('limists suggestion list to 15 hashtags', () => { describe('limists suggestion list to 15 hashtags', () => {
beforeEach(() => { beforeEach(() => {
let manyHashtagsList = [] const manyHashtagsList = []
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
manyHashtagsList.push({ id: `hashtag${i}` }) manyHashtagsList.push({ id: `hashtag${i}` })
} }

View File

@ -377,6 +377,7 @@ li > p {
.embed-preview-image { .embed-preview-image {
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 450px;
} }
.embed-preview-image--clickable { .embed-preview-image--clickable {

View File

@ -63,30 +63,26 @@ describe('defaultExtensions', () => {
it('recognizes embed code', () => { it('recognizes embed code', () => {
const editor = createEditor() const editor = createEditor()
const expected = { const expected = {
type: 'doc',
content: [ content: [
{ {
type: 'paragraph',
content: [ content: [
{ {
text: 'Baby loves cat:', text: 'Baby loves cat:',
type: 'text', type: 'text',
}, },
], ],
type: 'paragraph',
}, },
{ {
content: [ type: 'embed',
{ attrs: {
attrs: { dataEmbedUrl: 'https://www.youtube.com/watch?v=qkdXAtO40Fo',
dataEmbedUrl: 'https://www.youtube.com/watch?v=qkdXAtO40Fo', },
},
type: 'embed',
},
],
type: 'paragraph',
}, },
], ],
type: 'doc',
} }
expect(editor.getJSON()).toEqual(expected) expect(editor.getJSON()).toEqual(expected)
}) })
}) })

View File

@ -38,8 +38,8 @@ export default class Embed extends Node {
default: null, default: null,
}, },
}, },
group: 'inline', group: 'block',
inline: true, inline: false,
parseDOM: [ parseDOM: [
{ {
tag: 'a[href].embed', tag: 'a[href].embed',

View File

@ -5,6 +5,7 @@ export default class EventHandler extends Extension {
get name() { get name() {
return 'event_handler' return 'event_handler'
} }
get plugins() { get plugins() {
return [ return [
new Plugin({ new Plugin({

View File

@ -0,0 +1,54 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import Empty from './Empty.vue'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('Empty.vue', () => {
let propsData, wrapper
beforeEach(() => {
propsData = {}
})
const Wrapper = () => {
return shallowMount(Empty, { propsData, localVue })
}
describe('shallowMount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders an image with an alert icon as default', () => {
expect(wrapper.find('img[alt="Empty"]').attributes().src).toBe('/img/empty/alert.svg')
})
describe('receives icon prop', () => {
it('renders an image with that icon', () => {
propsData.icon = 'messages'
wrapper = Wrapper()
expect(wrapper.find('img[alt="Empty"]').attributes().src).toBe(
`/img/empty/${propsData.icon}.svg`,
)
})
})
describe('receives message prop', () => {
it('renders that message', () => {
propsData.message = 'this is a custom message for Empty component'
wrapper = Wrapper()
expect(wrapper.find('.hc-empty-message').text()).toEqual(propsData.message)
})
})
describe('receives margin prop', () => {
it('sets margin to that margin', () => {
propsData.margin = 'xxx-small'
wrapper = Wrapper()
expect(wrapper.find('.hc-empty').attributes().margin).toEqual(propsData.margin)
})
})
})
})

View File

@ -0,0 +1,24 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcEmpty from '~/components/Empty/Empty'
import helpers from '~/storybook/helpers'
helpers.init()
storiesOf('Empty', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add(
'tasks icon with message',
() => ({
components: { HcEmpty },
template: '<hc-empty icon="tasks" message="Sorry, there are no ... available." />',
}),
{
notes: "Possible icons include 'messages', 'events', 'alert', 'tasks', 'docs', and 'file'",
},
)
.add('default icon, no message', () => ({
components: { HcEmpty },
template: '<hc-empty />',
}))

View File

@ -26,7 +26,7 @@ export default {
*/ */
icon: { icon: {
type: String, type: String,
required: true, default: 'alert',
validator: value => { validator: value => {
return value.match(/(messages|events|alert|tasks|docs|file)/) return value.match(/(messages|events|alert|tasks|docs|file)/)
}, },

View File

@ -1,14 +1,16 @@
import { shallowMount, createLocalVue } from '@vue/test-utils' import { config, shallowMount, createLocalVue } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
import Hashtag from './Hashtag' import Hashtag from './Hashtag'
const localVue = createLocalVue() const localVue = createLocalVue()
localVue.use(Styleguide) localVue.use(Styleguide)
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('Hashtag', () => { describe('Hashtag', () => {
let id let id
let Wrapper = () => { const Wrapper = () => {
return shallowMount(Hashtag, { return shallowMount(Hashtag, {
localVue, localVue,
propsData: { propsData: {

View File

@ -1,5 +1,7 @@
<template> <template>
<ds-tag>#{{ id }}</ds-tag> <ds-tag>
<nuxt-link :to="hashtagUrl">#{{ id }}</nuxt-link>
</ds-tag>
</template> </template>
<script> <script>
@ -8,5 +10,10 @@ export default {
props: { props: {
id: { type: String, required: true }, id: { type: String, required: true },
}, },
computed: {
hashtagUrl() {
return `/?hashtag=${this.id}`
},
},
} }
</script> </script>

View File

@ -58,7 +58,7 @@ export default {
return find(this.locales, { code: this.$i18n.locale() }) return find(this.locales, { code: this.$i18n.locale() })
}, },
routes() { routes() {
let routes = this.locales.map(locale => { const routes = this.locales.map(locale => {
return { return {
name: locale.name, name: locale.name,
path: locale.code, path: locale.code,

View File

@ -69,10 +69,9 @@ export default {
} }
</script> </script>
<style> <style lang="scss">
.notification.read { .notification.read {
opacity: 0.6; /* Real browsers */ opacity: $opacity-soft;
filter: alpha(opacity = 60); /* MSIE */
} }
.notifications-card { .notifications-card {
min-width: 500px; min-width: 500px;

View File

@ -3,8 +3,8 @@ import NotificationList from './NotificationList'
import Notification from '../Notification/Notification' import Notification from '../Notification/Notification'
import Vuex from 'vuex' import Vuex from 'vuex'
import Filters from '~/plugins/vue-filters' import Filters from '~/plugins/vue-filters'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
import { notifications } from '~/components/utils/Notifications'
const localVue = createLocalVue() const localVue = createLocalVue()
@ -38,40 +38,7 @@ describe('NotificationList.vue', () => {
stubs = { stubs = {
NuxtLink: RouterLinkStub, NuxtLink: RouterLinkStub,
} }
propsData = { propsData = { notifications }
notifications: [
{
read: false,
from: {
__typename: 'Post',
id: 'post-1',
title: 'some post title',
slug: 'some-post-title',
contentExcerpt: 'this is a post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe',
},
},
},
{
read: false,
from: {
__typename: 'Post',
id: 'post-2',
title: 'another post title',
slug: 'another-post-title',
contentExcerpt: 'this is yet another post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe',
},
},
},
],
}
}) })
describe('shallowMount', () => { describe('shallowMount', () => {
@ -110,15 +77,11 @@ describe('NotificationList.vue', () => {
describe('click on a notification', () => { describe('click on a notification', () => {
beforeEach(() => { beforeEach(() => {
wrapper wrapper.find('.notification-mention-post').trigger('click')
.findAll('.notification-mention-post')
.at(1)
.trigger('click')
}) })
it("emits 'markAsRead' with the id of the notification source", () => { it("emits 'markAsRead' with the id of the notification source", () => {
expect(wrapper.emitted('markAsRead')).toBeTruthy() expect(wrapper.emitted('markAsRead')[0]).toEqual(['post-1'])
expect(wrapper.emitted('markAsRead')[0]).toEqual(['post-2'])
}) })
}) })
}) })

View File

@ -20,7 +20,7 @@ export default {
props: { props: {
notifications: { notifications: {
type: Array, type: Array,
required: true, default: () => [],
}, },
}, },
methods: { methods: {

View File

@ -10,7 +10,7 @@ localVue.use(Styleguide)
localVue.use(Filters) localVue.use(Filters)
localVue.filter('truncate', string => string) localVue.filter('truncate', string => string)
config.stubs['dropdown'] = '<span class="dropdown"><slot /></span>' config.stubs.dropdown = '<span class="dropdown"><slot /></span>'
describe('NotificationMenu.vue', () => { describe('NotificationMenu.vue', () => {
let wrapper let wrapper
@ -50,7 +50,7 @@ describe('NotificationMenu.vue', () => {
beforeEach(() => { beforeEach(() => {
data = () => { data = () => {
return { return {
displayedNotifications: [ notifications: [
{ {
id: 'notification-41', id: 'notification-41',
read: true, read: true,
@ -85,7 +85,7 @@ describe('NotificationMenu.vue', () => {
beforeEach(() => { beforeEach(() => {
data = () => { data = () => {
return { return {
displayedNotifications: [ notifications: [
{ {
id: 'notification-41', id: 'notification-41',
read: false, read: false,

View File

@ -1,8 +1,8 @@
<template> <template>
<ds-button v-if="!notificationsCount" class="notifications-menu" disabled icon="bell"> <ds-button v-if="!notifications.length" class="notifications-menu" disabled icon="bell">
{{ unreadNotificationsCount }} {{ unreadNotificationsCount }}
</ds-button> </ds-button>
<dropdown v-else class="notifications-menu" :placement="placement"> <dropdown v-else class="notifications-menu" offset="8" :placement="placement">
<template slot="default" slot-scope="{ toggleMenu }"> <template slot="default" slot-scope="{ toggleMenu }">
<ds-button :primary="!!unreadNotificationsCount" icon="bell" @click.prevent="toggleMenu"> <ds-button :primary="!!unreadNotificationsCount" icon="bell" @click.prevent="toggleMenu">
{{ unreadNotificationsCount }} {{ unreadNotificationsCount }}
@ -10,7 +10,12 @@
</template> </template>
<template slot="popover"> <template slot="popover">
<div class="notifications-menu-popover"> <div class="notifications-menu-popover">
<notification-list :notifications="displayedNotifications" @markAsRead="markAsRead" /> <notification-list :notifications="notifications" @markAsRead="markAsRead" />
</div>
<div class="notifications-link-container">
<nuxt-link :to="{ name: 'notifications' }">
{{ $t('notifications.pageLink') }}
</nuxt-link>
</div> </div>
</template> </template>
</dropdown> </dropdown>
@ -21,6 +26,7 @@ import Dropdown from '~/components/Dropdown'
import { NOTIFICATIONS_POLL_INTERVAL } from '~/constants/notifications' import { NOTIFICATIONS_POLL_INTERVAL } from '~/constants/notifications'
import { notificationQuery, markAsReadMutation } from '~/graphql/User' import { notificationQuery, markAsReadMutation } from '~/graphql/User'
import NotificationList from '../NotificationList/NotificationList' import NotificationList from '../NotificationList/NotificationList'
import unionBy from 'lodash/unionBy'
export default { export default {
name: 'NotificationMenu', name: 'NotificationMenu',
@ -30,7 +36,6 @@ export default {
}, },
data() { data() {
return { return {
displayedNotifications: [],
notifications: [], notifications: [],
} }
}, },
@ -41,36 +46,21 @@ export default {
async markAsRead(notificationSourceId) { async markAsRead(notificationSourceId) {
const variables = { id: notificationSourceId } const variables = { id: notificationSourceId }
try { try {
const { await this.$apollo.mutate({
data: { markAsRead },
} = await this.$apollo.mutate({
mutation: markAsReadMutation(this.$i18n), mutation: markAsReadMutation(this.$i18n),
variables, variables,
}) })
if (!(markAsRead && markAsRead.read === true)) return
this.displayedNotifications = this.displayedNotifications.map(n => {
return this.equalNotification(n, markAsRead) ? markAsRead : n
})
} catch (err) { } catch (err) {
this.$toast.error(err.message) this.$toast.error(err.message)
} }
}, },
equalNotification(a, b) {
return a.from.id === b.from.id && a.createdAt === b.createdAt && a.reason === b.reason
},
}, },
computed: { computed: {
notificationsCount() {
return (this.displayedNotifications || []).length
},
unreadNotificationsCount() { unreadNotificationsCount() {
let countUnread = 0 const result = this.notifications.reduce((count, notification) => {
if (this.displayedNotifications) { return notification.read ? count : count + 1
this.displayedNotifications.forEach(notification => { }, 0)
if (!notification.read) countUnread++ return result
})
}
return countUnread
}, },
}, },
apollo: { apollo: {
@ -78,17 +68,17 @@ export default {
query() { query() {
return notificationQuery(this.$i18n) return notificationQuery(this.$i18n)
}, },
variables() {
return {
read: false,
orderBy: 'updatedAt_desc',
}
},
pollInterval: NOTIFICATIONS_POLL_INTERVAL, pollInterval: NOTIFICATIONS_POLL_INTERVAL,
update(data) { update({ notifications }) {
const newNotifications = data.notifications.filter(newN => { return unionBy(notifications, this.notifications, notification => notification.id).sort(
return !this.displayedNotifications.find(oldN => this.equalNotification(newN, oldN)) (a, b) => new Date(b.createdAt) - new Date(a.createdAt),
}) )
this.displayedNotifications = newNotifications
.concat(this.displayedNotifications)
.sort((a, b) => {
return new Date(b.createdAt) - new Date(a.createdAt)
})
return data.notifications
}, },
error(error) { error(error) {
this.$toast.error(error.message) this.$toast.error(error.message)
@ -98,7 +88,7 @@ export default {
} }
</script> </script>
<style> <style lang="scss">
.notifications-menu { .notifications-menu {
display: flex; display: flex;
align-items: center; align-items: center;
@ -106,5 +96,16 @@ export default {
.notifications-menu-popover { .notifications-menu-popover {
max-width: 500px; max-width: 500px;
margin-bottom: $size-height-base;
}
.notifications-link-container {
background-color: $background-color-softer-active;
text-align: center;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: $size-height-base;
padding: $space-x-small;
} }
</style> </style>

View File

@ -0,0 +1,174 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import VTooltip from 'v-tooltip'
import Vuex from 'vuex'
import NotificationsTable from './NotificationsTable'
import Filters from '~/plugins/vue-filters'
import { notifications } from '~/components/utils/Notifications'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Filters)
localVue.use(VTooltip)
localVue.use(Vuex)
localVue.filter('truncate', string => string)
config.stubs['client-only'] = '<span><slot /></span>'
describe('NotificationsTable.vue', () => {
let wrapper, mocks, propsData, stubs
const postNotification = notifications[0]
const commentNotification = notifications[1]
beforeEach(() => {
mocks = {
$t: jest.fn(string => string),
}
stubs = {
NuxtLink: RouterLinkStub,
}
propsData = {}
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters: {
'auth/isModerator': () => false,
'auth/user': () => {
return {}
},
},
})
return mount(NotificationsTable, {
propsData,
mocks,
localVue,
store,
stubs,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('no notifications', () => {
it('renders HcEmpty component', () => {
expect(wrapper.find('.hc-empty').exists()).toBe(true)
})
})
describe('given notifications', () => {
beforeEach(() => {
propsData.notifications = notifications
wrapper = Wrapper()
})
it('renders a table', () => {
expect(wrapper.find('.ds-table').exists()).toBe(true)
})
describe('renders 4 columns', () => {
it('for icon', () => {
expect(wrapper.vm.fields.icon).toBeTruthy()
})
it('for user', () => {
expect(wrapper.vm.fields.user).toBeTruthy()
})
it('for post', () => {
expect(wrapper.vm.fields.post).toBeTruthy()
})
it('for content', () => {
expect(wrapper.vm.fields.content).toBeTruthy()
})
})
describe('Post', () => {
let firstRowNotification
beforeEach(() => {
firstRowNotification = wrapper.findAll('tbody tr').at(0)
})
it('renders the author', () => {
const username = firstRowNotification.find('.username')
expect(username.text()).toEqual(postNotification.from.author.name)
})
it('renders the reason for the notification', () => {
const dsTexts = firstRowNotification.findAll('.ds-text')
const reason = dsTexts.filter(
element => element.text() === 'notifications.reason.mentioned_in_post',
)
expect(reason.exists()).toBe(true)
})
it('renders a link to the Post', () => {
const postLink = firstRowNotification.find('a.notification-mention-post')
expect(postLink.text()).toEqual(postNotification.from.title)
})
it("renders the Post's content", () => {
const boldTags = firstRowNotification.findAll('b')
const content = boldTags.filter(
element => element.text() === postNotification.from.contentExcerpt,
)
expect(content.exists()).toBe(true)
})
})
describe('Comment', () => {
let secondRowNotification
beforeEach(() => {
secondRowNotification = wrapper.findAll('tbody tr').at(1)
})
it('renders the author', () => {
const username = secondRowNotification.find('.username')
expect(username.text()).toEqual(commentNotification.from.author.name)
})
it('renders the reason for the notification', () => {
const dsTexts = secondRowNotification.findAll('.ds-text')
const reason = dsTexts.filter(
element => element.text() === 'notifications.reason.mentioned_in_comment',
)
expect(reason.exists()).toBe(true)
})
it('renders a link to the Post', () => {
const postLink = secondRowNotification.find('a.notification-mention-post')
expect(postLink.text()).toEqual(commentNotification.from.post.title)
})
it("renders the Post's content", () => {
const boldTags = secondRowNotification.findAll('b')
const content = boldTags.filter(
element => element.text() === commentNotification.from.contentExcerpt,
)
expect(content.exists()).toBe(true)
})
})
describe('unread status', () => {
it('does not have class `notification-status`', () => {
expect(wrapper.find('.notification-status').exists()).toBe(false)
})
it('clicking on a Post link emits `markNotificationAsRead`', () => {
wrapper.find('a.notification-mention-post').trigger('click')
expect(wrapper.emitted().markNotificationAsRead[0][0]).toEqual(postNotification.from.id)
})
it('adds class `notification-status` when read is true', () => {
postNotification.read = true
wrapper = Wrapper()
expect(wrapper.find('.notification-status').exists()).toBe(true)
})
})
})
})
})

View File

@ -0,0 +1,86 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import helpers from '~/storybook/helpers'
import { post } from '~/components/PostCard/PostCard.story.js'
import { user } from '~/components/User/User.story.js'
helpers.init()
export const notifications = [
{
read: true,
reason: 'mentioned_in_post',
createdAt: '2019-10-29T15:36:02.106Z',
from: {
__typename: 'Post',
...post,
},
__typename: 'NOTIFIED',
index: 9,
},
{
read: false,
reason: 'commented_on_post',
createdAt: '2019-10-29T15:38:25.199Z',
from: {
__typename: 'Comment',
id: 'b6b38937-3efc-4d5e-b12c-549e4d6551a5',
createdAt: '2019-10-29T15:38:25.184Z',
updatedAt: '2019-10-29T15:38:25.184Z',
disabled: false,
deleted: false,
content:
'<p><a class="mention" href="/profile/u1" data-mention-id="u1" target="_blank">@peter-lustig</a> </p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac placerat. Tempor id eu nisl nunc mi ipsum faucibus vitae. Nibh praesent tristique magna sit amet purus gravida quis blandit. Magna eget est lorem ipsum dolor. In fermentum posuere urna nec. Eleifend donec pretium vulputate sapien nec sagittis aliquam. Augue interdum velit euismod in pellentesque. Id diam maecenas ultricies mi eget mauris pharetra. Donec pretium vulputate sapien nec. Dolor morbi non arcu risus quis varius quam quisque. Blandit turpis cursus in hac habitasse. Est ultricies integer quis auctor elit sed vulputate mi sit. Nunc consequat interdum varius sit amet mattis vulputate enim. Semper feugiat nibh sed pulvinar. Eget felis eget nunc lobortis mattis aliquam. Ultrices vitae auctor eu augue. Tellus molestie nunc non blandit massa enim nec dui. Pharetra massa massa ultricies mi quis hendrerit dolor. Nisl suscipit adipiscing bibendum est ultricies integer.</p>',
contentExcerpt:
'<p><a href="/profile/u1" target="_blank">@peter-lustig</a> </p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra …</p>',
...post,
author: user,
},
__typename: 'NOTIFIED',
index: 1,
},
{
read: false,
reason: 'mentioned_in_comment',
createdAt: '2019-10-29T15:38:13.422Z',
from: {
__typename: 'Comment',
id: 'b91f4d4d-b178-4e42-9764-7fbcbf097f4c',
createdAt: '2019-10-29T15:38:13.41Z',
updatedAt: '2019-10-29T15:38:13.41Z',
disabled: false,
deleted: false,
content:
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac placerat. Tempor id eu nisl nunc mi ipsum faucibus vitae. Nibh praesent tristique magna sit amet purus gravida quis blandit. Magna eget est lorem ipsum dolor. In fermentum posuere urna nec. Eleifend donec pretium vulputate sapien nec sagittis aliquam. Augue interdum velit euismod in pellentesque. Id diam maecenas ultricies mi eget mauris pharetra. Donec pretium vulputate sapien nec. Dolor morbi non arcu risus quis varius quam quisque. Blandit turpis cursus in hac habitasse. Est ultricies integer quis auctor elit sed vulputate mi sit. Nunc consequat interdum varius sit amet mattis vulputate enim. Semper feugiat nibh sed pulvinar. Eget felis eget nunc lobortis mattis aliquam. Ultrices vitae auctor eu augue. Tellus molestie nunc non blandit massa enim nec dui. Pharetra massa massa ultricies mi quis hendrerit dolor. Nisl suscipit adipiscing bibendum est ultricies integer.</p><p><a class="mention" href="/profile/u1" data-mention-id="u1" target="_blank">@peter-lustig</a> </p><p></p>',
contentExcerpt:
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac …</p>',
...post,
author: user,
},
__typename: 'NOTIFIED',
index: 2,
},
]
storiesOf('NotificationsTable', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('with notifications', () => ({
components: { NotificationsTable },
store: helpers.store,
data: () => ({
notifications,
}),
methods: {
markNotificationAsRead: action('markNotificationAsRead'),
},
template: `<notifications-table
@markNotificationAsRead="markNotificationAsRead"
:notifications="notifications"
/>`,
}))
.add('without notifications', () => ({
components: { NotificationsTable },
store: helpers.store,
template: `<notifications-table />`,
}))

View File

@ -0,0 +1,110 @@
<template>
<ds-table v-if="notifications && notifications.length" :data="notifications" :fields="fields">
<template #icon="scope">
<ds-icon
v-if="scope.row.from.post"
name="comment"
v-tooltip="{ content: $t('notifications.comment'), placement: 'right' }"
/>
<ds-icon
v-else
name="bookmark"
v-tooltip="{ content: $t('notifications.post'), placement: 'right' }"
/>
</template>
<template #user="scope">
<ds-space margin-bottom="base">
<client-only>
<hc-user
:user="scope.row.from.author"
:date-time="scope.row.from.createdAt"
:trunc="35"
:class="{ 'notification-status': scope.row.read }"
/>
</client-only>
</ds-space>
<ds-text :class="{ 'notification-status': scope.row.read, reason: true }">
{{ $t(`notifications.reason.${scope.row.reason}`) }}
</ds-text>
</template>
<template #post="scope">
<nuxt-link
class="notification-mention-post"
:class="{ 'notification-status': scope.row.read }"
:to="{
name: 'post-id-slug',
params: params(scope.row.from),
hash: hashParam(scope.row.from),
}"
@click.native="markNotificationAsRead(scope.row.from.id)"
>
<b>{{ scope.row.from.title || scope.row.from.post.title | truncate(50) }}</b>
</nuxt-link>
</template>
<template #content="scope">
<b :class="{ 'notification-status': scope.row.read }">
{{ scope.row.from.contentExcerpt | removeHtml }}
</b>
</template>
</ds-table>
<hc-empty v-else icon="alert" :message="$t('notifications.empty')" />
</template>
<script>
import HcUser from '~/components/User/User'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {
HcUser,
HcEmpty,
},
props: {
notifications: { type: Array, default: () => [] },
},
computed: {
fields() {
return {
icon: {
label: ' ',
width: '5',
},
user: {
label: this.$t('notifications.user'),
width: '45%',
},
post: {
label: this.$t('notifications.post'),
width: '25%',
},
content: {
label: this.$t('notifications.content'),
width: '25%',
},
}
},
},
methods: {
isComment(notificationSource) {
return notificationSource.__typename === 'Comment'
},
params(notificationSource) {
const post = this.isComment(notificationSource) ? notificationSource.post : notificationSource
return {
id: post.id,
slug: post.slug,
}
},
hashParam(notificationSource) {
return this.isComment(notificationSource) ? `#commentId-${notificationSource.id}` : ''
},
markNotificationAsRead(notificationSourceId) {
this.$emit('markNotificationAsRead', notificationSourceId)
},
},
}
</script>
<style lang="scss">
.notification-status {
opacity: $opacity-soft;
}
</style>

View File

@ -0,0 +1,72 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import Paginate from './Paginate'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('Paginate.vue', () => {
let propsData, wrapper, nextButton, backButton
beforeEach(() => {
propsData = {}
})
const Wrapper = () => {
return mount(Paginate, { propsData, localVue })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('next button', () => {
beforeEach(() => {
propsData.hasNext = true
wrapper = Wrapper()
nextButton = wrapper.findAll('.ds-button').at(0)
})
it('is disabled by default', () => {
propsData = {}
wrapper = Wrapper()
nextButton = wrapper.findAll('.ds-button').at(0)
expect(nextButton.attributes().disabled).toEqual('disabled')
})
it('is not disabled if hasNext is true', () => {
expect(nextButton.attributes().disabled).toBeUndefined()
})
it('emits next when clicked', async () => {
await nextButton.trigger('click')
expect(wrapper.emitted().next).toHaveLength(1)
})
})
describe('back button', () => {
beforeEach(() => {
propsData.hasPrevious = true
wrapper = Wrapper()
backButton = wrapper.findAll('.ds-button').at(1)
})
it('is disabled by default', () => {
propsData = {}
wrapper = Wrapper()
backButton = wrapper.findAll('.ds-button').at(1)
expect(backButton.attributes().disabled).toEqual('disabled')
})
it('is not disabled if hasPrevious is true', () => {
expect(backButton.attributes().disabled).toBeUndefined()
})
it('emits back when clicked', async () => {
await backButton.trigger('click')
expect(wrapper.emitted().back).toHaveLength(1)
})
})
})
})

View File

@ -0,0 +1,28 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import Paginate from '~/components/Paginate/Paginate'
import helpers from '~/storybook/helpers'
helpers.init()
storiesOf('Paginate', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('basic pagination', () => ({
components: { Paginate },
data: () => ({
hasNext: true,
hasPrevious: false,
}),
methods: {
back: action('back'),
next: action('next'),
},
template: `<paginate
:hasNext="hasNext"
:hasPrevious="hasPrevious"
@back="back"
@next="next"
/>`,
}))

View File

@ -0,0 +1,26 @@
<template>
<ds-flex direction="row-reverse">
<ds-flex-item width="50px">
<ds-button @click="next" :disabled="!hasNext" icon="arrow-right" primary />
</ds-flex-item>
<ds-flex-item width="50px">
<ds-button @click="back" :disabled="!hasPrevious" icon="arrow-left" primary />
</ds-flex-item>
</ds-flex>
</template>
<script>
export default {
props: {
hasNext: { type: Boolean, default: false },
hasPrevious: { type: Boolean, default: false },
},
methods: {
back() {
this.$emit('back')
},
next() {
this.$emit('next')
},
},
}
</script>

View File

@ -84,5 +84,18 @@ describe('Request', () => {
}) })
}) })
}) })
describe('capital letters in a gmail address', () => {
beforeEach(async () => {
wrapper = Wrapper()
wrapper.find('input#email').setValue('mAiL@gmail.com')
await wrapper.find('form').trigger('submit')
})
it('normalizes email to lower case letters', () => {
const expected = expect.objectContaining({ variables: { email: 'mail@gmail.com' } })
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
})
}) })
}) })

View File

@ -46,6 +46,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { SweetalertIcon } from 'vue-sweetalert-icons' import { SweetalertIcon } from 'vue-sweetalert-icons'
import { normalizeEmail } from 'validator'
export default { export default {
components: { components: {
@ -68,8 +69,11 @@ export default {
} }
}, },
computed: { computed: {
email() {
return normalizeEmail(this.formData.email)
},
submitMessage() { submitMessage() {
const { email } = this.formData const { email } = this
return this.$t('components.password-reset.request.form.submitted', { email }) return this.$t('components.password-reset.request.form.submitted', { email })
}, },
}, },
@ -86,9 +90,8 @@ export default {
requestPasswordReset(email: $email) requestPasswordReset(email: $email)
} }
` `
const { email } = this.formData
try { try {
const { email } = this
await this.$apollo.mutate({ mutation, variables: { email } }) await this.$apollo.mutate({ mutation, variables: { email } })
this.submitted = true this.submitted = true

View File

@ -5,7 +5,7 @@ import helpers from '~/storybook/helpers'
helpers.init() helpers.init()
const post = { export const post = {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
title: 'Very nice Post Title', title: 'Very nice Post Title',
contentExcerpt: '<p>My post content</p>', contentExcerpt: '<p>My post content</p>',

View File

@ -0,0 +1,65 @@
import { mount } from '@vue/test-utils'
import ProgressBar from './ProgressBar'
describe('ProgessBar.vue', () => {
let propsData
beforeEach(() => {
propsData = {
goal: 50000,
progress: 10000,
}
})
const Wrapper = () => mount(ProgressBar, { propsData })
describe('given only goal and progress', () => {
it('renders no title', () => {
expect(
Wrapper()
.find('.progress-bar__title')
.exists(),
).toBe(false)
})
it('renders no label', () => {
expect(
Wrapper()
.find('.progress-bar__label')
.exists(),
).toBe(false)
})
it('calculates the progress bar width as a percentage of the goal', () => {
expect(Wrapper().vm.progressBarWidth).toBe('width: 20%;')
})
})
describe('given a title', () => {
beforeEach(() => {
propsData.title = 'This is progress'
})
it('renders the title', () => {
expect(
Wrapper()
.find('.progress-bar__title')
.text(),
).toBe('This is progress')
})
})
describe('given a label', () => {
beforeEach(() => {
propsData.label = 'Going well'
})
it('renders the label', () => {
expect(
Wrapper()
.find('.progress-bar__label')
.text(),
).toBe('Going well')
})
})
})

View File

@ -0,0 +1,97 @@
<template>
<div class="progress-bar">
<div class="progress-bar__goal"></div>
<div class="progress-bar__progress" :style="progressBarWidth"></div>
<h4 v-if="title" class="progress-bar__title">{{ title }}</h4>
<span v-if="label" class="progress-bar__label">{{ label }}</span>
</div>
</template>
<script>
export default {
props: {
goal: {
type: Number,
required: true,
},
label: {
type: String,
},
progress: {
type: Number,
required: true,
},
title: {
type: String,
},
},
computed: {
progressBarWidth() {
return `width: ${(this.progress / this.goal) * 100}%;`
},
},
}
</script>
<style lang="scss">
.progress-bar {
position: relative;
height: 100%;
width: 240px;
margin-right: $space-x-small;
@media (max-width: 680px) {
width: 180px;
}
@media (max-width: 546px) {
flex-basis: 50%;
flex-grow: 1;
}
}
.progress-bar__title {
position: absolute;
top: -2px;
left: $space-xx-small;
margin: 0;
@media (max-width: 546px) {
top: $space-xx-small;
}
@media (max-width: 350px) {
font-size: $font-size-small;
}
}
.progress-bar__goal {
position: absolute;
bottom: 0;
left: 0;
height: 37.5px; // styleguide-button-size
width: 100%;
background-color: $color-neutral-100;
border-radius: $border-radius-base;
}
.progress-bar__progress {
position: absolute;
bottom: 1px;
left: 0;
height: 35.5px; // styleguide-button-size - 2px border
max-width: 100%;
background-color: $color-yellow;
border-radius: $border-radius-base;
}
.progress-bar__label {
position: absolute;
top: 50%;
left: $space-xx-small;
@media (max-width: 350px) {
font-size: $font-size-small;
}
}
</style>

View File

@ -48,7 +48,7 @@ describe('Signup', () => {
describe('submit', () => { describe('submit', () => {
beforeEach(async () => { beforeEach(async () => {
wrapper = Wrapper() wrapper = Wrapper()
wrapper.find('input#email').setValue('mail@example.org') wrapper.find('input#email').setValue('mAIL@exAMPLE.org')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
}) })
@ -59,7 +59,7 @@ describe('Signup', () => {
it('delivers email to backend', () => { it('delivers email to backend', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
variables: { email: 'mail@example.org', token: null }, variables: { email: 'mAIL@exAMPLE.org', token: null },
}) })
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
}) })

View File

@ -1,5 +1,5 @@
<template> <template>
<ds-space v-if="!success && !error" margin="large"> <ds-space v-if="!data && !error" margin="large">
<ds-form <ds-form
@input="handleInput" @input="handleInput"
@input-valid="handleInputValid" @input-valid="handleInputValid"
@ -100,13 +100,13 @@ export default {
}, },
}, },
disabled: true, disabled: true,
success: false, data: null,
error: null, error: null,
} }
}, },
computed: { computed: {
submitMessage() { submitMessage() {
const { email } = this.formData const { email } = this.data.Signup
return this.$t('components.registration.signup.form.success', { email }) return this.$t('components.registration.signup.form.success', { email })
}, },
}, },
@ -119,15 +119,14 @@ export default {
}, },
async handleSubmit() { async handleSubmit() {
const mutation = this.token ? SignupByInvitationMutation : SignupMutation const mutation = this.token ? SignupByInvitationMutation : SignupMutation
const { email } = this.formData
const { token } = this const { token } = this
const { email } = this.formData
try { try {
await this.$apollo.mutate({ mutation, variables: { email, token } }) const response = await this.$apollo.mutate({ mutation, variables: { email, token } })
this.success = true this.data = response.data
setTimeout(() => { setTimeout(() => {
this.$emit('submit', { email }) this.$emit('submit', { email: this.data.Signup.email })
}, 3000) }, 3000)
} catch (err) { } catch (err) {
const { message } = err const { message } = err

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