mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge branch 'master' into 1747-show-shouts
This commit is contained in:
commit
6213a25355
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -6,11 +6,19 @@ title: 🚀 [Feature]
|
||||
---
|
||||
|
||||
## :rocket: Feature
|
||||
<!-- Describe the Feature. Use Screenshots if possible. -->
|
||||
<!-- Give a short summary of the Feature. Use Screenshots if you want. -->
|
||||
|
||||
### User Problem
|
||||
<!-- Which problem is this solving? Why do you think this is important? Who will benefit from it and how? -->
|
||||
|
||||
### Implementation
|
||||
<!-- How do you think this feature should be implemented? How will it be used? Where in the network should it be located? Which steps and screens are involved? -->
|
||||
|
||||
### Design & Layout
|
||||
<!-- Attach Screenshots and Drawings. -->
|
||||
<!-- Attach Screenshots and Sketches to illustrate your idea. -->
|
||||
|
||||
### Validation
|
||||
<!-- How can we make sure that this feature indeed solves the above problem? How do we know if it has been accepted by the users of the network, once released? -->
|
||||
|
||||
### Additional context
|
||||
<!-- Add any other context or screenshots about the feature request here.-->
|
||||
<!-- Add other context or background about the feature request here.-->
|
||||
|
||||
@ -27,7 +27,7 @@ script:
|
||||
- echo "TRAVIS_BRANCH=$TRAVIS_BRANCH, PR=$PR, BRANCH=$BRANCH"
|
||||
# Backend
|
||||
- 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:reset
|
||||
# ActivityPub cucumber testing temporarily disabled because it's too buggy
|
||||
@ -37,7 +37,6 @@ script:
|
||||
# Frontend
|
||||
- docker-compose exec webapp yarn run lint
|
||||
- docker-compose exec webapp yarn run test --ci --verbose=false --coverage
|
||||
- docker-compose exec -d backend yarn run test:before:seeder
|
||||
# Fullstack
|
||||
- docker-compose down
|
||||
- docker-compose -f docker-compose.yml up -d
|
||||
|
||||
132
CHANGELOG.md
132
CHANGELOG.md
@ -4,7 +4,133 @@ 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).
|
||||
|
||||
#### [0.1.8](https://github.com/Human-Connection/Human-Connection/compare/0.1.7...0.1.8)
|
||||
#### [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)
|
||||
|
||||
> 4 November 2019
|
||||
|
||||
- Refactor and tidy up crop image implementation [`#1956`](https://github.com/Human-Connection/Human-Connection/pull/1956)
|
||||
- 🍰 First Implementation Of Filtering Posts By Language [`#2059`](https://github.com/Human-Connection/Human-Connection/pull/2059)
|
||||
- build(deps-dev): bump cypress from 3.5.0 to 3.6.0 [`#2105`](https://github.com/Human-Connection/Human-Connection/pull/2105)
|
||||
- Fix: Poll Interval [`#2108`](https://github.com/Human-Connection/Human-Connection/pull/2108)
|
||||
- build(deps-dev): bump cypress-plugin-retries from 1.3.0 to 1.4.0 [`#2104`](https://github.com/Human-Connection/Human-Connection/pull/2104)
|
||||
- build(deps): bump metascraper-title from 5.7.14 to 5.7.17 in /backend [`#2082`](https://github.com/Human-Connection/Human-Connection/pull/2082)
|
||||
- build(deps): bump metascraper-publisher from 5.7.14 to 5.7.17 in /backend [`#2098`](https://github.com/Human-Connection/Human-Connection/pull/2098)
|
||||
- build(deps): bump metascraper-description from 5.7.14 to 5.7.17 in /backend [`#2096`](https://github.com/Human-Connection/Human-Connection/pull/2096)
|
||||
- build(deps): bump metascraper-audio from 5.7.14 to 5.7.17 in /backend [`#2097`](https://github.com/Human-Connection/Human-Connection/pull/2097)
|
||||
- build(deps): bump metascraper-url from 5.7.14 to 5.7.17 in /backend [`#2086`](https://github.com/Human-Connection/Human-Connection/pull/2086)
|
||||
- build(deps): bump metascraper-image from 5.7.6 to 5.7.17 in /backend [`#2090`](https://github.com/Human-Connection/Human-Connection/pull/2090)
|
||||
- deleted posts no longer displayed in user profile [`#2093`](https://github.com/Human-Connection/Human-Connection/pull/2093)
|
||||
- Send only one notification for mention and comment [`#2062`](https://github.com/Human-Connection/Human-Connection/pull/2062)
|
||||
- build(deps): bump metascraper-date from 5.7.14 to 5.7.17 in /backend [`#2089`](https://github.com/Human-Connection/Human-Connection/pull/2089)
|
||||
- build(deps): bump @nuxtjs/apollo from 4.0.0-rc16 to 4.0.0-rc17 in /webapp [`#2088`](https://github.com/Human-Connection/Human-Connection/pull/2088)
|
||||
- build(deps): bump metascraper-lang from 5.7.14 to 5.7.17 in /backend [`#2087`](https://github.com/Human-Connection/Human-Connection/pull/2087)
|
||||
- build(deps): bump metascraper-video from 5.7.14 to 5.7.17 in /backend [`#2084`](https://github.com/Human-Connection/Human-Connection/pull/2084)
|
||||
- build(deps): bump metascraper-soundcloud from 5.7.14 to 5.7.17 in /backend [`#2081`](https://github.com/Human-Connection/Human-Connection/pull/2081)
|
||||
- build(deps-dev): bump auto-changelog from 1.16.1 to 1.16.2 [`#2085`](https://github.com/Human-Connection/Human-Connection/pull/2085)
|
||||
- build(deps): bump metascraper-logo from 5.7.14 to 5.7.17 in /backend [`#2083`](https://github.com/Human-Connection/Human-Connection/pull/2083)
|
||||
- build(deps): bump metascraper-youtube from 5.7.14 to 5.7.17 in /backend [`#2080`](https://github.com/Human-Connection/Human-Connection/pull/2080)
|
||||
- build(deps): bump metascraper-author from 5.7.14 to 5.7.17 in /backend [`#2079`](https://github.com/Human-Connection/Human-Connection/pull/2079)
|
||||
- build(deps): bump date-fns from 2.5.0 to 2.6.0 in /webapp [`#2007`](https://github.com/Human-Connection/Human-Connection/pull/2007)
|
||||
- build(deps-dev): bump eslint from 6.5.1 to 6.6.0 in /backend [`#2071`](https://github.com/Human-Connection/Human-Connection/pull/2071)
|
||||
- build(deps): bump node from 12.13.0-alpine to 13.0.1-alpine in /webapp [`#2019`](https://github.com/Human-Connection/Human-Connection/pull/2019)
|
||||
- [FIX # 2058] Typo Fixed. Password -> Passwort [`#2060`](https://github.com/Human-Connection/Human-Connection/pull/2060)
|
||||
- build(deps-dev): bump eslint-config-prettier from 6.4.0 to 6.5.0 in /backend [`#2064`](https://github.com/Human-Connection/Human-Connection/pull/2064)
|
||||
- build(deps): bump date-fns from 2.5.1 to 2.6.0 in /backend [`#2010`](https://github.com/Human-Connection/Human-Connection/pull/2010)
|
||||
- build(deps-dev): bump apollo-server-testing from 2.9.6 to 2.9.7 in /backend [`#1984`](https://github.com/Human-Connection/Human-Connection/pull/1984)
|
||||
- build(deps): bump metascraper-date from 5.7.6 to 5.7.14 in /backend [`#2070`](https://github.com/Human-Connection/Human-Connection/pull/2070)
|
||||
- build(deps): bump metascraper-video from 5.7.6 to 5.7.14 in /backend [`#2072`](https://github.com/Human-Connection/Human-Connection/pull/2072)
|
||||
- build(deps-dev): bump eslint-plugin-jest from 22.20.0 to 23.0.2 in /backend [`#2069`](https://github.com/Human-Connection/Human-Connection/pull/2069)
|
||||
- build(deps): bump metascraper-audio from 5.7.6 to 5.7.14 in /backend [`#2068`](https://github.com/Human-Connection/Human-Connection/pull/2068)
|
||||
- build(deps-dev): bump eslint-plugin-jest from 23.0.0 to 23.0.2 in /webapp [`#2066`](https://github.com/Human-Connection/Human-Connection/pull/2066)
|
||||
- build(deps-dev): bump cucumber from 6.0.2 to 6.0.3 in /backend [`#2065`](https://github.com/Human-Connection/Human-Connection/pull/2065)
|
||||
- build(deps): bump metascraper-logo from 5.7.6 to 5.7.14 in /backend [`#2039`](https://github.com/Human-Connection/Human-Connection/pull/2039)
|
||||
- build(deps): bump metascraper-url from 5.7.6 to 5.7.14 in /backend [`#2053`](https://github.com/Human-Connection/Human-Connection/pull/2053)
|
||||
- build(deps): bump metascraper-youtube from 5.7.6 to 5.7.14 in /backend [`#2054`](https://github.com/Human-Connection/Human-Connection/pull/2054)
|
||||
- build(deps): bump neo4j-graphql-js from 2.7.2 to 2.8.0 in /backend [`#2036`](https://github.com/Human-Connection/Human-Connection/pull/2036)
|
||||
- build(deps): bump metascraper-soundcloud from 5.7.7 to 5.7.14 in /backend [`#2052`](https://github.com/Human-Connection/Human-Connection/pull/2052)
|
||||
- build(deps): bump metascraper-author from 5.7.6 to 5.7.14 in /backend [`#2055`](https://github.com/Human-Connection/Human-Connection/pull/2055)
|
||||
- build(deps-dev): bump eslint-plugin-jest from 22.20.0 to 23.0.0 in /webapp [`#2051`](https://github.com/Human-Connection/Human-Connection/pull/2051)
|
||||
- build(deps): bump metascraper-title from 5.7.6 to 5.7.14 in /backend [`#2050`](https://github.com/Human-Connection/Human-Connection/pull/2050)
|
||||
- build(deps-dev): bump eslint-config-prettier from 6.4.0 to 6.5.0 in /webapp [`#2049`](https://github.com/Human-Connection/Human-Connection/pull/2049)
|
||||
- build(deps): bump metascraper-description from 5.7.6 to 5.7.14 in /backend [`#2038`](https://github.com/Human-Connection/Human-Connection/pull/2038)
|
||||
- build(deps-dev): bump node-sass from 4.12.0 to 4.13.0 in /webapp [`#2037`](https://github.com/Human-Connection/Human-Connection/pull/2037)
|
||||
- build(deps): bump metascraper-publisher from 5.7.6 to 5.7.14 in /backend [`#2033`](https://github.com/Human-Connection/Human-Connection/pull/2033)
|
||||
- build(deps): bump apollo-server from 2.9.6 to 2.9.7 in /backend [`#2004`](https://github.com/Human-Connection/Human-Connection/pull/2004)
|
||||
- fix #1993 [`#2043`](https://github.com/Human-Connection/Human-Connection/pull/2043)
|
||||
- Update to version 0.1.8 [`#2032`](https://github.com/Human-Connection/Human-Connection/pull/2032)
|
||||
- build(deps): bump graphql-shield from 6.1.0 to 7.0.0 in /backend [`#2035`](https://github.com/Human-Connection/Human-Connection/pull/2035)
|
||||
- build(deps): bump metascraper-lang from 5.7.6 to 5.7.14 in /backend [`#2034`](https://github.com/Human-Connection/Human-Connection/pull/2034)
|
||||
- change Changes & History to Changes [`#2030`](https://github.com/Human-Connection/Human-Connection/pull/2030)
|
||||
- chnage Änderungen & Verlauf zu Änderungen [`#2029`](https://github.com/Human-Connection/Human-Connection/pull/2029)
|
||||
- build(deps): bump @nuxtjs/apollo from 4.0.0-rc15 to 4.0.0-rc16 in /webapp [`#1990`](https://github.com/Human-Connection/Human-Connection/pull/1990)
|
||||
- Merge pull request #2043 from Human-Connection/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)
|
||||
- build(deps-dev): bump eslint-plugin-jest in /backend [`6c1bd53`](https://github.com/Human-Connection/Human-Connection/commit/6c1bd535ac482eb0a05d21e227a476800717a19e)
|
||||
- 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)
|
||||
|
||||
> 25 October 2019
|
||||
|
||||
@ -23,8 +149,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- build(deps-dev): bump @vue/cli-shared-utils from 4.0.4 to 4.0.5 in /webapp [`#2002`](https://github.com/Human-Connection/Human-Connection/pull/2002)
|
||||
- Update to version 0.1.7 [`#2015`](https://github.com/Human-Connection/Human-Connection/pull/2015)
|
||||
- Update to version 0.1.8 [`d45264b`](https://github.com/Human-Connection/Human-Connection/commit/d45264b3afa1557c2205e7ca1b77c778ee37ab5a)
|
||||
- build(deps): bump @nuxtjs/apollo in /webapp [`26c21b5`](https://github.com/Human-Connection/Human-Connection/commit/26c21b5b76c96206d98ff6bbfdbd1ca973ffcd4f)
|
||||
- build(deps-dev): bump @storybook/addon-actions in /webapp [`7e95d37`](https://github.com/Human-Connection/Human-Connection/commit/7e95d376a311a5ede6351d577d30e25aea9cb65d)
|
||||
- new terms and conditions fixed [`ff8680f`](https://github.com/Human-Connection/Human-Connection/commit/ff8680ff862846cf619423007809ea3139cada96)
|
||||
|
||||
#### [0.1.7](https://github.com/Human-Connection/Human-Connection/compare/0.1.6...0.1.7)
|
||||
|
||||
@ -131,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)
|
||||
- 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)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
|
||||
142
CONTRIBUTING.md
142
CONTRIBUTING.md
@ -1,79 +1,101 @@
|
||||
# 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
|
||||
|
||||
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):
|
||||
|
||||

|
||||
|
||||
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 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
|
||||
* "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
|
||||
* AgileVentures run open pairing sessions at 10:30am UTC each week on Tuesdays and Thursdays
|
||||
* Core team
|
||||
* all the people who are hired by HC non-profit corporation
|
||||
* 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/).
|
||||
* 9 people
|
||||
* 2 core developers \(Robert [@roschaefer](https://github.com/roschaefer) and Greg [@appinteractive](https://github.com/appinteractive)\)
|
||||
* 3 marketeers Jasi, Dennis and Sensi
|
||||
* Hardy doing business development
|
||||
* Martin head of IT and previously data protection officer
|
||||
* Victor doing accounting and controlling
|
||||
* Nicolas is the community manager \(reviews content in the network\) reflects community opinion back to the core team
|
||||
* when can folks pair with Robert
|
||||
* 10am UTC until 5pm UTC every working day
|
||||
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 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.
|
||||
|
||||
This is how we solve bugs and implement features, step by step:
|
||||
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.
|
||||
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.)
|
||||
3. We make sure we understand the issue in detail – what problem is it solving and how should it be implemented?
|
||||
4. We assign ourselves to the issue and move it to `In Progress` on [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f).
|
||||
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.
|
||||
6. When questions come up we clarify them with the team (directly in the issue on Github).
|
||||
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).
|
||||
8. We then incorporate the suggestions from the reviews into our work and once it has been approved it can be merged into master!
|
||||
|
||||
Every pull request needs to:
|
||||
* 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)
|
||||
* include tests for the code that is added or changed
|
||||
* 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 Monday–Friday 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
|
||||
|
||||
We practise [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that:
|
||||
|
||||
* anyone can start working on anyone elses code
|
||||
* 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
|
||||
* developers can make contributions to other people's PRs (after checking in with them)
|
||||
* we avoid blocking because someone else isn't working, so we sometimes take over PRs from other developers
|
||||
* 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
|
||||
* 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\)
|
||||
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!
|
||||
|
||||
13
SUMMARY.md
13
SUMMARY.md
@ -7,15 +7,10 @@
|
||||
* [Backend](backend/README.md)
|
||||
* [GraphQL](backend/graphql.md)
|
||||
* [Webapp](webapp/README.md)
|
||||
* [COMPONENTS](webapp/components.md)
|
||||
* [PLUGINS](webapp/plugins.md)
|
||||
* [STORE](webapp/store.md)
|
||||
* [PAGES](webapp/pages.md)
|
||||
* [ASSETS](webapp/assets.md)
|
||||
* [LAYOUTS](webapp/layouts.md)
|
||||
* [Styleguide](webapp/styleguide.md)
|
||||
* [STATIC](webapp/static.md)
|
||||
* [MIDDLEWARE](webapp/middleware.md)
|
||||
* [Components](webapp/components.md)
|
||||
* [HTML](webapp/html.md)
|
||||
* [SCSS](webapp/scss.md)
|
||||
* [Vue](webapp/vue.md)
|
||||
* [Testing Guide](testing.md)
|
||||
* [End-to-end tests](cypress/README.md)
|
||||
* [Frontend tests](webapp/testing.md)
|
||||
|
||||
@ -9,16 +9,9 @@
|
||||
"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",
|
||||
"lint": "eslint src --config .eslintrc.js",
|
||||
"jest": "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": "jest --forceExit --detectOpenHandles --runInBand",
|
||||
"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:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --",
|
||||
"db:reset": "babel-node src/seed/reset-db.js",
|
||||
"db:seed": "babel-node src/seed/seed-db.js"
|
||||
},
|
||||
@ -42,7 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/joi": "^16.1.7",
|
||||
"@sentry/node": "^5.7.1",
|
||||
"@sentry/node": "^5.8.0",
|
||||
"apollo-cache-inmemory": "~1.6.3",
|
||||
"apollo-client": "~2.6.4",
|
||||
"apollo-link-context": "~1.0.19",
|
||||
@ -54,7 +47,7 @@
|
||||
"cheerio": "~1.0.0-rc.3",
|
||||
"cors": "~2.8.5",
|
||||
"cross-env": "~6.0.3",
|
||||
"date-fns": "2.6.0",
|
||||
"date-fns": "2.7.0",
|
||||
"debug": "~4.1.1",
|
||||
"dotenv": "~8.2.0",
|
||||
"express": "^4.17.1",
|
||||
@ -62,29 +55,29 @@
|
||||
"graphql": "^14.5.8",
|
||||
"graphql-custom-directives": "~0.2.14",
|
||||
"graphql-iso-date": "~3.6.1",
|
||||
"graphql-middleware": "~4.0.1",
|
||||
"graphql-middleware": "~4.0.2",
|
||||
"graphql-middleware-sentry": "^3.2.1",
|
||||
"graphql-shield": "~7.0.0",
|
||||
"graphql-shield": "~7.0.2",
|
||||
"graphql-tag": "~2.10.1",
|
||||
"helmet": "~3.21.2",
|
||||
"jsonwebtoken": "~8.5.1",
|
||||
"linkifyjs": "~2.1.8",
|
||||
"lodash": "~4.17.14",
|
||||
"merge-graphql-schemas": "^1.7.0",
|
||||
"merge-graphql-schemas": "^1.7.3",
|
||||
"metascraper": "^4.10.3",
|
||||
"metascraper-audio": "^5.7.14",
|
||||
"metascraper-audio": "^5.7.17",
|
||||
"metascraper-author": "^5.7.17",
|
||||
"metascraper-clearbit-logo": "^5.3.0",
|
||||
"metascraper-date": "^5.7.17",
|
||||
"metascraper-description": "^5.7.14",
|
||||
"metascraper-image": "^5.7.6",
|
||||
"metascraper-description": "^5.7.17",
|
||||
"metascraper-image": "^5.7.17",
|
||||
"metascraper-lang": "^5.7.17",
|
||||
"metascraper-lang-detector": "^4.8.5",
|
||||
"metascraper-logo": "^5.7.17",
|
||||
"metascraper-publisher": "^5.7.14",
|
||||
"metascraper-publisher": "^5.7.17",
|
||||
"metascraper-soundcloud": "^5.7.17",
|
||||
"metascraper-title": "^5.7.14",
|
||||
"metascraper-url": "^5.7.14",
|
||||
"metascraper-title": "^5.7.17",
|
||||
"metascraper-url": "^5.7.17",
|
||||
"metascraper-video": "^5.7.17",
|
||||
"metascraper-youtube": "^5.7.17",
|
||||
"minimatch": "^3.0.4",
|
||||
@ -101,16 +94,17 @@
|
||||
"slug": "~1.1.0",
|
||||
"trunc-html": "~1.1.2",
|
||||
"uuid": "~3.3.3",
|
||||
"validator": "^12.0.0",
|
||||
"wait-on": "~3.3.0",
|
||||
"xregexp": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "~7.6.4",
|
||||
"@babel/core": "~7.6.4",
|
||||
"@babel/node": "~7.6.3",
|
||||
"@babel/cli": "~7.7.0",
|
||||
"@babel/core": "~7.7.2",
|
||||
"@babel/node": "~7.7.0",
|
||||
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
|
||||
"@babel/preset-env": "~7.6.3",
|
||||
"@babel/register": "~7.6.2",
|
||||
"@babel/preset-env": "~7.7.1",
|
||||
"@babel/register": "~7.7.0",
|
||||
"apollo-server-testing": "~2.9.7",
|
||||
"babel-core": "~7.0.0-0",
|
||||
"babel-eslint": "~10.0.3",
|
||||
@ -121,12 +115,11 @@
|
||||
"eslint-config-prettier": "~6.5.0",
|
||||
"eslint-config-standard": "~14.1.0",
|
||||
"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-prettier": "~3.1.1",
|
||||
"eslint-plugin-promise": "~4.2.1",
|
||||
"eslint-plugin-standard": "~4.0.1",
|
||||
"graphql-request": "~1.8.2",
|
||||
"jest": "~24.9.0",
|
||||
"nodemon": "~1.19.4",
|
||||
"prettier": "~1.18.2",
|
||||
|
||||
@ -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
|
||||
// highlighting of `gql` template strings in certain editors.
|
||||
export function gql(strings) {
|
||||
|
||||
@ -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 data = {
|
||||
@ -32,6 +32,7 @@ const createLocation = async (session, mapboxData) => {
|
||||
nameES: mapboxData.text_es,
|
||||
namePT: mapboxData.text_pt,
|
||||
namePL: mapboxData.text_pl,
|
||||
nameRU: mapboxData.text_ru,
|
||||
type: mapboxData.id.split('.')[0].toLowerCase(),
|
||||
lat: mapboxData.center && mapboxData.center.length ? mapboxData.center[0] : null,
|
||||
lng: mapboxData.center && mapboxData.center.length ? mapboxData.center[1] : null,
|
||||
@ -48,6 +49,7 @@ const createLocation = async (session, mapboxData) => {
|
||||
'l.nameES = $nameES, ' +
|
||||
'l.namePT = $namePT, ' +
|
||||
'l.namePL = $namePL, ' +
|
||||
'l.nameRU = $nameRU, ' +
|
||||
'l.type = $type'
|
||||
|
||||
if (data.lat && data.lng) {
|
||||
@ -56,6 +58,7 @@ const createLocation = async (session, mapboxData) => {
|
||||
query += ' RETURN l.id'
|
||||
|
||||
await session.run(query, data)
|
||||
session.close()
|
||||
}
|
||||
|
||||
const createOrUpdateLocations = async (userId, locationName, driver) => {
|
||||
|
||||
@ -1,5 +1,21 @@
|
||||
import extractMentionedUsers from './mentions/extractMentionedUsers'
|
||||
|
||||
const postAuthorOfComment = async (comment, { context }) => {
|
||||
const session = context.driver.session()
|
||||
const cypherFindUser = `
|
||||
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
|
||||
RETURN user { .id }
|
||||
`
|
||||
const result = await session.run(cypherFindUser, {
|
||||
commentId: comment.id,
|
||||
})
|
||||
session.close()
|
||||
const [postAuthor] = await result.records.map(record => {
|
||||
return record.get('user')
|
||||
})
|
||||
return postAuthor
|
||||
}
|
||||
|
||||
const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
|
||||
if (!idsOfUsers.length) return
|
||||
|
||||
@ -90,11 +106,13 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo
|
||||
}
|
||||
|
||||
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
|
||||
const idsOfUsers = extractMentionedUsers(args.content)
|
||||
|
||||
let idsOfUsers = extractMentionedUsers(args.content)
|
||||
const comment = await resolve(root, args, context, resolveInfo)
|
||||
|
||||
if (comment) {
|
||||
const postAuthor = await postAuthorOfComment(comment, { context })
|
||||
idsOfUsers = idsOfUsers.filter(id => id !== postAuthor.id)
|
||||
|
||||
await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
|
||||
}
|
||||
|
||||
|
||||
@ -105,6 +105,7 @@ describe('notifications', () => {
|
||||
let title
|
||||
let postContent
|
||||
let postAuthor
|
||||
|
||||
const createPostAction = async () => {
|
||||
authenticatedUser = await postAuthor.toJson()
|
||||
await mutate({
|
||||
@ -239,6 +240,7 @@ describe('notifications', () => {
|
||||
describe('mentions me in a post', () => {
|
||||
beforeEach(async () => {
|
||||
title = 'Mentioning Al Capone'
|
||||
|
||||
postContent =
|
||||
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
|
||||
})
|
||||
@ -439,7 +441,15 @@ describe('notifications', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a notification', async () => {
|
||||
it('sends only one notification with reason mentioned_in_comment', async () => {
|
||||
postAuthor = await instance.create('User', {
|
||||
id: 'MrPostAuthor',
|
||||
name: 'Mr Author',
|
||||
slug: 'mr-author',
|
||||
email: 'post-author@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
|
||||
await createCommentOnPostAction()
|
||||
const expected = expect.objectContaining({
|
||||
data: {
|
||||
@ -467,6 +477,40 @@ describe('notifications', () => {
|
||||
}),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
title = "Post where I'm the author and I get mentioned in a comment"
|
||||
postContent = 'Content of post where I get mentioned in a comment.'
|
||||
postAuthor = notifiedUser
|
||||
})
|
||||
it('sends only one notification with reason commented_on_post, no notification with reason mentioned_in_comment', async () => {
|
||||
await createCommentOnPostAction()
|
||||
const expected = expect.objectContaining({
|
||||
data: {
|
||||
notifications: [
|
||||
{
|
||||
read: false,
|
||||
createdAt: expect.any(String),
|
||||
reason: 'commented_on_post',
|
||||
from: {
|
||||
__typename: 'Comment',
|
||||
id: 'c47',
|
||||
content: commentContent,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const { query } = createTestClient(server)
|
||||
await expect(
|
||||
query({
|
||||
query: notificationQuery,
|
||||
variables: {
|
||||
read: false,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('but the author of the post blocked me', () => {
|
||||
|
||||
@ -135,6 +135,7 @@ const permissions = shield(
|
||||
blockedUsers: isAuthenticated,
|
||||
notifications: isAuthenticated,
|
||||
profilePagePosts: or(onlyEnabledContent, isModerator),
|
||||
Donations: isAuthenticated,
|
||||
},
|
||||
Mutation: {
|
||||
'*': deny,
|
||||
@ -177,6 +178,7 @@ const permissions = shield(
|
||||
VerifyEmailAddress: isAuthenticated,
|
||||
pinPost: isAdmin,
|
||||
unpinPost: isAdmin,
|
||||
UpdateDonations: isAdmin,
|
||||
},
|
||||
User: {
|
||||
email: or(isMyOwn, isAdmin),
|
||||
|
||||
@ -32,6 +32,7 @@ export default {
|
||||
Post: setDefaultFilters,
|
||||
Comment: setDefaultFilters,
|
||||
User: setDefaultFilters,
|
||||
profilePagePosts: setDefaultFilters,
|
||||
},
|
||||
Mutation: async (resolve, root, args, context, info) => {
|
||||
args.disabled = false
|
||||
|
||||
@ -72,6 +72,7 @@ const validateReport = async (resolve, root, args, context, info) => {
|
||||
submitterId: user.id,
|
||||
},
|
||||
)
|
||||
session.close()
|
||||
const [existingReportedResource] = reportQueryRes.records.map(record => {
|
||||
return {
|
||||
label: record.get('label'),
|
||||
|
||||
14
backend/src/models/Donations.js
Normal file
14
backend/src/models/Donations.js
Normal 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(),
|
||||
},
|
||||
}
|
||||
@ -12,6 +12,7 @@ module.exports = {
|
||||
nameDE: { type: 'string' },
|
||||
nameNL: { type: 'string' },
|
||||
namePL: { type: 'string' },
|
||||
nameRU: { type: 'string' },
|
||||
isIn: {
|
||||
type: 'relationship',
|
||||
relationship: 'IS_IN',
|
||||
|
||||
@ -12,4 +12,5 @@ export default {
|
||||
Category: require('./Category.js'),
|
||||
Tag: require('./Tag.js'),
|
||||
Location: require('./Location.js'),
|
||||
Donations: require('./Donations.js'),
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ export default applyScalars(
|
||||
'SocialMedia',
|
||||
'NOTIFIED',
|
||||
'REPORTED',
|
||||
'Donations',
|
||||
],
|
||||
// add 'User' here as soon as possible
|
||||
},
|
||||
@ -44,6 +45,7 @@ export default applyScalars(
|
||||
'EMOTED',
|
||||
'NOTIFIED',
|
||||
'REPORTED',
|
||||
'Donations',
|
||||
],
|
||||
// add 'User' here as soon as possible
|
||||
},
|
||||
|
||||
@ -59,6 +59,7 @@ export default {
|
||||
`,
|
||||
{ commentId: args.id },
|
||||
)
|
||||
session.close()
|
||||
const [comment] = transactionRes.records.map(record => record.get('comment').properties)
|
||||
return comment
|
||||
},
|
||||
|
||||
32
backend/src/schema/resolvers/donations.js
Normal file
32
backend/src/schema/resolvers/donations.js
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
||||
174
backend/src/schema/resolvers/donations.spec.js
Normal file
174
backend/src/schema/resolvers/donations.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -3,11 +3,14 @@ import Resolver from './helpers/Resolver'
|
||||
import existingEmailAddress from './helpers/existingEmailAddress'
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import Validator from 'neode/build/Services/Validator.js'
|
||||
import { normalizeEmail } from 'validator'
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
AddEmailAddress: async (_parent, args, context, _resolveInfo) => {
|
||||
let response
|
||||
args.email = normalizeEmail(args.email)
|
||||
|
||||
try {
|
||||
const { neode } = context
|
||||
await new Validator(neode, neode.model('UnverifiedEmailAddress'), args)
|
||||
@ -16,13 +19,13 @@ export default {
|
||||
}
|
||||
|
||||
// check email does not belong to anybody
|
||||
await existingEmailAddress(_parent, args, context)
|
||||
await existingEmailAddress({ args, context })
|
||||
|
||||
const nonce = generateNonce()
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = context
|
||||
const { email } = args
|
||||
|
||||
const session = context.driver.session()
|
||||
const writeTxResultPromise = session.writeTransaction(async txc => {
|
||||
const result = await txc.run(
|
||||
@ -32,7 +35,7 @@ export default {
|
||||
SET email.createdAt = toString(datetime())
|
||||
RETURN email, user
|
||||
`,
|
||||
{ userId, email, nonce },
|
||||
{ userId, email: args.email, nonce },
|
||||
)
|
||||
return result.records.map(record => ({
|
||||
name: record.get('user').properties.name,
|
||||
|
||||
31
backend/src/schema/resolvers/helpers/createPasswordReset.js
Normal file
31
backend/src/schema/resolvers/helpers/createPasswordReset.js
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
}),
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,6 @@
|
||||
import { UserInputError } from 'apollo-server'
|
||||
export default async function alreadyExistingMail(_parent, args, context) {
|
||||
let { email } = args
|
||||
email = email.toLowerCase()
|
||||
|
||||
export default async function alreadyExistingMail({ args, context }) {
|
||||
const cypher = `
|
||||
MATCH (email:EmailAddress {email: $email})
|
||||
OPTIONAL MATCH (email)-[:BELONGS_TO]-(user)
|
||||
@ -10,7 +9,7 @@ export default async function alreadyExistingMail(_parent, args, context) {
|
||||
let transactionRes
|
||||
const session = context.driver.session()
|
||||
try {
|
||||
transactionRes = await session.run(cypher, { email })
|
||||
transactionRes = await session.run(cypher, { email: args.email })
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
|
||||
19
backend/src/schema/resolvers/locations.js
Normal file
19
backend/src/schema/resolvers/locations.js
Normal 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',
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
85
backend/src/schema/resolvers/locations.spec.js
Normal file
85
backend/src/schema/resolvers/locations.spec.js
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -18,9 +18,8 @@ export default {
|
||||
notifications: async (_parent, args, context, _resolveInfo) => {
|
||||
const { user: currentUser } = context
|
||||
const session = context.driver.session()
|
||||
let notifications
|
||||
let whereClause
|
||||
let orderByClause
|
||||
let notifications, whereClause, orderByClause
|
||||
|
||||
switch (args.read) {
|
||||
case true:
|
||||
whereClause = 'WHERE notification.read = TRUE'
|
||||
@ -41,13 +40,15 @@ export default {
|
||||
default:
|
||||
orderByClause = ''
|
||||
}
|
||||
|
||||
const offset = args.offset && typeof args.offset === 'number' ? `SKIP ${args.offset}` : ''
|
||||
const limit = args.first && typeof args.first === 'number' ? `LIMIT ${args.first}` : ''
|
||||
try {
|
||||
const cypher = `
|
||||
MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id})
|
||||
${whereClause}
|
||||
RETURN resource, notification, user
|
||||
${orderByClause}
|
||||
${offset} ${limit}
|
||||
`
|
||||
const result = await session.run(cypher, { id: currentUser.id })
|
||||
notifications = await result.records.map(transformReturnType)
|
||||
@ -77,4 +78,10 @@ export default {
|
||||
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}`
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,34 +1,6 @@
|
||||
import uuid from 'uuid/v4'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
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
|
||||
}
|
||||
import createPasswordReset from './helpers/createPasswordReset'
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Factory from '../../seed/factories'
|
||||
import { gql } from '../../jest/helpers'
|
||||
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
|
||||
import { createPasswordReset } from './passwordReset'
|
||||
import createPasswordReset from './helpers/createPasswordReset'
|
||||
import createServer from '../../server'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
|
||||
@ -109,10 +109,7 @@ describe('passwordReset', () => {
|
||||
describe('resetPassword', () => {
|
||||
const setup = async (options = {}) => {
|
||||
const { email = 'user@example.org', issuedAt = new Date(), nonce = 'abcdef' } = options
|
||||
|
||||
const session = driver.session()
|
||||
await createPasswordReset({ driver, email, issuedAt, nonce })
|
||||
session.close()
|
||||
}
|
||||
|
||||
const mutation = gql`
|
||||
|
||||
@ -182,6 +182,7 @@ export default {
|
||||
`,
|
||||
{ postId: args.id },
|
||||
)
|
||||
session.close()
|
||||
const [post] = transactionRes.records.map(record => record.get('post').properties)
|
||||
return post
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ import fileUpload from './fileUpload'
|
||||
import encryptPassword from '../../helpers/encryptPassword'
|
||||
import generateNonce from './helpers/generateNonce'
|
||||
import existingEmailAddress from './helpers/existingEmailAddress'
|
||||
import { normalizeEmail } from 'validator'
|
||||
|
||||
const instance = neode()
|
||||
|
||||
@ -29,9 +30,9 @@ export default {
|
||||
return response
|
||||
},
|
||||
Signup: async (_parent, args, context) => {
|
||||
const nonce = generateNonce()
|
||||
args.nonce = nonce
|
||||
let emailAddress = await existingEmailAddress(_parent, args, context)
|
||||
args.nonce = generateNonce()
|
||||
args.email = normalizeEmail(args.email)
|
||||
let emailAddress = await existingEmailAddress({ args, context })
|
||||
if (emailAddress) return emailAddress
|
||||
try {
|
||||
emailAddress = await instance.create('EmailAddress', args)
|
||||
@ -42,9 +43,9 @@ export default {
|
||||
},
|
||||
SignupByInvitation: async (_parent, args, context) => {
|
||||
const { token } = args
|
||||
const nonce = generateNonce()
|
||||
args.nonce = nonce
|
||||
let emailAddress = await existingEmailAddress(_parent, args, context)
|
||||
args.nonce = generateNonce()
|
||||
args.email = normalizeEmail(args.email)
|
||||
let emailAddress = await existingEmailAddress({ args, context })
|
||||
if (emailAddress) return emailAddress
|
||||
try {
|
||||
const result = await instance.cypher(
|
||||
@ -78,7 +79,7 @@ export default {
|
||||
args.termsAndConditionsAgreedAt = new Date().toISOString()
|
||||
|
||||
let { nonce, email } = args
|
||||
email = email.toLowerCase()
|
||||
email = normalizeEmail(email)
|
||||
const result = await instance.cypher(
|
||||
`
|
||||
MATCH(email:EmailAddress {nonce: {nonce}, email: {email}})
|
||||
|
||||
@ -178,6 +178,7 @@ export default {
|
||||
'termsAndConditionsAgreedAt',
|
||||
'allowEmbedIframes',
|
||||
'showShoutsPublicly',
|
||||
'locale',
|
||||
],
|
||||
boolean: {
|
||||
followedByCurrentUser:
|
||||
|
||||
@ -9,6 +9,7 @@ type Location {
|
||||
nameES: String
|
||||
namePT: String
|
||||
namePL: String
|
||||
nameRU: String
|
||||
type: String!
|
||||
lat: Float
|
||||
lng: Float
|
||||
|
||||
15
backend/src/schema/types/type/Donations.gql
Normal file
15
backend/src/schema/types/type/Donations.gql
Normal 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
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
type NOTIFIED {
|
||||
id: ID!
|
||||
from: NotificationSource
|
||||
to: User
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
read: Boolean
|
||||
reason: NotificationReason
|
||||
}
|
||||
@ -23,7 +24,7 @@ enum NotificationReason {
|
||||
}
|
||||
|
||||
type Query {
|
||||
notifications(read: Boolean, orderBy: NotificationOrdering): [NOTIFIED]
|
||||
notifications(read: Boolean, orderBy: NotificationOrdering, first: Int, offset: Int): [NOTIFIED]
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
|
||||
18
backend/src/seed/factories/donations.js
Normal file
18
backend/src/seed/factories/donations.js
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
import { GraphQLClient, request } from 'graphql-request'
|
||||
import { getDriver, neode } from '../../bootstrap/neo4j'
|
||||
import createBadge from './badges.js'
|
||||
import createUser from './users.js'
|
||||
@ -9,20 +8,9 @@ import createTag from './tags.js'
|
||||
import createSocialMedia from './socialMedia.js'
|
||||
import createLocation from './locations.js'
|
||||
import createEmailAddress from './emailAddresses.js'
|
||||
import createDonations from './donations.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 = {
|
||||
Badge: createBadge,
|
||||
User: createUser,
|
||||
@ -34,6 +22,7 @@ const factories = {
|
||||
Location: createLocation,
|
||||
EmailAddress: createEmailAddress,
|
||||
UnverifiedEmailAddress: createUnverifiedEmailAddresss,
|
||||
Donations: createDonations,
|
||||
}
|
||||
|
||||
export const cleanDatabase = async (options = {}) => {
|
||||
@ -48,127 +37,31 @@ export const cleanDatabase = async (options = {}) => {
|
||||
}
|
||||
|
||||
export default function Factory(options = {}) {
|
||||
const {
|
||||
seedServerHost = 'http://127.0.0.1:4001',
|
||||
neo4jDriver = getDriver(),
|
||||
neodeInstance = neode(),
|
||||
} = options
|
||||
|
||||
const graphQLClient = new GraphQLClient(seedServerHost)
|
||||
const { neo4jDriver = getDriver(), neodeInstance = neode() } = options
|
||||
|
||||
const result = {
|
||||
neo4jDriver,
|
||||
seedServerHost,
|
||||
graphQLClient,
|
||||
factories,
|
||||
lastResponse: null,
|
||||
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 = {}) {
|
||||
const { factory, mutation, variables } = this.factories[node](args)
|
||||
if (factory) {
|
||||
this.lastResponse = await factory({
|
||||
args,
|
||||
neodeInstance,
|
||||
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,
|
||||
const { factory } = this.factories[node](args)
|
||||
this.lastResponse = await factory({
|
||||
args,
|
||||
neodeInstance,
|
||||
factoryInstance: this,
|
||||
})
|
||||
return this
|
||||
return this.lastResponse
|
||||
},
|
||||
|
||||
async cleanDatabase() {
|
||||
this.lastResponse = await cleanDatabase({
|
||||
driver: this.neo4jDriver,
|
||||
})
|
||||
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.relate.bind(result)
|
||||
result.mutate.bind(result)
|
||||
result.shout.bind(result)
|
||||
result.followUser.bind(result)
|
||||
result.invite.bind(result)
|
||||
result.cleanDatabase.bind(result)
|
||||
return result
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import faker from 'faker'
|
||||
import sample from 'lodash/sample'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import createServer from '../server'
|
||||
import Factory from './factories'
|
||||
import { neode as getNeode, getDriver } from '../bootstrap/neo4j'
|
||||
import { gql } from '../jest/helpers'
|
||||
|
||||
const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
|
||||
/* eslint-disable no-multi-spaces */
|
||||
;(async function() {
|
||||
let authenticatedUser = null
|
||||
@ -39,6 +42,7 @@ import { gql } from '../jest/helpers'
|
||||
nameDE: 'Hamburg',
|
||||
nameNL: 'Hamburg',
|
||||
namePL: 'Hamburg',
|
||||
nameRU: 'Гамбург',
|
||||
}),
|
||||
factory.create('Location', {
|
||||
id: 'region.14880313158564380',
|
||||
@ -54,6 +58,7 @@ import { gql } from '../jest/helpers'
|
||||
nameDE: 'Berlin',
|
||||
nameNL: 'Berlijn',
|
||||
namePL: 'Berlin',
|
||||
nameRU: 'Берлин',
|
||||
}),
|
||||
factory.create('Location', {
|
||||
id: 'country.10743216036480410',
|
||||
@ -67,6 +72,7 @@ import { gql } from '../jest/helpers'
|
||||
nameFR: 'Allemagne',
|
||||
nameIT: 'Germania',
|
||||
nameEN: 'Germany',
|
||||
nameRU: 'Германия',
|
||||
}),
|
||||
factory.create('Location', {
|
||||
id: 'region.9397217726497330',
|
||||
@ -82,6 +88,7 @@ import { gql } from '../jest/helpers'
|
||||
nameDE: 'Paris',
|
||||
nameNL: 'Parijs',
|
||||
namePL: 'Paryż',
|
||||
nameRU: 'Париж',
|
||||
}),
|
||||
factory.create('Location', {
|
||||
id: 'country.9759535382641660',
|
||||
@ -95,6 +102,7 @@ import { gql } from '../jest/helpers'
|
||||
nameFR: 'France',
|
||||
nameIT: 'Francia',
|
||||
nameEN: 'France',
|
||||
nameRU: 'Франция',
|
||||
}),
|
||||
])
|
||||
await Promise.all([
|
||||
@ -341,39 +349,46 @@ import { gql } from '../jest/helpers'
|
||||
factory.create('Post', {
|
||||
author: peterLustig,
|
||||
id: 'p0',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.food(),
|
||||
categoryIds: ['cat16'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
author: bobDerBaumeister,
|
||||
id: 'p1',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.technology(),
|
||||
categoryIds: ['cat1'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
author: huey,
|
||||
id: 'p3',
|
||||
language: sample(languages),
|
||||
categoryIds: ['cat3'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
author: dewey,
|
||||
id: 'p4',
|
||||
language: sample(languages),
|
||||
categoryIds: ['cat4'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
author: louie,
|
||||
id: 'p5',
|
||||
language: sample(languages),
|
||||
categoryIds: ['cat5'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
authorId: 'u1',
|
||||
id: 'p6',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.buildings(),
|
||||
categoryIds: ['cat6'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
author: huey,
|
||||
id: 'p9',
|
||||
language: sample(languages),
|
||||
categoryIds: ['cat9'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
@ -384,23 +399,27 @@ import { gql } from '../jest/helpers'
|
||||
factory.create('Post', {
|
||||
author: louie,
|
||||
id: 'p11',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.people(),
|
||||
categoryIds: ['cat11'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
author: bobDerBaumeister,
|
||||
id: 'p13',
|
||||
language: sample(languages),
|
||||
categoryIds: ['cat13'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
author: jennyRostock,
|
||||
id: 'p14',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.objects(),
|
||||
categoryIds: ['cat14'],
|
||||
}),
|
||||
factory.create('Post', {
|
||||
author: huey,
|
||||
id: 'p15',
|
||||
language: sample(languages),
|
||||
categoryIds: ['cat15'],
|
||||
}),
|
||||
])
|
||||
@ -910,6 +929,7 @@ import { gql } from '../jest/helpers'
|
||||
}),
|
||||
)
|
||||
|
||||
await factory.create('Donations')
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log('Seeded Data...')
|
||||
process.exit(0)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ The kubernetes dashboard is optional but very helpful for debugging. If you want
|
||||
```bash
|
||||
# in folder deployment/digital-ocean/
|
||||
$ 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
|
||||
@ -18,7 +18,7 @@ $ kubectl proxy
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
# 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:
|
||||
[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 clusterrolebinding tiller-admin --serviceaccount=kube-system:tiller --clusterrole=cluster-admin
|
||||
$ helm init --service-account=tiller
|
||||
$ helm repo add jetstack https://charts.jetstack.io
|
||||
$ helm repo update
|
||||
$ helm install stable/nginx-ingress
|
||||
$ 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 stable/cert-manager
|
||||
$ kubectl apply -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.11/deploy/manifests/00-crds.yaml
|
||||
$ helm install --name cert-manager --namespace cert-manager --version v0.11.0 jetstack/cert-manager
|
||||
```
|
||||
|
||||
## Create Letsencrypt Issuers and Ingress Services
|
||||
|
||||
@ -12,20 +12,20 @@ spec:
|
||||
tls:
|
||||
- hosts:
|
||||
# - nitro-mailserver.human-connection.org
|
||||
- nitro-staging.human-connection.org
|
||||
- develop.human-connection.org
|
||||
secretName: tls
|
||||
rules:
|
||||
- host: nitro-staging.human-connection.org
|
||||
- host: develop.human-connection.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
backend:
|
||||
serviceName: nitro-web
|
||||
serviceName: web
|
||||
servicePort: 3000
|
||||
# - host: nitro-mailserver.human-connection.org
|
||||
# http:
|
||||
# paths:
|
||||
# - path: /
|
||||
# backend:
|
||||
# serviceName: mailserver
|
||||
# servicePort: 80
|
||||
- host: mailserver.human-connection.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
backend:
|
||||
serviceName: mailserver
|
||||
servicePort: 80
|
||||
|
||||
@ -1,47 +1,60 @@
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nitro-backend
|
||||
namespace: human-connection
|
||||
spec:
|
||||
replicas: 1
|
||||
minReadySeconds: 15
|
||||
progressDeadlineSeconds: 60
|
||||
strategy:
|
||||
rollingUpdate:
|
||||
maxSurge: 0
|
||||
maxUnavailable: "100%"
|
||||
selector:
|
||||
matchLabels:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
human-connection.org/commit: COMMIT
|
||||
human-connection.org/selector: deployment-human-connection-backend
|
||||
name: backend
|
||||
namespace: human-connection
|
||||
spec:
|
||||
minReadySeconds: 15
|
||||
progressDeadlineSeconds: 60
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 2147483647
|
||||
selector:
|
||||
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
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
backup.velero.io/backup-volumes: uploads
|
||||
labels:
|
||||
human-connection.org/commit: COMMIT
|
||||
human-connection.org/selector: deployment-human-connection-backend
|
||||
name: "nitro-backend"
|
||||
spec:
|
||||
containers:
|
||||
- name: nitro-backend
|
||||
image: humanconnection/nitro-backend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 4000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: configmap
|
||||
- secretRef:
|
||||
name: human-connection
|
||||
volumeMounts:
|
||||
- mountPath: /nitro-backend/public/uploads
|
||||
name: uploads
|
||||
volumes:
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: uploads-claim
|
||||
restartPolicy: Always
|
||||
terminationGracePeriodSeconds: 30
|
||||
status: {}
|
||||
name: backend
|
||||
spec:
|
||||
containers:
|
||||
- envFrom:
|
||||
- configMapRef:
|
||||
name: configmap
|
||||
- secretRef:
|
||||
name: human-connection
|
||||
image: humanconnection/nitro-backend:latest
|
||||
imagePullPolicy: Always
|
||||
name: nitro-backend
|
||||
ports:
|
||||
- containerPort: 4000
|
||||
protocol: TCP
|
||||
resources: {}
|
||||
terminationMessagePath: /dev/termination-log
|
||||
terminationMessagePolicy: File
|
||||
volumeMounts:
|
||||
- mountPath: /nitro-backend/public/uploads
|
||||
name: uploads
|
||||
dnsPolicy: ClusterFirst
|
||||
restartPolicy: Always
|
||||
schedulerName: default-scheduler
|
||||
securityContext: {}
|
||||
terminationGracePeriodSeconds: 30
|
||||
volumes:
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: uploads-claim
|
||||
status: {}
|
||||
|
||||
@ -1,47 +1,61 @@
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nitro-neo4j
|
||||
namespace: human-connection
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
rollingUpdate:
|
||||
maxSurge: 0
|
||||
maxUnavailable: "100%"
|
||||
selector:
|
||||
matchLabels:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
human-connection.org/selector: deployment-human-connection-neo4j
|
||||
name: neo4j
|
||||
namespace: human-connection
|
||||
spec:
|
||||
progressDeadlineSeconds: 2147483647
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 2147483647
|
||||
selector:
|
||||
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
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
backup.velero.io/backup-volumes: neo4j-data
|
||||
labels:
|
||||
human-connection.org/selector: deployment-human-connection-neo4j
|
||||
name: nitro-neo4j
|
||||
spec:
|
||||
containers:
|
||||
- name: nitro-neo4j
|
||||
image: humanconnection/neo4j:latest
|
||||
imagePullPolicy: Always
|
||||
resources:
|
||||
requests:
|
||||
memory: "2G"
|
||||
limits:
|
||||
memory: "8G"
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: configmap
|
||||
ports:
|
||||
- containerPort: 7687
|
||||
- containerPort: 7474
|
||||
volumeMounts:
|
||||
- mountPath: /data/
|
||||
name: neo4j-data
|
||||
volumes:
|
||||
- name: neo4j-data
|
||||
persistentVolumeClaim:
|
||||
claimName: neo4j-data-claim
|
||||
restartPolicy: Always
|
||||
terminationGracePeriodSeconds: 30
|
||||
name: neo4j
|
||||
spec:
|
||||
containers:
|
||||
- envFrom:
|
||||
- configMapRef:
|
||||
name: configmap
|
||||
image: humanconnection/neo4j:latest
|
||||
imagePullPolicy: Always
|
||||
name: neo4j
|
||||
ports:
|
||||
- containerPort: 7687
|
||||
protocol: TCP
|
||||
- containerPort: 7474
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
requests:
|
||||
memory: 1G
|
||||
terminationMessagePath: /dev/termination-log
|
||||
terminationMessagePolicy: File
|
||||
volumeMounts:
|
||||
- mountPath: /data/
|
||||
name: neo4j-data
|
||||
dnsPolicy: ClusterFirst
|
||||
restartPolicy: Always
|
||||
schedulerName: default-scheduler
|
||||
securityContext: {}
|
||||
terminationGracePeriodSeconds: 30
|
||||
volumes:
|
||||
- name: neo4j-data
|
||||
persistentVolumeClaim:
|
||||
claimName: neo4j-data-claim
|
||||
status: {}
|
||||
|
||||
@ -1,37 +1,54 @@
|
||||
apiVersion: extensions/v1beta1
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
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
|
||||
spec:
|
||||
replicas: 2
|
||||
minReadySeconds: 15
|
||||
progressDeadlineSeconds: 60
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 2147483647
|
||||
selector:
|
||||
matchLabels:
|
||||
human-connection.org/selector: deployment-human-connection-web
|
||||
strategy:
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 1
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
human-connection.org/commit: COMMIT
|
||||
human-connection.org/selector: deployment-human-connection-web
|
||||
name: nitro-web
|
||||
name: web
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
- env:
|
||||
- name: HOST
|
||||
value: 0.0.0.0
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: configmap
|
||||
- secretRef:
|
||||
name: human-connection
|
||||
env:
|
||||
- name: HOST
|
||||
value: 0.0.0.0
|
||||
image: humanconnection/nitro-web:latest
|
||||
imagePullPolicy: Always
|
||||
name: web
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
resources: {}
|
||||
imagePullPolicy: Always
|
||||
terminationMessagePath: /dev/termination-log
|
||||
terminationMessagePolicy: File
|
||||
dnsPolicy: ClusterFirst
|
||||
restartPolicy: Always
|
||||
schedulerName: default-scheduler
|
||||
securityContext: {}
|
||||
terminationGracePeriodSeconds: 30
|
||||
status: {}
|
||||
|
||||
@ -1,34 +1,51 @@
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mailserver
|
||||
namespace: human-connection
|
||||
spec:
|
||||
replicas: 1
|
||||
minReadySeconds: 15
|
||||
progressDeadlineSeconds: 60
|
||||
selector:
|
||||
matchLabels:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
human-connection.org/selector: deployment-human-connection-mailserver
|
||||
name: mailserver
|
||||
namespace: human-connection
|
||||
spec:
|
||||
minReadySeconds: 15
|
||||
progressDeadlineSeconds: 60
|
||||
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
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
human-connection.org/selector: deployment-human-connection-mailserver
|
||||
name: "mailserver"
|
||||
spec:
|
||||
containers:
|
||||
- name: mailserver
|
||||
image: djfarrelly/maildev
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- containerPort: 25
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: configmap
|
||||
- secretRef:
|
||||
name: human-connection
|
||||
restartPolicy: Always
|
||||
terminationGracePeriodSeconds: 30
|
||||
status: {}
|
||||
name: mailserver
|
||||
spec:
|
||||
containers:
|
||||
- envFrom:
|
||||
- configMapRef:
|
||||
name: configmap
|
||||
- secretRef:
|
||||
name: human-connection
|
||||
image: djfarrelly/maildev
|
||||
imagePullPolicy: Always
|
||||
name: mailserver
|
||||
ports:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 25
|
||||
protocol: TCP
|
||||
resources: {}
|
||||
terminationMessagePath: /dev/termination-log
|
||||
terminationMessagePolicy: File
|
||||
dnsPolicy: ClusterFirst
|
||||
restartPolicy: Always
|
||||
schedulerName: default-scheduler
|
||||
securityContext: {}
|
||||
terminationGracePeriodSeconds: 30
|
||||
status: {}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nitro-backend
|
||||
name: backend
|
||||
namespace: human-connection
|
||||
labels:
|
||||
human-connection.org/selector: deployment-human-connection-backend
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nitro-neo4j
|
||||
name: neo4j
|
||||
namespace: human-connection
|
||||
labels:
|
||||
human-connection.org/selector: deployment-human-connection-neo4j
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nitro-web
|
||||
name: web
|
||||
namespace: human-connection
|
||||
labels:
|
||||
human-connection.org/selector: deployment-human-connection-web
|
||||
|
||||
10
package.json
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nitro-cypress",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.10",
|
||||
"description": "Fullstack tests with cypress for Human Connection",
|
||||
"author": "Human Connection gGmbh",
|
||||
"license": "MIT",
|
||||
@ -24,11 +24,11 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"codecov": "^3.6.1",
|
||||
"cross-env": "^6.0.3",
|
||||
"cypress": "^3.5.0",
|
||||
"cypress": "^3.6.1",
|
||||
"cypress-cucumber-preprocessor": "^1.16.2",
|
||||
"cypress-file-upload": "^3.4.0",
|
||||
"cypress-plugin-retries": "^1.3.0",
|
||||
"date-fns": "^2.6.0",
|
||||
"cypress-file-upload": "^3.5.0",
|
||||
"cypress-plugin-retries": "^1.4.0",
|
||||
"date-fns": "^2.7.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"faker": "Marak/faker.js#master",
|
||||
"graphql-request": "^1.8.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/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-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 nitro-web -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-deployment.yaml)"
|
||||
kubectl --namespace=human-connection patch configmap configmap -p "$(cat $TRAVIS_BUILD_DIR/scripts/patches/patch-configmap.yaml)"
|
||||
kubectl --namespace=human-connection patch deployment backend -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)"
|
||||
|
||||
@ -13,6 +13,6 @@ tar xf doctl-1.14.0-linux-amd64.tar.gz
|
||||
chmod +x ./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/
|
||||
doctl kubernetes cluster kubeconfig show nitro-staging > ~/.kube/config
|
||||
doctl k8s cluster kubeconfig show develop > ~/.kube/config
|
||||
|
||||
@ -33,48 +33,74 @@ $ yarn build
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
### Storybook
|
||||
### Run tests
|
||||
|
||||
We encourage contributors to use Storybook to test out new components in an isolated way, and benefit from its many features.
|
||||
See the docs for live examples and answers to FAQ, among other helpful information. 
|
||||
We ensure the quality of our frontend code by using
|
||||
- [ESLint](https://eslint.org/) for checking our JavaScript code
|
||||
- [Jest](https://jestjs.io/) and [Vue Test Utils](https://vue-test-utils.vuejs.org/) to unit test our components
|
||||
- [Storybook](https://storybook.js.org/) to document and manually test our components in an isolated playground
|
||||
|
||||
For more information see our [frontend testing guide](testing.md). Use these commands to run the tests:
|
||||
|
||||
{% tabs %}
|
||||
{% tab title="Docker" %}
|
||||
{% tab title="With Docker" %}
|
||||
|
||||
After you have started the application following the instructions above, in another terminal run:
|
||||
After starting the application following the above guidelines, open new terminal windows for each of these commands:
|
||||
|
||||
```bash
|
||||
# run eslint
|
||||
$ docker-compose exec webapp yarn lint
|
||||
```
|
||||
|
||||
```bash
|
||||
# run unit tests
|
||||
$ docker-compose exec webapp yarn test
|
||||
```
|
||||
|
||||
```bash
|
||||
# start storybook
|
||||
$ docker-compose exec webapp yarn storybook
|
||||
```
|
||||
The output should look similar to this:
|
||||
|
||||

|
||||
|
||||
Click on the link http://localhost:3002/ to open the browser to your interactive storybook.
|
||||
You can then visit the Storybook playground on `http://localhost:3002`
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Without Docker" %}
|
||||
Run the following command:
|
||||
|
||||
After starting the application following the above guidelines, open new terminal windows and navigate to the `/webapp` directory for each of these commands:
|
||||
|
||||
```bash
|
||||
# in webapp/
|
||||
yarn storybook
|
||||
# run eslint in /webapp
|
||||
$ yarn lint
|
||||
```
|
||||
|
||||
Open http://localhost:3002/ in your browser
|
||||
```bash
|
||||
# run unit tests in /webapp
|
||||
$ yarn test
|
||||
```
|
||||
|
||||
```bash
|
||||
# start storybook in /webapp
|
||||
$ yarn storybook
|
||||
```
|
||||
|
||||
You can then visit the Storybook playground on `http://localhost:3002`
|
||||
|
||||
{% endtab %}
|
||||
{% endtabs %}
|
||||
|
||||
## Styleguide Migration
|
||||
|
||||
We are currently in the process of migrating our styleguide components and design tokens from the [Nitro Styleguide](https://github.com/Human-Connection/Nitro-Styleguide) into the main [Human Connection repository](https://github.com/Human-Connection/Human-Connection) and refactoring our components in the process. During this migration, our new components will live in a `view` folder to separate them from the old, yet untouched components.
|
||||
|
||||
## Styleguide
|
||||
### Folder Structure
|
||||
|
||||
All reusable Components \(for example avatar\) should be done inside the [Nitro-Styleguide](https://github.com/Human-Connection/Nitro-Styleguide) repository.
|
||||
The folder structure we are aiming for is based on the [directory setup proposed by Nuxt.js](https://nuxtjs.org/guide/directory-structure):
|
||||
|
||||

|
||||
|
||||
More information can be found here: [https://github.com/Human-Connection/Nitro-Styleguide](https://github.com/Human-Connection/Nitro-Styleguide)
|
||||
|
||||
If you need to change something in the styleguide and want to see the effects on the frontend immediately, then we have you covered. You need to clone the styleguide to the parent directory `../Nitro-Styleguide` and run `yarn && yarn run dev`. After that you run `yarn run dev:styleguide` instead of `yarn run dev` and you will see your changes reflected inside the frontend!
|
||||
- **assets** contains icons, images and logos in `svg` format
|
||||
- **components** are the generic building blocks of the app – small, reusable and usually not coupled to state
|
||||
- **features** are composed of components but tied to a particular function of the app (e.g. `comment` or `post`)
|
||||
- **layouts** can use components to create layout templates for pages
|
||||
- **pages** are the entry points for all `routes` in the app and are composed of layouts, features and components
|
||||
- **styles** holds all shared SCSS files such as `variables` and `mixins`
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
# ASSETS
|
||||
|
||||
This directory contains your un-compiled assets such as LESS, SASS, or JavaScript – in our case SCSS styles.
|
||||
|
||||
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked).
|
||||
@ -1,5 +1,38 @@
|
||||
# COMPONENTS
|
||||
# Components – Code Guidelines
|
||||
|
||||
The components directory contains your Vue.js Components.
|
||||
## We adhere to the [single responsibility principle](https://en.wikipedia.org/wiki/Single_responsibility_principle)
|
||||
|
||||
_Nuxt.js doesn't supercharge these components._
|
||||
Each component does _exactly one job_. The goal is to end up with many small components that are:
|
||||
- easy to understand
|
||||
- easy to maintain
|
||||
- easy to reuse
|
||||
|
||||
**How do you decide what is a separate component?** Try to describe what it does in _one sentence_! When you find yourself using `and` and `or` the code you are talking about should probably be split up into two or more components.
|
||||
|
||||
On the other hand, when something is easily expressed in a few lines of HTML and SCSS and not likely to be reused this is a good indicator that it should _not_ go into a separate component.
|
||||
|
||||
## We compose with components
|
||||
|
||||
Usually `pages` use `layouts` as templates and will be composed of `features`. `features` are composed of `components`, the smallest building blocks of the app. The further down we go in this hierarchy the simpler and more generic the components become. Here is an example:
|
||||
|
||||
- The `index` page is responsible for displaying a list of posts. It uses the `default` layout and the `PostList` feature.
|
||||
- The `PostList` feature uses a `List` component to render `PostTeaser` features.
|
||||
- The `PostTeaser` feature consists of a `LayoutCard` wrapped around a `CardImage`, `CardTitle` and `CardContent` component.
|
||||
|
||||
The `index` page is unique in the app and will never be reused. The `PostList` knows it is handling post data and can therefore not be used for anything else – but it can display posts on the `index` as well as the `user` page.
|
||||
|
||||
The `Card` on the other hand does not care about the type of data it needs to handle. It just takes whatever it receives and renders it in a certain way, so it can be reused throughout the app for many different features.
|
||||
|
||||
## We use two-word names
|
||||
|
||||
We follow the W3C rules for naming custom elements as suggested in the [Vue.js docs](https://vuejs.org/v2/guide/components-registration.html#Component-Names) to differentiate our own components from regular HTML elements in our templates.
|
||||
|
||||
Names should also be meaningful and unique to avoid confusion and code duplication, and also not too long to make them readable. Therefore: aim for two-word names, such as `layout-card`, `post-list` or `post-teaser`.
|
||||
|
||||
## Recommended reads
|
||||
|
||||
For a deeper dive into the WHY and HOW have a look at the following resources which the above guidelines are based on:
|
||||
|
||||
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
|
||||
- [CDD – component based design](https://medium.com/@wereheavyweight/how-were-using-component-based-design-5f9e3176babb)
|
||||
- [Vue.js component styleguide](https://pablohpsilva.github.io/vuejs-component-style-guide/#/)
|
||||
|
||||
168
webapp/components/AvatarMenu/AvatarMenu.spec.js
Normal file
168
webapp/components/AvatarMenu/AvatarMenu.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
17
webapp/components/AvatarMenu/AvatarMenu.story.js
Normal file
17
webapp/components/AvatarMenu/AvatarMenu.story.js
Normal 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" />',
|
||||
}))
|
||||
146
webapp/components/AvatarMenu/AvatarMenu.vue
Normal file
146
webapp/components/AvatarMenu/AvatarMenu.vue
Normal 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 []
|
||||
}
|
||||
let 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>
|
||||
80
webapp/components/DonationInfo/DonationInfo.spec.js
Normal file
80
webapp/components/DonationInfo/DonationInfo.spec.js
Normal 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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
66
webapp/components/DonationInfo/DonationInfo.vue
Normal file
66
webapp/components/DonationInfo/DonationInfo.vue
Normal 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>
|
||||
78
webapp/components/DropdownFilter/DropdownFilter.spec.js
Normal file
78
webapp/components/DropdownFilter/DropdownFilter.spec.js
Normal 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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
30
webapp/components/DropdownFilter/DropdownFilter.story.js
Normal file
30
webapp/components/DropdownFilter/DropdownFilter.story.js
Normal 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"
|
||||
/>`,
|
||||
}))
|
||||
78
webapp/components/DropdownFilter/DropdownFilter.vue
Normal file
78
webapp/components/DropdownFilter/DropdownFilter.vue
Normal 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>
|
||||
54
webapp/components/Empty/Empty.spec.js
Normal file
54
webapp/components/Empty/Empty.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
24
webapp/components/Empty/Empty.story.js
Normal file
24
webapp/components/Empty/Empty.story.js
Normal 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 />',
|
||||
}))
|
||||
@ -26,7 +26,7 @@ export default {
|
||||
*/
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'alert',
|
||||
validator: value => {
|
||||
return value.match(/(messages|events|alert|tasks|docs|file)/)
|
||||
},
|
||||
@ -3,6 +3,9 @@ import VTooltip from 'v-tooltip'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import Vuex from 'vuex'
|
||||
import FilterPosts from './FilterPosts.vue'
|
||||
import locales from '~/locales'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Styleguide)
|
||||
@ -12,6 +15,8 @@ localVue.use(Vuex)
|
||||
let mutations
|
||||
let getters
|
||||
|
||||
const languages = orderBy(locales, 'name')
|
||||
|
||||
describe('FilterPosts.vue', () => {
|
||||
let mocks
|
||||
let propsData
|
||||
@ -20,6 +25,8 @@ describe('FilterPosts.vue', () => {
|
||||
let environmentAndNatureButton
|
||||
let democracyAndPoliticsButton
|
||||
let happyEmotionButton
|
||||
let englishButton
|
||||
let spanishButton
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
@ -54,6 +61,8 @@ describe('FilterPosts.vue', () => {
|
||||
'posts/RESET_CATEGORIES': jest.fn(),
|
||||
'posts/TOGGLE_CATEGORY': jest.fn(),
|
||||
'posts/TOGGLE_EMOTION': jest.fn(),
|
||||
'posts/TOGGLE_LANGUAGE': jest.fn(),
|
||||
'posts/RESET_LANGUAGES': jest.fn(),
|
||||
}
|
||||
getters = {
|
||||
'posts/isActive': () => false,
|
||||
@ -64,6 +73,7 @@ describe('FilterPosts.vue', () => {
|
||||
'posts/filteredCategoryIds': jest.fn(() => []),
|
||||
'posts/filteredByUsersFollowed': jest.fn(),
|
||||
'posts/filteredByEmotions': jest.fn(() => []),
|
||||
'posts/filteredLanguageCodes': jest.fn(() => []),
|
||||
}
|
||||
const openFilterPosts = () => {
|
||||
const store = new Vuex.Store({ mutations, getters })
|
||||
@ -97,6 +107,15 @@ describe('FilterPosts.vue', () => {
|
||||
expect(mutations['posts/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat4')
|
||||
})
|
||||
|
||||
it('calls TOGGLE_LANGUAGE when clicked', () => {
|
||||
const wrapper = openFilterPosts()
|
||||
englishButton = wrapper
|
||||
.findAll('button.language-buttons')
|
||||
.at(languages.findIndex(l => l.code === 'en'))
|
||||
englishButton.trigger('click')
|
||||
expect(mutations['posts/TOGGLE_LANGUAGE']).toHaveBeenCalledWith({}, 'en')
|
||||
})
|
||||
|
||||
it('sets category button attribute `primary` when corresponding category is filtered', () => {
|
||||
getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9'])
|
||||
const wrapper = openFilterPosts()
|
||||
@ -104,6 +123,15 @@ describe('FilterPosts.vue', () => {
|
||||
expect(democracyAndPoliticsButton.attributes().class).toContain('ds-button-primary')
|
||||
})
|
||||
|
||||
it('sets language button attribute `primary` when corresponding language is filtered', () => {
|
||||
getters['posts/filteredLanguageCodes'] = jest.fn(() => ['es'])
|
||||
const wrapper = openFilterPosts()
|
||||
spanishButton = wrapper
|
||||
.findAll('button.language-buttons')
|
||||
.at(languages.findIndex(l => l.code === 'es'))
|
||||
expect(spanishButton.attributes().class).toContain('ds-button-primary')
|
||||
})
|
||||
|
||||
it('sets "filter-by-followed-authors-only" button attribute `primary`', () => {
|
||||
getters['posts/filteredByUsersFollowed'] = jest.fn(() => true)
|
||||
const wrapper = openFilterPosts()
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
<ds-container>
|
||||
<categories-filter-menu-items :chunk="chunk" />
|
||||
<general-filter-menu-items :user="currentUser" />
|
||||
<language-filter-menu-items :user="currentUser" />
|
||||
</ds-container>
|
||||
</template>
|
||||
</dropdown>
|
||||
@ -24,12 +25,14 @@ import Dropdown from '~/components/Dropdown'
|
||||
import { mapGetters } from 'vuex'
|
||||
import CategoriesFilterMenuItems from './CategoriesFilterMenuItems'
|
||||
import GeneralFilterMenuItems from './GeneralFilterMenuItems'
|
||||
import LanguageFilterMenuItems from './LanguageFilterMenuItems'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Dropdown,
|
||||
CategoriesFilterMenuItems,
|
||||
GeneralFilterMenuItems,
|
||||
LanguageFilterMenuItems,
|
||||
},
|
||||
props: {
|
||||
placement: { type: String },
|
||||
|
||||
104
webapp/components/FilterPosts/LanguageFilterMenuItems.vue
Normal file
104
webapp/components/FilterPosts/LanguageFilterMenuItems.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<ds-space margin-top="large">
|
||||
<ds-flex id="filter-posts-header">
|
||||
<ds-heading tag="h4">{{ $t('filter-posts.language.header') }}</ds-heading>
|
||||
<ds-space margin-bottom="large" />
|
||||
</ds-flex>
|
||||
<ds-flex :gutter="{ lg: 'small' }">
|
||||
<ds-flex-item
|
||||
:width="{ base: '100%', sm: '100%', md: '100%', lg: '5%' }"
|
||||
class="language-menu-item"
|
||||
>
|
||||
<ds-flex>
|
||||
<ds-flex-item width="10%" />
|
||||
<ds-flex-item width="100%">
|
||||
<ds-button
|
||||
icon="check"
|
||||
@click.stop.prevent="resetLanguages"
|
||||
:primary="!filteredLanguageCodes.length"
|
||||
/>
|
||||
<ds-flex-item>
|
||||
<label class="language-labels">{{ $t('filter-posts.language.all') }}</label>
|
||||
</ds-flex-item>
|
||||
<ds-space />
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item :width="{ base: '0%', sm: '0%', md: '0%', lg: '4%' }" />
|
||||
<ds-flex-item
|
||||
:width="{ base: '0%', sm: '0%', md: '0%', lg: '3%' }"
|
||||
id="languages-menu-divider"
|
||||
/>
|
||||
<ds-flex v-for="language in locales" :key="language.code" class="languages-menu">
|
||||
<ds-flex class="languages-menu">
|
||||
<ds-flex-item width="100%" class="language-menu-item">
|
||||
<ds-button
|
||||
class="language-buttons"
|
||||
:primary="filteredLanguageCodes.includes(language.code)"
|
||||
@click.stop.prevent="toggleLanguage(language.code)"
|
||||
>
|
||||
{{ language.code.toUpperCase() }}
|
||||
</ds-button>
|
||||
<ds-space margin-bottom="small" />
|
||||
</ds-flex-item>
|
||||
<ds-flex>
|
||||
<ds-flex-item class="language-menu-item">
|
||||
<label class="language-labels">
|
||||
{{ language.name }}
|
||||
</label>
|
||||
</ds-flex-item>
|
||||
<ds-space margin-bottom="xx-large" />
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-flex>
|
||||
</ds-space>
|
||||
</template>
|
||||
<script>
|
||||
import locales from '~/locales'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
chunk: { type: Array, default: () => [] },
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
filteredLanguageCodes: 'posts/filteredLanguageCodes',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
resetLanguages: 'posts/RESET_LANGUAGES',
|
||||
toggleLanguage: 'posts/TOGGLE_LANGUAGE',
|
||||
}),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
locales: orderBy(locales, 'name'),
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.language-menu-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.languages-menu {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.language-labels,
|
||||
.follow-label {
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 960px) {
|
||||
#languages-menu-divider {
|
||||
border-left: 1px solid $border-color-soft;
|
||||
margin: 9px 0px 40px 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,10 +1,12 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils'
|
||||
import { config, shallowMount, createLocalVue } from '@vue/test-utils'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import Hashtag from './Hashtag'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
localVue.use(Styleguide)
|
||||
|
||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||
|
||||
describe('Hashtag', () => {
|
||||
let id
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<ds-tag>#{{ id }}</ds-tag>
|
||||
<ds-tag>
|
||||
<nuxt-link :to="hashtagUrl">#{{ id }}</nuxt-link>
|
||||
</ds-tag>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -8,5 +10,10 @@ export default {
|
||||
props: {
|
||||
id: { type: String, required: true },
|
||||
},
|
||||
computed: {
|
||||
hashtagUrl() {
|
||||
return `/?hashtag=${this.id}`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -69,10 +69,9 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
.notification.read {
|
||||
opacity: 0.6; /* Real browsers */
|
||||
filter: alpha(opacity = 60); /* MSIE */
|
||||
opacity: $opacity-soft;
|
||||
}
|
||||
.notifications-card {
|
||||
min-width: 500px;
|
||||
@ -3,8 +3,8 @@ import NotificationList from './NotificationList'
|
||||
import Notification from '../Notification/Notification'
|
||||
import Vuex from 'vuex'
|
||||
import Filters from '~/plugins/vue-filters'
|
||||
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import { notifications } from '~/components/utils/Notifications'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
@ -38,40 +38,7 @@ describe('NotificationList.vue', () => {
|
||||
stubs = {
|
||||
NuxtLink: RouterLinkStub,
|
||||
}
|
||||
propsData = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
propsData = { notifications }
|
||||
})
|
||||
|
||||
describe('shallowMount', () => {
|
||||
@ -110,15 +77,11 @@ describe('NotificationList.vue', () => {
|
||||
|
||||
describe('click on a notification', () => {
|
||||
beforeEach(() => {
|
||||
wrapper
|
||||
.findAll('.notification-mention-post')
|
||||
.at(1)
|
||||
.trigger('click')
|
||||
wrapper.find('.notification-mention-post').trigger('click')
|
||||
})
|
||||
|
||||
it("emits 'markAsRead' with the id of the notification source", () => {
|
||||
expect(wrapper.emitted('markAsRead')).toBeTruthy()
|
||||
expect(wrapper.emitted('markAsRead')[0]).toEqual(['post-2'])
|
||||
expect(wrapper.emitted('markAsRead')[0]).toEqual(['post-1'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -20,7 +20,7 @@ export default {
|
||||
props: {
|
||||
notifications: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@ -50,7 +50,7 @@ describe('NotificationMenu.vue', () => {
|
||||
beforeEach(() => {
|
||||
data = () => {
|
||||
return {
|
||||
displayedNotifications: [
|
||||
notifications: [
|
||||
{
|
||||
id: 'notification-41',
|
||||
read: true,
|
||||
@ -85,7 +85,7 @@ describe('NotificationMenu.vue', () => {
|
||||
beforeEach(() => {
|
||||
data = () => {
|
||||
return {
|
||||
displayedNotifications: [
|
||||
notifications: [
|
||||
{
|
||||
id: 'notification-41',
|
||||
read: false,
|
||||
@ -1,8 +1,8 @@
|
||||
<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 }}
|
||||
</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 }">
|
||||
<ds-button :primary="!!unreadNotificationsCount" icon="bell" @click.prevent="toggleMenu">
|
||||
{{ unreadNotificationsCount }}
|
||||
@ -10,7 +10,12 @@
|
||||
</template>
|
||||
<template slot="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>
|
||||
</template>
|
||||
</dropdown>
|
||||
@ -21,6 +26,7 @@ import Dropdown from '~/components/Dropdown'
|
||||
import { NOTIFICATIONS_POLL_INTERVAL } from '~/constants/notifications'
|
||||
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
|
||||
import NotificationList from '../NotificationList/NotificationList'
|
||||
import unionBy from 'lodash/unionBy'
|
||||
|
||||
export default {
|
||||
name: 'NotificationMenu',
|
||||
@ -30,7 +36,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
displayedNotifications: [],
|
||||
notifications: [],
|
||||
}
|
||||
},
|
||||
@ -41,36 +46,21 @@ export default {
|
||||
async markAsRead(notificationSourceId) {
|
||||
const variables = { id: notificationSourceId }
|
||||
try {
|
||||
const {
|
||||
data: { markAsRead },
|
||||
} = await this.$apollo.mutate({
|
||||
await this.$apollo.mutate({
|
||||
mutation: markAsReadMutation(this.$i18n),
|
||||
variables,
|
||||
})
|
||||
if (!(markAsRead && markAsRead.read === true)) return
|
||||
this.displayedNotifications = this.displayedNotifications.map(n => {
|
||||
return this.equalNotification(n, markAsRead) ? markAsRead : n
|
||||
})
|
||||
} catch (err) {
|
||||
this.$toast.error(err.message)
|
||||
}
|
||||
},
|
||||
equalNotification(a, b) {
|
||||
return a.from.id === b.from.id && a.createdAt === b.createdAt && a.reason === b.reason
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
notificationsCount() {
|
||||
return (this.displayedNotifications || []).length
|
||||
},
|
||||
unreadNotificationsCount() {
|
||||
let countUnread = 0
|
||||
if (this.displayedNotifications) {
|
||||
this.displayedNotifications.forEach(notification => {
|
||||
if (!notification.read) countUnread++
|
||||
})
|
||||
}
|
||||
return countUnread
|
||||
const result = this.notifications.reduce((count, notification) => {
|
||||
return notification.read ? count : count + 1
|
||||
}, 0)
|
||||
return result
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
@ -78,19 +68,17 @@ export default {
|
||||
query() {
|
||||
return notificationQuery(this.$i18n)
|
||||
},
|
||||
pollInterval() {
|
||||
return NOTIFICATIONS_POLL_INTERVAL
|
||||
variables() {
|
||||
return {
|
||||
read: false,
|
||||
orderBy: 'updatedAt_desc',
|
||||
}
|
||||
},
|
||||
update(data) {
|
||||
const newNotifications = data.notifications.filter(newN => {
|
||||
return !this.displayedNotifications.find(oldN => this.equalNotification(newN, oldN))
|
||||
})
|
||||
this.displayedNotifications = newNotifications
|
||||
.concat(this.displayedNotifications)
|
||||
.sort((a, b) => {
|
||||
return new Date(b.createdAt) - new Date(a.createdAt)
|
||||
})
|
||||
return data.notifications
|
||||
pollInterval: NOTIFICATIONS_POLL_INTERVAL,
|
||||
update({ notifications }) {
|
||||
return unionBy(notifications, this.notifications, notification => notification.id).sort(
|
||||
(a, b) => new Date(b.createdAt) - new Date(a.createdAt),
|
||||
)
|
||||
},
|
||||
error(error) {
|
||||
this.$toast.error(error.message)
|
||||
@ -100,7 +88,7 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
.notifications-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -108,5 +96,16 @@ export default {
|
||||
|
||||
.notifications-menu-popover {
|
||||
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>
|
||||
174
webapp/components/NotificationsTable/NotificationsTable.spec.js
Normal file
174
webapp/components/NotificationsTable/NotificationsTable.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 />`,
|
||||
}))
|
||||
110
webapp/components/NotificationsTable/NotificationsTable.vue
Normal file
110
webapp/components/NotificationsTable/NotificationsTable.vue
Normal 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>
|
||||
72
webapp/components/Paginate/Paginate.spec.js
Normal file
72
webapp/components/Paginate/Paginate.spec.js
Normal 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, Wrapper, nextButton, backButton
|
||||
|
||||
beforeEach(() => {
|
||||
propsData = {}
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
28
webapp/components/Paginate/Paginate.story.js
Normal file
28
webapp/components/Paginate/Paginate.story.js
Normal 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"
|
||||
/>`,
|
||||
}))
|
||||
26
webapp/components/Paginate/Paginate.vue
Normal file
26
webapp/components/Paginate/Paginate.vue
Normal 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>
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { SweetalertIcon } from 'vue-sweetalert-icons'
|
||||
import { normalizeEmail } from 'validator'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -68,8 +69,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
email() {
|
||||
return normalizeEmail(this.formData.email)
|
||||
},
|
||||
submitMessage() {
|
||||
const { email } = this.formData
|
||||
const { email } = this
|
||||
return this.$t('components.password-reset.request.form.submitted', { email })
|
||||
},
|
||||
},
|
||||
@ -86,9 +90,8 @@ export default {
|
||||
requestPasswordReset(email: $email)
|
||||
}
|
||||
`
|
||||
const { email } = this.formData
|
||||
|
||||
try {
|
||||
const { email } = this
|
||||
await this.$apollo.mutate({ mutation, variables: { email } })
|
||||
this.submitted = true
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import helpers from '~/storybook/helpers'
|
||||
|
||||
helpers.init()
|
||||
|
||||
const post = {
|
||||
export const post = {
|
||||
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
|
||||
title: 'Very nice Post Title',
|
||||
contentExcerpt: '<p>My post content</p>',
|
||||
|
||||
65
webapp/components/ProgressBar/ProgressBar.spec.js
Normal file
65
webapp/components/ProgressBar/ProgressBar.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
97
webapp/components/ProgressBar/ProgressBar.vue
Normal file
97
webapp/components/ProgressBar/ProgressBar.vue
Normal 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>
|
||||
@ -48,7 +48,7 @@ describe('Signup', () => {
|
||||
describe('submit', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#email').setValue('mail@example.org')
|
||||
wrapper.find('input#email').setValue('mAIL@exAMPLE.org')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
@ -59,7 +59,7 @@ describe('Signup', () => {
|
||||
|
||||
it('delivers email to backend', () => {
|
||||
const expected = expect.objectContaining({
|
||||
variables: { email: 'mail@example.org', token: null },
|
||||
variables: { email: 'mAIL@exAMPLE.org', token: null },
|
||||
})
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ds-space v-if="!success && !error" margin="large">
|
||||
<ds-space v-if="!data && !error" margin="large">
|
||||
<ds-form
|
||||
@input="handleInput"
|
||||
@input-valid="handleInputValid"
|
||||
@ -100,13 +100,13 @@ export default {
|
||||
},
|
||||
},
|
||||
disabled: true,
|
||||
success: false,
|
||||
data: null,
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
submitMessage() {
|
||||
const { email } = this.formData
|
||||
const { email } = this.data.Signup
|
||||
return this.$t('components.registration.signup.form.success', { email })
|
||||
},
|
||||
},
|
||||
@ -119,15 +119,14 @@ export default {
|
||||
},
|
||||
async handleSubmit() {
|
||||
const mutation = this.token ? SignupByInvitationMutation : SignupMutation
|
||||
const { email } = this.formData
|
||||
const { token } = this
|
||||
const { email } = this.formData
|
||||
|
||||
try {
|
||||
await this.$apollo.mutate({ mutation, variables: { email, token } })
|
||||
this.success = true
|
||||
|
||||
const response = await this.$apollo.mutate({ mutation, variables: { email, token } })
|
||||
this.data = response.data
|
||||
setTimeout(() => {
|
||||
this.$emit('submit', { email })
|
||||
this.$emit('submit', { email: this.data.Signup.email })
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
const { message } = err
|
||||
|
||||
@ -15,6 +15,7 @@ describe('TeaserImage.vue', () => {
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
$t: jest.fn(string => string),
|
||||
}
|
||||
})
|
||||
describe('mount', () => {
|
||||
|
||||
@ -7,8 +7,13 @@
|
||||
:use-custom-slot="true"
|
||||
@vdropzone-error="verror"
|
||||
@vdropzone-thumbnail="transformImage"
|
||||
@vdropzone-drop="dropzoneDrop"
|
||||
>
|
||||
<div class="crop-overlay" ref="cropperOverlay" v-show="showCropper">
|
||||
<ds-button @click="cropImage" class="crop-confirm" primary>
|
||||
{{ $t('contribution.teaserImage.cropperConfirm') }}
|
||||
</ds-button>
|
||||
<ds-button @click="cancelCrop" class="crop-cancel" icon="close"></ds-button>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'hc-attachments-upload-area-post': true,
|
||||
@ -47,7 +52,14 @@ export default {
|
||||
maxFilesize: 5.0,
|
||||
previewTemplate: this.template(),
|
||||
},
|
||||
image: null,
|
||||
file: null,
|
||||
editor: null,
|
||||
cropper: null,
|
||||
thumbnailElement: null,
|
||||
oldImage: null,
|
||||
error: false,
|
||||
showCropper: false,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -72,50 +84,53 @@ export default {
|
||||
this.$toast.error(file.status, message)
|
||||
},
|
||||
transformImage(file) {
|
||||
let thumbnailElement, editor, confirm, thumbnailPreview, contributionImage
|
||||
// Create the image editor overlay
|
||||
editor = document.createElement('div')
|
||||
thumbnailElement = document.querySelectorAll('#postdropzone')[0]
|
||||
thumbnailPreview = document.querySelectorAll('.thumbnail-preview')[0]
|
||||
if (thumbnailPreview) thumbnailPreview.remove()
|
||||
contributionImage = document.querySelectorAll('.contribution-image')[0]
|
||||
if (contributionImage) contributionImage.remove()
|
||||
editor.classList.add('crop-overlay')
|
||||
thumbnailElement.appendChild(editor)
|
||||
// Create the confirm button
|
||||
confirm = document.createElement('button')
|
||||
confirm.classList.add('crop-confirm', 'ds-button', 'ds-button-primary')
|
||||
confirm.textContent = this.$t('contribution.teaserImage.cropperConfirm')
|
||||
confirm.addEventListener('click', () => {
|
||||
// Get the canvas with image data from Cropper.js
|
||||
let canvas = cropper.getCroppedCanvas()
|
||||
canvas.toBlob(blob => {
|
||||
this.$refs.el.manuallyAddFile(blob, canvas.toDataURL(), null, null, {
|
||||
dontSubstractMaxFiles: false,
|
||||
addToFiles: true,
|
||||
})
|
||||
image = new Image()
|
||||
image.src = canvas.toDataURL()
|
||||
image.classList.add('thumbnail-preview')
|
||||
thumbnailElement.appendChild(image)
|
||||
// Remove the editor from view
|
||||
editor.parentNode.removeChild(editor)
|
||||
const croppedImageFile = new File([blob], file.name, { type: 'image/jpeg' })
|
||||
this.$emit('addTeaserImage', croppedImageFile)
|
||||
}, 'image/jpeg')
|
||||
})
|
||||
editor.appendChild(confirm)
|
||||
|
||||
// Load the image
|
||||
let image = new Image()
|
||||
image.src = URL.createObjectURL(file)
|
||||
editor.appendChild(image)
|
||||
// Create Cropper.js and pass image
|
||||
let cropper = new Cropper(image, { zoomable: false, autoCropArea: 1.0 })
|
||||
this.file = file
|
||||
this.showCropper = true
|
||||
this.initEditor()
|
||||
this.initCropper()
|
||||
},
|
||||
dropzoneDrop() {
|
||||
let cropOverlay = document.querySelectorAll('.crop-overlay')[0]
|
||||
if (cropOverlay) cropOverlay.remove()
|
||||
initEditor() {
|
||||
this.editor = this.$refs.cropperOverlay
|
||||
this.clearImages()
|
||||
this.thumbnailElement.appendChild(this.editor)
|
||||
},
|
||||
clearImages() {
|
||||
this.thumbnailElement = document.querySelectorAll('#postdropzone')[0]
|
||||
const thumbnailPreview = document.querySelectorAll('.thumbnail-preview')[0]
|
||||
if (thumbnailPreview) thumbnailPreview.remove()
|
||||
const contributionImage = document.querySelectorAll('.contribution-image')[0]
|
||||
this.oldImage = contributionImage
|
||||
if (contributionImage) contributionImage.remove()
|
||||
},
|
||||
initCropper() {
|
||||
this.image = new Image()
|
||||
this.image.src = URL.createObjectURL(this.file)
|
||||
this.editor.appendChild(this.image)
|
||||
this.cropper = new Cropper(this.image, { zoomable: false, autoCropArea: 0.9 })
|
||||
},
|
||||
cropImage() {
|
||||
this.showCropper = false
|
||||
const canvas = this.cropper.getCroppedCanvas()
|
||||
canvas.toBlob(blob => {
|
||||
this.setupPreview(canvas)
|
||||
this.removeCropper()
|
||||
const croppedImageFile = new File([blob], this.file.name, { type: 'image/jpeg' })
|
||||
this.$emit('addTeaserImage', croppedImageFile)
|
||||
}, 'image/jpeg')
|
||||
},
|
||||
setupPreview(canvas) {
|
||||
this.image = new Image()
|
||||
this.image.src = canvas.toDataURL()
|
||||
this.image.classList.add('thumbnail-preview')
|
||||
this.thumbnailElement.appendChild(this.image)
|
||||
},
|
||||
cancelCrop() {
|
||||
this.showCropper = false
|
||||
if (this.oldImage) this.thumbnailElement.appendChild(this.oldImage)
|
||||
this.removeCropper()
|
||||
},
|
||||
removeCropper() {
|
||||
this.editor.removeChild(document.querySelectorAll('.cropper-container')[0])
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -209,4 +224,10 @@ export default {
|
||||
top: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
.crop-cancel {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -5,7 +5,7 @@ import helpers from '~/storybook/helpers'
|
||||
|
||||
helpers.init()
|
||||
|
||||
const user = {
|
||||
export const user = {
|
||||
id: 'u6',
|
||||
slug: 'louie',
|
||||
name: 'Louie',
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
<ds-flex v-if="!itsMe" gutter="x-small" style="margin-bottom: 0;">
|
||||
<ds-flex-item :width="{ base: 3 }">
|
||||
<ds-flex-item>
|
||||
<hc-follow-button
|
||||
:follow-id="user.id"
|
||||
:is-followed="user.followedByCurrentUser"
|
||||
@ -78,11 +78,6 @@
|
||||
@update="updateFollow"
|
||||
/>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item :width="{ base: 1 }">
|
||||
<ds-button fullwidth>
|
||||
<ds-icon name="user-times" />
|
||||
</ds-button>
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
<!--<ds-space margin-bottom="x-small" />-->
|
||||
</div>
|
||||
|
||||
43
webapp/components/utils/Notifications.js
Normal file
43
webapp/components/utils/Notifications.js
Normal file
@ -0,0 +1,43 @@
|
||||
export const notifications = [
|
||||
{
|
||||
read: false,
|
||||
reason: 'mentioned_in_post',
|
||||
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,
|
||||
reason: 'mentioned_in_comment',
|
||||
from: {
|
||||
__typename: 'Comment',
|
||||
id: 'comment-2',
|
||||
contentExcerpt: 'this is yet another post content',
|
||||
post: {
|
||||
id: 'post-1',
|
||||
title: 'some post on a comment',
|
||||
slug: 'some-post-on-a-comment',
|
||||
contentExcerpt: 'this is a post content',
|
||||
author: {
|
||||
id: 'john-1',
|
||||
slug: 'john-doe',
|
||||
name: 'John Doe',
|
||||
},
|
||||
},
|
||||
author: {
|
||||
id: 'jane-1',
|
||||
slug: 'jane-doe',
|
||||
name: 'Jane Doe',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
2
webapp/constants/posts.js
Normal file
2
webapp/constants/posts.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const first = 12
|
||||
export const offset = 0
|
||||
24
webapp/graphql/Donations.js
Normal file
24
webapp/graphql/Donations.js
Normal file
@ -0,0 +1,24 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const DonationsQuery = () => gql`
|
||||
query {
|
||||
Donations {
|
||||
id
|
||||
goal
|
||||
progress
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UpdateDonations = () => {
|
||||
return gql`
|
||||
mutation($goal: Int, $progress: Int) {
|
||||
UpdateDonations(goal: $goal, progress: $progress) {
|
||||
id
|
||||
goal
|
||||
progress
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
@ -52,8 +52,9 @@ export const notificationQuery = i18n => {
|
||||
${commentFragment(lang)}
|
||||
${postFragment(lang)}
|
||||
|
||||
query {
|
||||
notifications(read: false, orderBy: updatedAt_desc) {
|
||||
query($read: Boolean, $orderBy: NotificationOrdering, $first: Int, $offset: Int) {
|
||||
notifications(read: $read, orderBy: $orderBy, first: $first, offset: $offset) {
|
||||
id
|
||||
read
|
||||
reason
|
||||
createdAt
|
||||
@ -82,6 +83,7 @@ export const markAsReadMutation = i18n => {
|
||||
|
||||
mutation($id: ID!) {
|
||||
markAsRead(id: $id) {
|
||||
id
|
||||
read
|
||||
reason
|
||||
createdAt
|
||||
|
||||
29
webapp/html.md
Normal file
29
webapp/html.md
Normal file
@ -0,0 +1,29 @@
|
||||
# HTML – Code Guidelines
|
||||
|
||||
## We write semantic markup
|
||||
|
||||
We avoid using `divs` and `spans` and try to choose more meaningful HTML elements instead. If unsure which element to use [this list by MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element) can be of help.
|
||||
|
||||
Why?
|
||||
- semantic markup is crucial for accessibility
|
||||
- it makes the code more readable for other developers
|
||||
- it benefits our SEO
|
||||
|
||||
For more background [see this article](https://css-tricks.com/why-how-and-when-to-use-semantic-html-and-aria/).
|
||||
|
||||
This doesn’t mean you can’t ever use a `div` – just think twice before you do!
|
||||
|
||||
## We write as little HTML as possible – and as much as necessary
|
||||
|
||||
HTML is used to _structure content on the page_ and should therefore reflect its complexity. Not more and not less. Most content does not require deep nesting of HTML elements – if you find yourself wrapping `container` around `container` or adding an element just to correctly position another element on the page this calls for the use of CSS instead!
|
||||
|
||||
Why?
|
||||
- deep nesting makes it hard to understand, style and maintain components
|
||||
- it can lead to performance issues
|
||||
|
||||
## Recommended reads
|
||||
|
||||
For a deeper dive into the WHY and HOW have a look at the following resources:
|
||||
|
||||
- [HTML: a good basis for accessibility](https://developer.mozilla.org/en-US/docs/Learn/Accessibility/HTML)
|
||||
- [Why, how, and when to use semantic HTML and ARIA](https://css-tricks.com/why-how-and-when-to-use-semantic-html-and-aria/)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user