Merge branch 'master' of github.com:Ocelot-Social-Community/Ocelot-Social into 5137-refactor-social-media-and-mysomethinglist

This commit is contained in:
Wolfgang Huß 2022-11-23 14:34:23 +01:00
commit 02598e5224
184 changed files with 13540 additions and 1285 deletions

View File

@ -1,3 +1,5 @@
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
<!--
Please take a look at the issue templates at https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/new/choose
before submitting a new issue. Following one of the issue templates will ensure maintainers can route your request efficiently.

View File

@ -1,11 +1,10 @@
---
name: "\U0001F41B Bug Report"
about: Create a report to help us to improve.
title: "\U0001F41B [Bug] XXX"
name: 🐛 Bug report
about: Create a report to help us improve
labels: bug
assignees: ''
title: 🐛 [Bug]
---
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
## :bug: Bug Report
## 🐛 Bugreport
<!-- Describe your issue in detail. Include screenshots if needed. Give us as much information as possible. Use a clear and concise description of what the bug is.-->

View File

@ -1,11 +1,10 @@
---
name: "\U0001F4A5 DevOps Ticket"
about: Help us manage our deployed app.
title: "\U0001F4A5 [DevOps] XXX"
name: 💥 DevOps ticket
about: Help us manage our deployed Software.
labels: devops
assignees: ''
title: 💥 [DevOps]
---
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
## 💥 DevOps Ticket
## 💥 DevOps ticket
<!-- Describe your issue in detail. Include screenshots if needed. Give us as much information as possible. Use a clear and concise description of what the problem is.-->

View File

@ -1,15 +1,13 @@
---
name: "\U0001F31F Epic"
about: Define a big development step.
title: "\U0001F31F [EPIC] XXX"
name: 🌟 Epic
about: Define a big development Step
labels: epic
assignees: ''
title: 🌟 [EPIC]
---
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
<!-- THIS ISSUE-TYPE IS NOT FOR YOU! -->
<!-- If you need an answer right away, visit the ocelot.social Discord:
https://discord.gg/AJSX9DCSUA -->
<!-- Proceed only if you know what you are doing - have a chat with Project's Team first -->
## 🌟 EPIC
<!-- Describe your Epic in detail. Include screenshots and drawings -->

View File

@ -1,11 +1,10 @@
---
name: "\U0001F680 Feature Request"
about: Suggest an idea for this project.
title: "\U0001F680 [Feature] XXX"
name: 🚀 Feature request
about: Suggest an idea for this project
labels: feature
assignees: ''
title: 🚀 [Feature]
---
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
## :rocket: Feature Request
## 🚀 Feature
<!-- Give a short summary of the Feature. Use Screenshots if you want. -->

View File

@ -1,15 +1,13 @@
---
name: "\U0001F4AC Question"
about: If you need help understanding ocelot.social.
title: "\U0001F4AC [Question] XXX"
name: 💬 Question
about: If you need help understanding our Software.
labels: question
assignees: ''
title: 💬 [Question]
---
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
<!-- Chat with ocelot.social team -->
<!-- If you need an answer right away, visit the ocelot.social Discord:
https://discord.gg/AJSX9DCSUA -->
<!-- Question the project's team -->
<!-- If you need an answer right away, consider to take other means of communication with the project's team -->
## 💬 Question
<!-- Describe your Question in detail. Include screenshots and drawings if needed. -->

View File

@ -1,11 +1,10 @@
---
name: "\U0001F527 Refactor"
name: 🔧 Refactor ticket
about: Help us improve our code by refactoring it.
title: "\U0001F527 [Refactor] XXX"
labels: refactor
assignees: ''
title: 🔧 [Refactor]
---
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
## 🔧 Refactor
## 🔧 Refactor ticket
<!-- Describe your issue in detail. Include screenshots if needed. Give us as much information as possible. Use a clear and concise description of what the problem is.-->

13
.github/ISSUE_TEMPLATE/release.md vendored Normal file
View File

@ -0,0 +1,13 @@
---
name: 🎂 Release
about: Define a Release
labels: release
title: 🎂 [RELEASE]
---
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
<!-- THIS ISSUE-TYPE IS NOT FOR YOU! -->
<!-- Proceed only if you know what you are doing - have a chat with Project's Team first -->
## 🎂 RELEASE
<!-- Describe your Release in detail. Include screenshots and drawings -->

View File

@ -1,15 +1,15 @@
## 🍰 Pull Request
<!-- You can find the latest issue templates here https://github.com/ulfgebhardt/issue-templates -->
## 🍰 Pullrequest
<!-- Describe the Pullrequest. Use Screenshots if possible. -->
XXX
### Issues
<!-- Which Issues does this fix, which are related? -->
<!-- Which Issues does this fix, which are related?
- fixes #XXX
- relates #XXX
-->
- None
### Todo
<!-- In case some parts are still missing, list them here. -->
- [ ] XXX list here …
- [X] None

71
.github/workflows/lint_pr.yml vendored Normal file
View File

@ -0,0 +1,71 @@
name: "ocelot.social lint pull request CI"
on:
pull_request:
pull_request_target:
types:
- opened
- edited
- synchronize
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# Configure which types are allowed (newline delimited).
# Default: https://github.com/commitizen/conventional-commit-types
#types: |
# fix
# feat
# Configure which scopes are allowed (newline delimited).
scopes: |
backend
webapp
database
release
other
# Configure that a scope must always be provided.
requireScope: true
# Configure which scopes (newline delimited) are disallowed in PR
# titles. For instance by setting # the value below, `chore(release):
# ...` and `ci(e2e,release): ...` will be rejected.
#disallowScopes: |
# release
# Configure additional validation for the subject based on a regex.
# This example ensures the subject doesn't start with an uppercase character.
subjectPattern: ^(?![A-Z]).+$
# If `subjectPattern` is configured, you can use this property to override
# the default error message that is shown when the pattern doesn't match.
# The variables `subject` and `title` can be used within the message.
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
doesn't start with an uppercase character.
# If you use GitHub Enterprise, you can set this to the URL of your server
#githubBaseUrl: https://github.myorg.com/api/v3
# If the PR contains one of these labels (newline delimited), the
# validation is skipped.
# If you want to rerun the validation when labels change, you might want
# to use the `labeled` and `unlabeled` event triggers in your workflow.
#ignoreLabels: |
# bot
# ignore-semantic-pull-request
# If you're using a format for the PR title that differs from the traditional Conventional
# Commits spec, you can use these options to customize the parsing of the type, scope and
# subject. The `headerPattern` should contain a regex where the capturing groups in parentheses
# correspond to the parts listed in `headerPatternCorrespondence`.
# See: https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser#headerpattern
headerPattern: '^(\w*)(?:\(([\w$.\-*/ ]*)\))?: (.*)$'
headerPatternCorrespondence: type, scope, subject
# For work-in-progress PRs you can typically use draft pull requests
# from GitHub. However, private repositories on the free plan don't have
# this option and therefore this action allows you to opt-in to using the
# special "[WIP]" prefix to indicate this state. This will avoid the
# validation of the PR title and the pull request checks remain pending.
# Note that a second check will be reported if this is enabled.
wip: true

View File

@ -4,7 +4,9 @@ on:
push:
branches:
- master
# - 5093-fix-automatic-deployment # for testing while developing
# - 5059-epic-groups # for testing while developing
# template branches in repo
# - template--separate-branch-auto-deployment--5059-epic-groups
jobs:
##############################################################################
@ -303,16 +305,19 @@ jobs:
# because this step 'kubectl -n default rollout status deployment/* --timeout=600s' does not work as expected
# and we need the pods to be up again for cleaning and seeding the Neo4j database and the backend.
# !!! this is not a perfect solution !!!
# deployments are regularely up again after 3 minutes and 10 seconds
# deployments are regularly up again after 3 minutes and 10 seconds
- name: Sleep for 4 minutes, means 240 seconds
run: sleep 240s
shell: bash
- name: Verify deployment and wait for the pods of each deplyment to get ready for cleaning and seeding of the database
- name: Verify deployment and wait for the pods of each deployment to get ready for cleaning and seeding of the database
run: |
kubectl -n default rollout status deployment/ocelot-backend --timeout=600s
kubectl -n default rollout status deployment/ocelot-neo4j --timeout=600s
kubectl -n default rollout status deployment/ocelot-maintenance --timeout=600s
kubectl -n default rollout status deployment/ocelot-webapp --timeout=600s
- name: Run migrations for Neo4j database via backend for staging
run: |
kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "yarn prod:migrate up"
- name: Reset and seed Neo4j database via backend for staging
# db cleaning and seeding is only possible in production if env 'PRODUCTION_DB_CLEAN_ALLOW=true' is set in deployment
run: |

View File

@ -202,6 +202,8 @@ jobs:
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend
- name: backend | Initialize Database
run: docker-compose exec -T backend yarn db:migrate init
- name: backend | Migrate Database Up
run: docker-compose exec -T backend yarn db:migrate up
- name: backend | Unit test
run: docker-compose exec -T backend yarn test
##########################################################################
@ -267,7 +269,7 @@ jobs:
report_name: Coverage Webapp
type: lcov
result_path: ./coverage/lcov.info
min_coverage: 65
min_coverage: 63
token: ${{ github.token }}
##############################################################################

View File

@ -4,18 +4,60 @@ 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).
#### [1.1.1](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.1.0...1.1.1)
#### [2.2.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.1.0...2.2.0)
- refactor: 🍰 Refactor Design Of Category Filter In Filter Menu [`#5597`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5597)
- feat: 🍰 Footer And Header Links Configurable To Have External Link Target [`#5590`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5590)
- feat: 🍰 Header Menu To Component And Other Refinements [`#5546`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5546)
- fix: 🍰 Comments On Posts In Groups In Webapp [`#5607`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5607)
- docs: 🍰 Add Live Demo To Main Readme [`#5600`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5600)
- feat: Restrict Comments on Posts in Groups [`#5599`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5599)
- fix: 🍰 Add Space In Register Box [`#5591`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5591)
- fix: 🍰 Update Group Avatar After Upload [`#5583`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5583)
- feat: 🍰 Make Donation Progress Bar Color Configurable [`#5593`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5593)
- fix: 🍰 Bug In Edit Slug Of Group [`#5596`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5596)
- fix: 🍰 Fix Group Teaser [`#5584`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5584)
- feat: 🍰 List All Groups [`#5582`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5582)
- feat: 🍰 Header Logo Routing Update [`#5579`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5579)
- chore: 🍰 Release v2.1.0 [`#5574`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5574)
- feat: 🍰 EPIC Groups [`#5132`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5132)
- chore: 🍰 Remove Group Branchs `5059-epic-groups` Separate Auto-Deployment [`#5552`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5552)
- fix: [WIP] 🍰 Long Words Are Being Wrapped Now [`#5559`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5559)
- feat: 🍰 Search For Groups [`#5543`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5543)
- feat: 🍰 Mobile Footer Menu To Header Menu [`#5524`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5524)
- feat: 🍰 Implement `LOGO_HEADER_CLICK` As Configuration [`#5525`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5525)
- refactor: 🍰 Category Filter In Filter Menu [`#5527`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5527)
- feat: 🍰 Group Invitation [`#5512`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5512)
- feat: 🍰 Implement Post In Group In Webapp [`#5468`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5468)
- feat: :cake: Update Issue Templates [`#5508`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5508)
- refactor: 🍰 Disable Submit Button On group Update Changed Error [`#5489`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5489)
- feat: 🍰 Seed Posts In Groups [`#5503`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5503)
- feat: 🍰 Make Configurable Header Menu Translatable [`#5491`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5491)
- feat: 🍰 Post In Groups [`#5380`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5380)
- feat: 🍰 Refine Group Creation And Group Edit [`#5418`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5418)
- feat: 🍰 Implement Content Menu On Group Profile [`#5419`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5419)
- feat: 🍰 Group Members Management [`#5345`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5345)
- feat: 🍰 Have My Groups In The User Menu And Configure Groups Button In Header [`#5411`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5411)
- chore: 🍰 Implement Automatic Deployment For Groups Branch '5059-epic-groups' [`#5408`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5408)
- Chore: 🍰 Release v1.1.1 Refactor Rebranding [`#5392`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5392)
- chore: 🍰 Refactor Rebranding [`#5390`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5390)
- feat: 🍰 Group Profile Description Etc [`#5368`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5368)
- feat: 🍰 Tooltips For Topics [`#5350`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5350)
- feat: 🍰 Group Profile Members List Etc [`#5335`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5335)
- feat: 🍰 Implement Group Profile Visibility [`#5332`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5332)
- feat: 🍰 Save Categories In Frontend [`#5284`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5284)
- feat: 🍰 Add New Yunite Icons [`#5319`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5319)
- chore: 🍰 Update Neode From v^0.4.7 To v^0.4.8 In Backend [`#5334`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5334)
- feat: 🍰 My Groups Page [`#5148`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5148)
- feat: 🍰 Hidden Groups Shall Not Be Visible For None Or Pending Members In Backend [`#5317`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5317)
- feat: 🍰 Implement Group Profile [`#5197`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5197)
- fix: Category Filter Menu Client Only [`#5301`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5301)
- feat: Save Category Settings [`#5261`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5261)
- feat: 🍰 Implement `UpdateGroup` Resolver [`#5224`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5224)
- feat: Topics Menu [`#5248`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5248)
- docs: 🍰 Document GraqhQL Playground [`#5253`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5253)
- feat: Categories Filter Menu [`#5198`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5198)
- feat: 🍰 Implement `JoinGroup`, `GroupMember`, `SwitchGroupMemberRole` Resolvers [`#5199`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5199)
- fix: 🍰 Fix Test Description From `enter-nonce.vue` To `change-password` [`#5217`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5217)
- Bump cookie-universal-nuxt from 2.1.5 to 2.2.2 in /webapp [`#5218`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5218)
- Bump prettier from 2.2.1 to 2.7.1 in /webapp [`#5170`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5170)
@ -27,10 +69,12 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- feat: 🍰 Change Error Message With `Authorised` To `Authorized` All Over The Place To Have American English [`#5206`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5206)
- Bump cross-env from 7.0.2 to 7.0.3 in /webapp [`#5168`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5168)
- chore: 🍰 Add `--logHeapUsage` To Jest Test Call [`#5182`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5182)
- chore: 🍰 Add Groups To Seeding [`#5185`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5185)
- feat: 🍰 Implement Group GQL Model And CRUD Resolvers First Step [`#5139`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5139)
- refactor: 🍰 Rename `UserGroup` To `UserRole` [`#5143`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5143)
- add new yunite icons [`bb0d632`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/bb0d6329e7e36ea03671318ea8dd128a6d5a5a7a)
- cleanup refactor rebranding [`5f5c0fa`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/5f5c0faa1f28cd4df7681eba335ae5998b2d9cca)
- change color and scss in branding [`52070b8`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/52070b8c570970bf48df561134bf67cb4111b640)
- improved code and tests as suggested by @tirokk, thanks for the great review! [`631f34a`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/631f34a2e5224d68279337a92e7535794b670d70)
- implement and test post visibilty when leaving or changing the role in a group [`76bfe48`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/76bfe484768cf9b20b2dced865d5d3e3eb999235)
- comment out LanguagesFilter, EmotionsFilter, fix tests, fix lint [`52dcd77`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/52dcd772fa81e02a0d95e89a9fc8232e70a09d28)
#### [1.1.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.9...1.1.0)

View File

@ -65,11 +65,18 @@ Change into the new folder.
$ cd Ocelot-Social
```
## Live Demo And Developer Logins
**Try out our deployed [development environment](https://stage.ocelot.social).**
Visit our staging networks:
* central staging network: [stage.ocelot.social](https://stage.ocelot.social)
<!-- - rebranded staging network: [rebrand.ocelot.social](https://stage.ocelot.social). -->
### Login
<!-- Try out our deployed [development environment](https://develop.human-connection.org/). -->
Logins in the browser after the following installations:
Logins for the live demos and developers (local developers after the following installations) in the browser:
| email | password | role |
| :--- | :--- | :--- |

View File

@ -29,4 +29,4 @@ AWS_BUCKET=
EMAIL_DEFAULT_SENDER="devops@ocelot.social"
EMAIL_SUPPORT="devops@ocelot.social"
CATEGORIES_ACTIVE=false
CATEGORIES_ACTIVE=false

View File

@ -7,8 +7,9 @@ Run the following command to install everything through docker.
The installation takes a bit longer on the first pass or on rebuild ...
```bash
# in main folder
$ docker-compose up
# or
# rebuild the containers for a cleanup
$ docker-compose up --build
```
@ -28,6 +29,7 @@ between different local node versions.
Install node dependencies with [yarn](https://yarnpkg.com/en/):
```bash
# in main folder
$ cd backend
$ yarn install
```
@ -45,12 +47,14 @@ a [local Neo4J](http://localhost:7474) instance is up and running.
Start the backend for development with:
```bash
# in backend/
$ yarn run dev
```
or start the backend in production environment with:
```bash
# in backend/
$ yarn run start
```
@ -73,6 +77,7 @@ backend is running:
{% tab title="Docker" %}
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:migrate init
```
@ -80,7 +85,7 @@ $ docker-compose exec backend yarn run db:migrate init
{% tab title="Without Docker" %}
```bash
# in folder backend/
# in folder backend/ while database is running
# make sure your database is running on http://localhost:7474/browser/
yarn run db:migrate init
```
@ -99,12 +104,14 @@ need to seed your database:
In another terminal run:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:seed
```
To reset the database run:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:reset
# you could also wipe out your neo4j database and delete all volumes with:
$ docker-compose down -v
@ -118,12 +125,14 @@ $ docker-compose exec backend yarn run db:migrate init
Run:
```bash
# in backend/ while database is running
$ yarn run db:seed
```
To reset the database run:
```bash
# in backend/ while database is running
$ yarn run db:reset
```
@ -141,6 +150,7 @@ you have to migrate your data e.g. because your data modeling has changed.
Generate a data migration file:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:migrate:create your_data_migration
# Edit the file in ./src/db/migrations/
```
@ -148,6 +158,7 @@ $ docker-compose exec backend yarn run db:migrate:create your_data_migration
To run the migration:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:migrate up
```
@ -157,6 +168,7 @@ $ docker-compose exec backend yarn run db:migrate up
Generate a data migration file:
```bash
# in backend/
$ yarn run db:migrate:create your_data_migration
# Edit the file in ./src/db/migrations/
```
@ -164,6 +176,7 @@ $ yarn run db:migrate:create your_data_migration
To run the migration:
```bash
# in backend/ while database is running
$ yarn run db:migrate up
```
@ -181,6 +194,7 @@ database after each test, running the tests will wipe out all your data!
Run the unit tests:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run test
```
@ -191,6 +205,7 @@ $ docker-compose exec backend yarn run test
Run the unit tests:
```bash
# in backend/ while database is running
$ yarn run test
```

View File

@ -1,6 +1,6 @@
{
"name": "ocelot-social-backend",
"version": "1.1.1",
"version": "2.2.0",
"description": "GraphQL Backend for ocelot.social",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",

View File

@ -1,3 +1,7 @@
// this file is duplicated in `backend/src/constants/metadata.js` and `webapp/constants/metadata.js`
export const CATEGORIES_MIN = 1
export const CATEGORIES_MAX = 3
export const categories = [
{
icon: 'networking',

View File

@ -0,0 +1,3 @@
// this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js`
export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags
export const DESCRIPTION_EXCERPT_HTML_LENGTH = 250 // with removed HTML tags

View File

@ -85,11 +85,11 @@ class Store {
await createDefaultAdminUser(session)
if (CONFIG.CATEGORIES_ACTIVE) await createCategories(session)
const writeTxResultPromise = session.writeTransaction(async (txc) => {
await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices
await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices and contraints
return Promise.all(
[
'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])',
'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])',
'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])',
'CALL db.index.fulltext.createNodeIndex("tag_fulltext_search",["Tag"],["id"])',
].map((statement) => txc.run(statement)),
)

View File

@ -0,0 +1,66 @@
import { getDriver } from '../../db/neo4j'
export const description = `
We introduced a new node label 'Group' and we need two primary keys 'id' and 'slug' for it.
Additional we like to have fulltext indices the keys 'name', 'slug', 'about', and 'description'.
`
export async function up(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
CREATE CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE
`)
await transaction.run(`
CREATE CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE
`)
await transaction.run(`
CALL db.index.fulltext.createNodeIndex("group_fulltext_search",["Group"],["name", "slug", "about", "description"])
`)
await transaction.commit()
next()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
session.close()
}
}
export async function down(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
DROP CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE
`)
await transaction.run(`
DROP CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE
`)
await transaction.run(`
CALL db.index.fulltext.drop("group_fulltext_search")
`)
await transaction.commit()
next()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
session.close()
}
}

View File

@ -5,7 +5,13 @@ import createServer from '../server'
import faker from '@faker-js/faker'
import Factory from '../db/factories'
import { getNeode, getDriver } from '../db/neo4j'
import { gql } from '../helpers/jest'
import {
createGroupMutation,
joinGroupMutation,
changeGroupMemberRoleMutation,
} from '../graphql/groups'
import { createPostMutation } from '../graphql/posts'
import { createCommentMutation } from '../graphql/comments'
import { categories } from '../constants/categories'
if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) {
@ -294,6 +300,302 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}),
])
// Create Groups
authenticatedUser = await peterLustig.toJson()
await Promise.all([
mutate({
mutation: createGroupMutation(),
variables: {
id: 'g0',
name: 'Investigative Journalism',
about: 'Investigative journalists share ideas and insights and can collaborate.',
description: `<p class=""><em>English:</em></p><p class="">This group is hidden.</p><h3>What is our group for?</h3><p>This group was created to allow investigative journalists to share and collaborate.</p><h3>How does it work?</h3><p>Here you can internally share posts and comments about them.</p><p><br></p><p><em>Deutsch:</em></p><p class="">Diese Gruppe ist verborgen.</p><h3>Wofür ist unsere Gruppe?</h3><p class="">Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.</p><h3>Wie funktioniert das?</h3><p class="">Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.</p>`,
groupType: 'hidden',
actionRadius: 'global',
categoryIds: ['cat6', 'cat12', 'cat16'],
locationName: 'Hamburg, Germany',
},
}),
])
await Promise.all([
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g0',
userId: 'u2',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g0',
userId: 'u4',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g0',
userId: 'u6',
},
}),
])
await Promise.all([
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g0',
userId: 'u2',
roleInGroup: 'usual',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g0',
userId: 'u4',
roleInGroup: 'admin',
},
}),
])
// post into group
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p0-g0',
groupId: 'g0',
title: `What happend in Shanghai?`,
content: 'A sack of rise dropped in Shanghai. Should we further investigate?',
categoryIds: ['cat6'],
},
}),
])
authenticatedUser = await bobDerBaumeister.toJson()
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p1-g0',
groupId: 'g0',
title: `The man on the moon`,
content: 'We have to further investigate about the stories of a man living on the moon.',
categoryIds: ['cat12', 'cat16'],
},
}),
])
authenticatedUser = await jennyRostock.toJson()
await Promise.all([
mutate({
mutation: createGroupMutation(),
variables: {
id: 'g1',
name: 'School For Citizens',
about: 'Our children shall receive education for life.',
description: `<p class=""><em>English</em></p><h3>Our goal</h3><p>Only those who enjoy learning and do not lose their curiosity can obtain a good education for life and continue to learn with joy throughout their lives.</p><h3>Curiosity</h3><p>For this we need a school that takes up the curiosity of the children, the people, and satisfies it through a lot of experience.</p><p><br></p><p><em>Deutsch</em></p><h3>Unser Ziel</h3><p class="">Nur wer Spaß am Lernen hat und seine Neugier nicht verliert, kann gute Bildung für's Leben erlangen und sein ganzes Leben mit Freude weiter lernen.</p><h3>Neugier</h3><p class="">Dazu benötigen wir eine Schule, die die Neugier der Kinder, der Menschen, aufnimmt und durch viel Erfahrung befriedigt.</p>`,
groupType: 'closed',
actionRadius: 'national',
categoryIds: ['cat8', 'cat14'],
locationName: 'France',
},
}),
])
await Promise.all([
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u1',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u2',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u5',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u6',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u7',
},
}),
])
await Promise.all([
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g1',
userId: 'u1',
roleInGroup: 'usual',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g1',
userId: 'u5',
roleInGroup: 'admin',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g1',
userId: 'u6',
roleInGroup: 'owner',
},
}),
])
// post into group
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p0-g1',
groupId: 'g1',
title: `Can we use ocelot for education?`,
content: 'I like the concept of this school. Can we use our software in this?',
categoryIds: ['cat8'],
},
}),
])
authenticatedUser = await peterLustig.toJson()
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p1-g1',
groupId: 'g1',
title: `Can we push this idea out of France?`,
content: 'This idea is too inportant to have the scope only on France.',
categoryIds: ['cat14'],
},
}),
])
authenticatedUser = await bobDerBaumeister.toJson()
await Promise.all([
mutate({
mutation: createGroupMutation(),
variables: {
id: 'g2',
name: 'Yoga Practice',
about: 'We do yoga around the clock.',
description: `<h3>What Is yoga?</h3><p>Yoga is not just about practicing asanas. It's about how we do it.</p><p class="">And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.</p><h3>What makes practicing asanas yogic?</h3><p class="">The important thing is:</p><ul><li><p>Use the exercises (consciously) for your personal development.</p></li></ul>`,
groupType: 'public',
actionRadius: 'interplanetary',
categoryIds: ['cat4', 'cat5', 'cat17'],
},
}),
])
await Promise.all([
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u3',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u4',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u5',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u6',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u7',
},
}),
])
await Promise.all([
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g2',
userId: 'u3',
roleInGroup: 'usual',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g2',
userId: 'u4',
roleInGroup: 'pending',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g2',
userId: 'u5',
roleInGroup: 'admin',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g2',
userId: 'u6',
roleInGroup: 'usual',
},
}),
])
authenticatedUser = await louie.toJson()
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p0-g2',
groupId: 'g2',
title: `I am a Noob`,
content: 'I am new to Yoga and did not join this group so far.',
categoryIds: ['cat4'],
},
}),
])
// Create Posts
const [p0, p1, p3, p4, p5, p6, p9, p10, p11, p13, p14, p15] = await Promise.all([
Factory.build(
'post',
@ -471,17 +773,10 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
'See <a class="hashtag" data-hashtag-id="NaturphilosophieYoga" href="/?hashtag=NaturphilosophieYoga">#NaturphilosophieYoga</a>, it can really help you!'
const hashtagAndMention1 =
'The new physics of <a class="hashtag" data-hashtag-id="QuantenFlussTheorie" href="/?hashtag=QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" data-hashtag-id="QuantumGravity" href="/?hashtag=QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
const createPostMutation = gql`
mutation ($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) {
CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
id
}
}
`
await Promise.all([
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p2',
title: `Nature Philosophy Yoga`,
@ -490,7 +785,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p7',
title: 'This is post #7',
@ -499,7 +794,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p8',
image: faker.image.unsplash.nature(),
@ -509,7 +804,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p12',
title: 'This is post #12',
@ -528,13 +823,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
'I heard <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a> has practiced it for 3 years now.'
const mentionInComment2 =
'Did <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> tell you?'
const createCommentMutation = gql`
mutation ($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}
}
`
await Promise.all([
mutate({
mutation: createCommentMutation,

View File

@ -0,0 +1,30 @@
import gql from 'graphql-tag'
// ------ mutations
export const signupVerificationMutation = gql`
mutation (
$password: String!
$email: String!
$name: String!
$slug: String
$nonce: String!
$termsAndConditionsAgreedVersion: String!
) {
SignupVerification(
email: $email
password: $password
name: $name
slug: $slug
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
) {
id
slug
}
}
`
// ------ queries
// fill queries in here

View File

@ -0,0 +1,15 @@
import gql from 'graphql-tag'
// ------ mutations
export const createCommentMutation = gql`
mutation ($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}
}
`
// ------ queries
// fill queries in here

View File

@ -0,0 +1,203 @@
import gql from 'graphql-tag'
// ------ mutations
export const createGroupMutation = () => {
return gql`
mutation (
$id: ID
$name: String!
$slug: String
$about: String
$description: String!
$groupType: GroupType!
$actionRadius: GroupActionRadius!
$categoryIds: [ID]
$locationName: String # empty string '' sets it to null
) {
CreateGroup(
id: $id
name: $name
slug: $slug
about: $about
description: $description
groupType: $groupType
actionRadius: $actionRadius
categoryIds: $categoryIds
locationName: $locationName
) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
locationName
location {
name
nameDE
nameEN
}
myRole
}
}
`
}
export const updateGroupMutation = () => {
return gql`
mutation (
$id: ID!
$name: String
$slug: String
$about: String
$description: String
$actionRadius: GroupActionRadius
$categoryIds: [ID]
$avatar: ImageInput
$locationName: String # empty string '' sets it to null
) {
UpdateGroup(
id: $id
name: $name
slug: $slug
about: $about
description: $description
actionRadius: $actionRadius
categoryIds: $categoryIds
avatar: $avatar
locationName: $locationName
) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
# avatar # test this as result
locationName
location {
name
nameDE
nameEN
}
myRole
}
}
`
}
export const joinGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
JoinGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
}
}
`
}
export const leaveGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
LeaveGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
}
}
`
}
export const changeGroupMemberRoleMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) {
ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) {
id
name
slug
myRoleInGroup
}
}
`
}
// ------ queries
export const groupQuery = () => {
return gql`
query ($isMember: Boolean, $id: ID, $slug: String) {
Group(isMember: $isMember, id: $id, slug: $slug) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
avatar {
url
}
locationName
location {
name
nameDE
nameEN
}
myRole
}
}
`
}
export const groupMembersQuery = () => {
return gql`
query ($id: ID!) {
GroupMembers(id: $id) {
id
name
slug
myRoleInGroup
}
}
`
}

View File

@ -0,0 +1,88 @@
import gql from 'graphql-tag'
// ------ mutations
export const createPostMutation = () => {
return gql`
mutation (
$id: ID
$title: String!
$slug: String
$content: String!
$categoryIds: [ID]
$groupId: ID
) {
CreatePost(
id: $id
title: $title
slug: $slug
content: $content
categoryIds: $categoryIds
groupId: $groupId
) {
id
slug
title
content
}
}
`
}
// ------ queries
export const postQuery = () => {
return gql`
query Post($id: ID!) {
Post(id: $id) {
id
title
content
}
}
`
}
export const filterPosts = () => {
return gql`
query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id
title
content
}
}
`
}
export const profilePagePosts = () => {
return gql`
query profilePagePosts(
$filter: _PostFilter
$first: Int
$offset: Int
$orderBy: [_PostOrdering]
) {
profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id
title
content
}
}
`
}
export const searchPosts = () => {
return gql`
query ($query: String!, $firstPosts: Int, $postsOffset: Int) {
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
id
title
content
}
}
}
`
}

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag'
// ------ mutations
export const loginMutation = gql`
mutation ($email: String!, $password: String!) {
login(email: $email, password: $password)
}
`
// ------ queries
// fill queries in here

View File

@ -1,5 +1,18 @@
// TODO: can be replaced with: (which is no a fake)
// import gql from 'graphql-tag'
// See issue: https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/5152
//* This is a fake ES2015 template string, just to benefit of syntax
// highlighting of `gql` template strings in certain editors.
export function gql(strings) {
return strings.join('')
}
// sometime we have to wait to check a db state by having a look into the db in a certain moment
// or we wait a bit to check if we missed to set an await somewhere
// see: https://www.sitepoint.com/delay-sleep-pause-wait/
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// usage 4 seconds for example
// await sleep(4 * 1000)

View File

@ -1,26 +1,32 @@
import trunc from 'trunc-html'
import { DESCRIPTION_EXCERPT_HTML_LENGTH } from '../constants/groups'
export default {
Mutation: {
CreateGroup: async (resolve, root, args, context, info) => {
args.descriptionExcerpt = trunc(args.description, DESCRIPTION_EXCERPT_HTML_LENGTH).html
return resolve(root, args, context, info)
},
UpdateGroup: async (resolve, root, args, context, info) => {
if (args.description)
args.descriptionExcerpt = trunc(args.description, DESCRIPTION_EXCERPT_HTML_LENGTH).html
return resolve(root, args, context, info)
},
CreatePost: async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 120).html
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
UpdatePost: async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 120).html
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
CreateComment: async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 180).html
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
UpdateComment: async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 180).html
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
},
}

View File

@ -1,6 +1,13 @@
import sanitizeHtml from 'sanitize-html'
import linkifyHtml from 'linkifyjs/html'
export const removeHtmlTags = (input) => {
return sanitizeHtml(input, {
allowedTags: [],
allowedAttributes: {},
})
}
const standardSanitizeHtmlOptions = {
allowedTags: [
'img',

View File

@ -1,12 +1,5 @@
import LanguageDetect from 'languagedetect'
import sanitizeHtml from 'sanitize-html'
const removeHtmlTags = (input) => {
return sanitizeHtml(input, {
allowedTags: [],
allowedAttributes: {},
})
}
import { removeHtmlTags } from '../helpers/cleanHtml.js'
const setPostLanguage = (text) => {
const lngDetector = new LanguageDetect()

View File

@ -1,4 +1,4 @@
import { rule, shield, deny, allow, or } from 'graphql-shield'
import { rule, shield, deny, allow, or, and } from 'graphql-shield'
import { getNeode } from '../db/neo4j'
import CONFIG from '../config'
import { validateInviteCode } from '../schema/resolvers/transactions/inviteCodes'
@ -52,6 +52,241 @@ const isMySocialMedia = rule({
return socialMedia.ownedBy.node.id === user.id
})
const isAllowedToChangeGroupSettings = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const ownerId = user.id
const { id: groupId } = args
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (owner:User {id: $ownerId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
RETURN group {.*}, owner {.*, myRoleInGroup: membership.role}
`,
{ groupId, ownerId },
)
return {
owner: transactionResponse.records.map((record) => record.get('owner'))[0],
group: transactionResponse.records.map((record) => record.get('group'))[0],
}
})
try {
const { owner, group } = await readTxPromise
return !!group && !!owner && ['owner'].includes(owner.myRoleInGroup)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAllowedSeeingGroupMembers = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { id: groupId } = args
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (group:Group {id: $groupId})
OPTIONAL MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group)
RETURN group {.*}, member {.*, myRoleInGroup: membership.role}
`,
{ groupId, userId: user.id },
)
return {
member: transactionResponse.records.map((record) => record.get('member'))[0],
group: transactionResponse.records.map((record) => record.get('group'))[0],
}
})
try {
const { member, group } = await readTxPromise
return (
!!group &&
(group.groupType === 'public' ||
(['closed', 'hidden'].includes(group.groupType) &&
!!member &&
['usual', 'admin', 'owner'].includes(member.myRoleInGroup)))
)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAllowedToChangeGroupMemberRole = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const currentUserId = user.id
const { groupId, userId, roleInGroup } = args
if (currentUserId === userId) return false
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (currentUser:User {id: $currentUserId})-[currentUserMembership:MEMBER_OF]->(group:Group {id: $groupId})
OPTIONAL MATCH (group)<-[userMembership:MEMBER_OF]-(member:User {id: $userId})
RETURN group {.*}, currentUser {.*, myRoleInGroup: currentUserMembership.role}, member {.*, myRoleInGroup: userMembership.role}
`,
{ groupId, currentUserId, userId },
)
return {
currentUser: transactionResponse.records.map((record) => record.get('currentUser'))[0],
group: transactionResponse.records.map((record) => record.get('group'))[0],
member: transactionResponse.records.map((record) => record.get('member'))[0],
}
})
try {
const { currentUser, group, member } = await readTxPromise
const groupExists = !!group
const currentUserExists = !!currentUser
const userIsMember = !!member
const sameUserRoleInGroup = member && member.myRoleInGroup === roleInGroup
const userIsOwner = member && ['owner'].includes(member.myRoleInGroup)
const currentUserIsAdmin = currentUser && ['admin'].includes(currentUser.myRoleInGroup)
const adminCanSetRole = ['pending', 'usual', 'admin'].includes(roleInGroup)
const currentUserIsOwner = currentUser && ['owner'].includes(currentUser.myRoleInGroup)
const ownerCanSetRole = ['pending', 'usual', 'admin', 'owner'].includes(roleInGroup)
return (
groupExists &&
currentUserExists &&
(!userIsMember || (userIsMember && (sameUserRoleInGroup || !userIsOwner))) &&
((currentUserIsAdmin && adminCanSetRole) || (currentUserIsOwner && ownerCanSetRole))
)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAllowedToJoinGroup = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { groupId, userId } = args
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (group:Group {id: $groupId})
OPTIONAL MATCH (group)<-[membership:MEMBER_OF]-(member:User {id: $userId})
RETURN group {.*}, member {.*, myRoleInGroup: membership.role}
`,
{ groupId, userId },
)
return {
group: transactionResponse.records.map((record) => record.get('group'))[0],
member: transactionResponse.records.map((record) => record.get('member'))[0],
}
})
try {
const { group, member } = await readTxPromise
return !!group && (group.groupType !== 'hidden' || (!!member && !!member.myRoleInGroup))
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAllowedToLeaveGroup = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { groupId, userId } = args
if (user.id !== userId) return false
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
RETURN group {.*}, member {.*, myRoleInGroup: membership.role}
`,
{ groupId, userId },
)
return {
group: transactionResponse.records.map((record) => record.get('group'))[0],
member: transactionResponse.records.map((record) => record.get('member'))[0],
}
})
try {
const { group, member } = await readTxPromise
return !!group && !!member && !!member.myRoleInGroup && member.myRoleInGroup !== 'owner'
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isMemberOfGroup = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { groupId } = args
if (!groupId) return true
const userId = user.id
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (User {id: $userId})-[membership:MEMBER_OF]->(Group {id: $groupId})
RETURN membership.role AS role
`,
{ groupId, userId },
)
return transactionResponse.records.map((record) => record.get('role'))[0]
})
try {
const role = await readTxPromise
return ['usual', 'admin', 'owner'].includes(role)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const canCommentPost = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { postId } = args
const userId = user.id
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (post:Post { id: $postId })
OPTIONAL MATCH (post)-[:IN]->(group:Group)
OPTIONAL MATCH (user:User { id: $userId })-[membership:MEMBER_OF]->(group)
RETURN group AS group, membership AS membership
`,
{ postId, userId },
)
return {
group: transactionResponse.records.map((record) => record.get('group'))[0],
membership: transactionResponse.records.map((record) => record.get('membership'))[0],
}
})
try {
const { group, membership } = await readTxPromise
return (
!group || (membership && ['usual', 'admin', 'owner'].includes(membership.properties.role))
)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAuthor = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
@ -78,7 +313,7 @@ const isAuthor = rule({
const isDeletingOwnAccount = rule({
cache: 'no_cache',
})(async (parent, args, context, info) => {
})(async (parent, args, context, _info) => {
return context.user.id === args.id
})
@ -102,11 +337,10 @@ export default shield(
{
Query: {
'*': deny,
findPosts: allow,
findUsers: allow,
searchResults: allow,
searchPosts: allow,
searchUsers: allow,
searchGroups: allow,
searchHashtags: allow,
embed: allow,
Category: allow,
@ -114,6 +348,9 @@ export default shield(
reports: isModerator,
statistics: allow,
currentUser: allow,
Group: isAuthenticated,
GroupMembers: isAllowedSeeingGroupMembers,
GroupCount: isAuthenticated,
Post: allow,
profilePagePosts: allow,
Comment: allow,
@ -140,7 +377,12 @@ export default shield(
Signup: or(publicRegistration, inviteRegistration, isAdmin),
SignupVerification: allow,
UpdateUser: onlyYourself,
CreatePost: isAuthenticated,
CreateGroup: isAuthenticated,
UpdateGroup: isAllowedToChangeGroupSettings,
JoinGroup: isAllowedToJoinGroup,
LeaveGroup: isAllowedToLeaveGroup,
ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole,
CreatePost: and(isAuthenticated, isMemberOfGroup),
UpdatePost: isAuthor,
DeletePost: isAuthor,
fileReport: isAuthenticated,
@ -157,7 +399,7 @@ export default shield(
unshout: isAuthenticated,
changePassword: isAuthenticated,
review: isModerator,
CreateComment: isAuthenticated,
CreateComment: and(isAuthenticated, canCommentPost),
UpdateComment: isAuthor,
DeleteComment: isAuthor,
DeleteUser: or(isDeletingOwnAccount, isAdmin),

View File

@ -26,11 +26,16 @@ export default {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User')))
return resolve(root, args, context, info)
},
CreateGroup: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Group')))
return resolve(root, args, context, info)
},
CreatePost: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
},
UpdatePost: async (resolve, root, args, context, info) => {
// TODO: is this absolutely correct? what happens if "args.title" is not defined? may it works accidentally, because "args.title" or "args.slug" is always send?
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
},

View File

@ -1,4 +1,5 @@
import slugify from 'slug'
export default async function uniqueSlug(string, isUnique) {
const slug = slugify(string || 'anonymous', {
lower: true,

View File

@ -1,29 +1,34 @@
import Factory, { cleanDatabase } from '../db/factories'
import { gql } from '../helpers/jest'
import { getNeode, getDriver } from '../db/neo4j'
import createServer from '../server'
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../db/factories'
import { createGroupMutation, updateGroupMutation } from '../graphql/groups'
import { createPostMutation } from '../graphql/posts'
import { signupVerificationMutation } from '../graphql/authentications'
let mutate
let authenticatedUser
let variables
const categoryIds = ['cat9']
const driver = getDriver()
const neode = getNeode()
const descriptionAdditional100 =
' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789'
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
const { mutate } = createTestClient(server)
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
mutate = createTestClient(server).mutate
})
afterAll(async () => {
@ -46,6 +51,7 @@ beforeEach(async () => {
await Factory.build('category', {
id: 'cat9',
name: 'Democracy & Politics',
slug: 'democracy-politics',
icon: 'university',
})
authenticatedUser = await admin.toJson()
@ -57,16 +63,242 @@ afterEach(async () => {
})
describe('slugifyMiddleware', () => {
describe('CreatePost', () => {
const categoryIds = ['cat9']
const createPostMutation = gql`
mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) {
CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) {
slug
}
describe('CreateGroup', () => {
beforeEach(() => {
variables = {
...variables,
name: 'The Best Group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
categoryIds,
}
`
})
describe('if slug not exists', () => {
it('generates a slug based on name', async () => {
await expect(
mutate({
mutation: createGroupMutation(),
variables,
}),
).resolves.toMatchObject({
data: {
CreateGroup: {
name: 'The Best Group',
slug: 'the-best-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
},
},
errors: undefined,
})
})
it('generates a slug based on given slug', async () => {
await expect(
mutate({
mutation: createGroupMutation(),
variables: {
...variables,
slug: 'the-group',
},
}),
).resolves.toMatchObject({
data: {
CreateGroup: {
slug: 'the-group',
},
},
errors: undefined,
})
})
})
describe('if slug exists', () => {
beforeEach(async () => {
await mutate({
mutation: createGroupMutation(),
variables: {
...variables,
name: 'Pre-Existing Group',
slug: 'pre-existing-group',
about: 'As an about',
},
})
})
it('chooses another slug', async () => {
await expect(
mutate({
mutation: createGroupMutation(),
variables: {
...variables,
name: 'Pre-Existing Group',
about: 'As an about',
},
}),
).resolves.toMatchObject({
data: {
CreateGroup: {
slug: 'pre-existing-group-1',
},
},
errors: undefined,
})
})
describe('but if the client specifies a slug', () => {
it('rejects CreateGroup', async (done) => {
try {
await expect(
mutate({
mutation: createGroupMutation(),
variables: {
...variables,
name: 'Pre-Existing Group',
about: 'As an about',
slug: 'pre-existing-group',
},
}),
).resolves.toMatchObject({
errors: [
{
message: 'Group with this slug already exists!',
},
],
})
done()
} catch (error) {
throw new Error(`
${error}
Probably your database has no unique constraints!
To see all constraints go to http://localhost:7474/browser/ and
paste the following:
\`\`\`
CALL db.constraints();
\`\`\`
Learn how to setup the database here:
https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints
`)
}
})
})
})
})
describe('UpdateGroup', () => {
let createGroupResult
beforeEach(async () => {
createGroupResult = await mutate({
mutation: createGroupMutation(),
variables: {
name: 'The Best Group',
slug: 'the-best-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
categoryIds,
},
})
})
describe('if group exists', () => {
describe('if new slug not(!) exists', () => {
describe('setting slug explicitly', () => {
it('has the new slug', async () => {
await expect(
mutate({
mutation: updateGroupMutation(),
variables: {
id: createGroupResult.data.CreateGroup.id,
slug: 'my-best-group',
},
}),
).resolves.toMatchObject({
data: {
UpdateGroup: {
name: 'The Best Group',
slug: 'my-best-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
myRole: 'owner',
},
},
errors: undefined,
})
})
})
})
describe('if new slug exists in another group', () => {
beforeEach(async () => {
await mutate({
mutation: createGroupMutation(),
variables: {
name: 'Pre-Existing Group',
slug: 'pre-existing-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
categoryIds,
},
})
})
describe('setting slug explicitly', () => {
it('rejects UpdateGroup', async (done) => {
try {
await expect(
mutate({
mutation: updateGroupMutation(),
variables: {
id: createGroupResult.data.CreateGroup.id,
slug: 'pre-existing-group',
},
}),
).resolves.toMatchObject({
errors: [
{
message: 'Group with this slug already exists!',
},
],
})
done()
} catch (error) {
throw new Error(`
${error}
Probably your database has no unique constraints!
To see all constraints go to http://localhost:7474/browser/ and
paste the following:
\`\`\`
CALL db.constraints();
\`\`\`
Learn how to setup the database here:
https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints
`)
}
})
})
})
})
})
describe('CreatePost', () => {
beforeEach(() => {
variables = {
...variables,
@ -76,18 +308,40 @@ describe('slugifyMiddleware', () => {
}
})
it('generates a slug based on title', async () => {
await expect(
mutate({
mutation: createPostMutation,
variables,
}),
).resolves.toMatchObject({
data: {
CreatePost: {
slug: 'i-am-a-brand-new-post',
describe('if slug not exists', () => {
it('generates a slug based on title', async () => {
await expect(
mutate({
mutation: createPostMutation(),
variables,
}),
).resolves.toMatchObject({
data: {
CreatePost: {
slug: 'i-am-a-brand-new-post',
},
},
},
errors: undefined,
})
})
it('generates a slug based on given slug', async () => {
await expect(
mutate({
mutation: createPostMutation(),
variables: {
...variables,
slug: 'the-post',
},
}),
).resolves.toMatchObject({
data: {
CreatePost: {
slug: 'the-post',
},
},
errors: undefined,
})
})
})
@ -107,16 +361,15 @@ describe('slugifyMiddleware', () => {
})
it('chooses another slug', async () => {
variables = {
...variables,
title: 'Pre-existing post',
content: 'Some content',
categoryIds,
}
await expect(
mutate({
mutation: createPostMutation,
variables,
mutation: createPostMutation(),
variables: {
...variables,
title: 'Pre-existing post',
content: 'Some content',
categoryIds,
},
}),
).resolves.toMatchObject({
data: {
@ -124,21 +377,24 @@ describe('slugifyMiddleware', () => {
slug: 'pre-existing-post-1',
},
},
errors: undefined,
})
})
describe('but if the client specifies a slug', () => {
it('rejects CreatePost', async (done) => {
variables = {
...variables,
title: 'Pre-existing post',
content: 'Some content',
slug: 'pre-existing-post',
categoryIds,
}
try {
await expect(
mutate({ mutation: createPostMutation, variables }),
mutate({
mutation: createPostMutation(),
variables: {
...variables,
title: 'Pre-existing post',
content: 'Some content',
slug: 'pre-existing-post',
categoryIds,
},
}),
).resolves.toMatchObject({
errors: [
{
@ -160,7 +416,7 @@ describe('slugifyMiddleware', () => {
\`\`\`
Learn how to setup the database here:
https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints
https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints
`)
}
})
@ -168,29 +424,9 @@ describe('slugifyMiddleware', () => {
})
})
describe('SignupVerification', () => {
const mutation = gql`
mutation (
$password: String!
$email: String!
$name: String!
$slug: String
$nonce: String!
$termsAndConditionsAgreedVersion: String!
) {
SignupVerification(
email: $email
password: $password
name: $name
slug: $slug
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
) {
slug
}
}
`
it.todo('UpdatePost')
describe('SignupVerification', () => {
beforeEach(() => {
variables = {
...variables,
@ -211,18 +447,40 @@ describe('slugifyMiddleware', () => {
})
})
it('generates a slug based on name', async () => {
await expect(
mutate({
mutation,
variables,
}),
).resolves.toMatchObject({
data: {
SignupVerification: {
slug: 'i-am-a-user',
describe('if slug not exists', () => {
it('generates a slug based on name', async () => {
await expect(
mutate({
mutation: signupVerificationMutation,
variables,
}),
).resolves.toMatchObject({
data: {
SignupVerification: {
slug: 'i-am-a-user',
},
},
},
errors: undefined,
})
})
it('generates a slug based on given slug', async () => {
await expect(
mutate({
mutation: signupVerificationMutation,
variables: {
...variables,
slug: 'the-user',
},
}),
).resolves.toMatchObject({
data: {
SignupVerification: {
slug: 'the-user',
},
},
errors: undefined,
})
})
})
@ -237,7 +495,7 @@ describe('slugifyMiddleware', () => {
it('chooses another slug', async () => {
await expect(
mutate({
mutation,
mutation: signupVerificationMutation,
variables,
}),
).resolves.toMatchObject({
@ -246,6 +504,7 @@ describe('slugifyMiddleware', () => {
slug: 'i-am-a-user-1',
},
},
errors: undefined,
})
})
@ -260,7 +519,7 @@ describe('slugifyMiddleware', () => {
it('rejects SignupVerification (on FAIL Neo4j constraints may not defined in database)', async () => {
await expect(
mutate({
mutation,
mutation: signupVerificationMutation,
variables,
}),
).resolves.toMatchObject({

View File

@ -31,7 +31,7 @@ const setPostCounter = async (postId, relation, context) => {
}
const userClickedPost = async (resolve, root, args, context, info) => {
if (args.id) {
if (args.id && context.user) {
await setPostCounter(args.id, 'CLICKED', context)
}
return resolve(root, args, context, info)

View File

@ -0,0 +1,46 @@
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
name: { type: 'string', disallow: [null], min: 3 },
slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true },
createdAt: {
type: 'string',
isoDate: true,
required: true,
default: () => new Date().toISOString(),
},
updatedAt: {
type: 'string',
isoDate: true,
required: true,
default: () => new Date().toISOString(),
},
deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false },
avatar: {
type: 'relationship',
relationship: 'AVATAR_IMAGE',
target: 'Image',
direction: 'out',
},
about: { type: 'string', allow: [null, ''] },
description: { type: 'string', disallow: [null], min: 100 },
descriptionExcerpt: { type: 'string', allow: [null] },
groupType: { type: 'string', default: 'public' },
actionRadius: { type: 'string', default: 'regional' },
myRole: { type: 'string', default: 'pending' },
locationName: { type: 'string', allow: [null] },
isIn: {
type: 'relationship',
relationship: 'IS_IN',
target: 'Location',
direction: 'out',
},
}

View File

@ -55,7 +55,7 @@ describe('slug', () => {
\`\`\`
Learn how to setup the database here:
https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints
https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints
`)
}
})

View File

@ -4,6 +4,7 @@ export default {
Image: require('./Image.js').default,
Badge: require('./Badge.js').default,
User: require('./User.js').default,
Group: require('./Group.js').default,
EmailAddress: require('./EmailAddress.js').default,
UnverifiedEmailAddress: require('./UnverifiedEmailAddress.js').default,
SocialMedia: require('./SocialMedia.js').default,

View File

@ -20,6 +20,7 @@ export default makeAugmentedSchema({
'FILED',
'REVIEWED',
'Report',
'Group',
],
},
mutation: false,

View File

@ -0,0 +1,385 @@
import { v4 as uuid } from 'uuid'
import { UserInputError } from 'apollo-server'
import CONFIG from '../../config'
import { CATEGORIES_MIN, CATEGORIES_MAX } from '../../constants/categories'
import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups'
import { removeHtmlTags } from '../../middleware/helpers/cleanHtml.js'
import Resolver, {
removeUndefinedNullValuesFromObject,
convertObjectToCypherMapLiteral,
} from './helpers/Resolver'
import { mergeImage } from './images/images'
import { createOrUpdateLocations } from './users/location'
export default {
Query: {
Group: async (_object, params, context, _resolveInfo) => {
const { isMember, id, slug, first, offset } = params
let pagination = ''
if (first !== undefined && offset !== undefined) pagination = `SKIP ${offset} LIMIT ${first}`
const matchParams = { id, slug }
removeUndefinedNullValuesFromObject(matchParams)
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (txc) => {
const groupMatchParamsCypher = convertObjectToCypherMapLiteral(matchParams, true)
let groupCypher
if (isMember === true) {
groupCypher = `
MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupMatchParamsCypher})
WITH group, membership
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role}
${pagination}
`
} else {
if (isMember === false) {
groupCypher = `
MATCH (group:Group${groupMatchParamsCypher})
WHERE (NOT (:User {id: $userId})-[:MEMBER_OF]->(group))
WITH group
WHERE group.groupType IN ['public', 'closed']
RETURN group {.*, myRole: NULL}
${pagination}
`
} else {
groupCypher = `
MATCH (group:Group${groupMatchParamsCypher})
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
WITH group, membership
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role}
${pagination}
`
}
}
const transactionResponse = await txc.run(groupCypher, {
userId: context.user.id,
})
return transactionResponse.records.map((record) => record.get('group'))
})
try {
return await readTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
GroupMembers: async (_object, params, context, _resolveInfo) => {
const { id: groupId } = params
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (txc) => {
const groupMemberCypher = `
MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId})
RETURN user {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await txc.run(groupMemberCypher, {
groupId,
})
return transactionResponse.records.map((record) => record.get('user'))
})
try {
return await readTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
GroupCount: async (_object, params, context, _resolveInfo) => {
const { isMember } = params
const {
user: { id: userId },
} = context
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (txc) => {
let cypher
if (isMember) {
cypher = `MATCH (user:User)-[membership:MEMBER_OF]->(group:Group)
WHERE user.id = $userId
AND membership.role IN ['usual', 'admin', 'owner', 'pending']
RETURN toString(count(group)) AS count`
} else {
cypher = `MATCH (group:Group)
OPTIONAL MATCH (user:User)-[membership:MEMBER_OF]->(group)
WHERE user.id = $userId
WITH group, membership
WHERE group.groupType IN ['public', 'closed']
OR membership.role IN ['usual', 'admin', 'owner']
RETURN toString(count(group)) AS count`
}
const transactionResponse = await txc.run(cypher, { userId })
return transactionResponse.records.map((record) => record.get('count'))
})
try {
return parseInt(await readTxResultPromise)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
},
Mutation: {
CreateGroup: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
delete params.categoryIds
params.locationName = params.locationName === '' ? null : params.locationName
if (CONFIG.CATEGORIES_ACTIVE && (!categoryIds || categoryIds.length < CATEGORIES_MIN)) {
throw new UserInputError('Too view categories!')
}
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > CATEGORIES_MAX) {
throw new UserInputError('Too many categories!')
}
if (
params.description === undefined ||
params.description === null ||
removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN
) {
throw new UserInputError('Description too short!')
}
params.id = params.id || uuid()
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const categoriesCypher =
CONFIG.CATEGORIES_ACTIVE && categoryIds
? `
WITH group, membership
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (group)-[:CATEGORIZED]->(category)
`
: ''
const ownerCreateGroupTransactionResponse = await transaction.run(
`
CREATE (group:Group)
SET group += $params
SET group.createdAt = toString(datetime())
SET group.updatedAt = toString(datetime())
WITH group
MATCH (owner:User {id: $userId})
MERGE (owner)-[:CREATED]->(group)
MERGE (owner)-[membership:MEMBER_OF]->(group)
SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
membership.role = 'owner'
${categoriesCypher}
RETURN group {.*, myRole: membership.role}
`,
{ userId: context.user.id, categoryIds, params },
)
const [group] = await ownerCreateGroupTransactionResponse.records.map((record) =>
record.get('group'),
)
return group
})
try {
const group = await writeTxResultPromise
// TODO: put in a middleware, see "UpdateGroup", "UpdateUser"
await createOrUpdateLocations('Group', params.id, params.locationName, session)
return group
} catch (error) {
if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('Group with this slug already exists!')
throw new Error(error)
} finally {
session.close()
}
},
UpdateGroup: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
delete params.categoryIds
const { id: groupId, avatar: avatarInput } = params
delete params.avatar
params.locationName = params.locationName === '' ? null : params.locationName
if (CONFIG.CATEGORIES_ACTIVE && categoryIds) {
if (categoryIds.length < CATEGORIES_MIN) {
throw new UserInputError('Too view categories!')
}
if (categoryIds.length > CATEGORIES_MAX) {
throw new UserInputError('Too many categories!')
}
}
if (
params.description &&
removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN
) {
throw new UserInputError('Description too short!')
}
const session = context.driver.session()
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
MATCH (group:Group {id: $groupId})-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations
RETURN group, category
`
await session.writeTransaction((transaction) => {
return transaction.run(cypherDeletePreviousRelations, { groupId })
})
}
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let updateGroupCypher = `
MATCH (group:Group {id: $groupId})
SET group += $params
SET group.updatedAt = toString(datetime())
WITH group
`
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
updateGroupCypher += `
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (group)-[:CATEGORIZED]->(category)
WITH group
`
}
updateGroupCypher += `
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
RETURN group {.*, myRole: membership.role}
`
const transactionResponse = await transaction.run(updateGroupCypher, {
groupId,
userId: context.user.id,
categoryIds,
params,
})
const [group] = await transactionResponse.records.map((record) => record.get('group'))
if (avatarInput) {
await mergeImage(group, 'AVATAR_IMAGE', avatarInput, { transaction })
}
return group
})
try {
const group = await writeTxResultPromise
// TODO: put in a middleware, see "CreateGroup", "UpdateUser"
await createOrUpdateLocations('Group', params.id, params.locationName, session)
return group
} catch (error) {
if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('Group with this slug already exists!')
throw new Error(error)
} finally {
session.close()
}
},
JoinGroup: async (_parent, params, context, _resolveInfo) => {
const { groupId, userId } = params
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const joinGroupCypher = `
MATCH (member:User {id: $userId}), (group:Group {id: $groupId})
MERGE (member)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
membership.role =
CASE WHEN group.groupType = 'public'
THEN 'usual'
ELSE 'pending'
END
RETURN member {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId })
const [member] = await transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
LeaveGroup: async (_parent, params, context, _resolveInfo) => {
const { groupId, userId } = params
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const leaveGroupCypher = `
MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
DELETE membership
WITH member, group
OPTIONAL MATCH (p:Post)-[:IN]->(group)
WHERE NOT group.groupType = 'public'
WITH member, group, collect(p) AS posts
FOREACH (post IN posts |
MERGE (member)-[:CANNOT_SEE]->(post))
RETURN member {.*, myRoleInGroup: NULL}
`
const transactionResponse = await transaction.run(leaveGroupCypher, { groupId, userId })
const [member] = await transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
ChangeGroupMemberRole: async (_parent, params, context, _resolveInfo) => {
const { groupId, userId, roleInGroup } = params
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let postRestrictionCypher = ''
if (['usual', 'admin', 'owner'].includes(roleInGroup)) {
postRestrictionCypher = `
WITH group, member, membership
FOREACH (restriction IN [(member)-[r:CANNOT_SEE]->(:Post)-[:IN]->(group) | r] |
DELETE restriction)`
} else {
postRestrictionCypher = `
WITH group, member, membership
FOREACH (post IN [(p:Post)-[:IN]->(group) | p] |
MERGE (member)-[:CANNOT_SEE]->(post))`
}
const joinGroupCypher = `
MATCH (member:User {id: $userId})
MATCH (group:Group {id: $groupId})
MERGE (member)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
membership.role = $roleInGroup
ON MATCH SET
membership.updatedAt = toString(datetime()),
membership.role = $roleInGroup
${postRestrictionCypher}
RETURN member {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await transaction.run(joinGroupCypher, {
groupId,
userId,
roleInGroup,
})
const [member] = await transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
},
Group: {
...Resolver('Group', {
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
hasMany: {
categories: '-[:CATEGORIZED]->(related:Category)',
posts: '<-[:IN]-(related:Post)',
},
hasOne: {
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
location: '-[:IS_IN]->(related:Location)',
},
}),
},
}

File diff suppressed because it is too large Load Diff

View File

@ -121,3 +121,25 @@ export default function Resolver(type, options = {}) {
}
return result
}
export const removeUndefinedNullValuesFromObject = (obj) => {
Object.keys(obj).forEach((key) => {
if ([undefined, null].includes(obj[key])) {
delete obj[key]
}
})
}
export const convertObjectToCypherMapLiteral = (params, addSpaceInfrontIfMapIsNotEmpty = false) => {
// I have found no other way yet. maybe "apoc.convert.fromJsonMap(key)" can help, but couldn't get it how, see: https://stackoverflow.com/questions/43217823/neo4j-cypher-inline-conversion-of-string-to-a-map
// result looks like: '{id: "g0", slug: "yoga"}'
const paramsEntries = Object.entries(params)
let mapLiteral = ''
paramsEntries.forEach((ele, index) => {
mapLiteral += index === 0 ? '{' : ''
mapLiteral += `${ele[0]}: "${ele[1]}"`
mapLiteral += index < paramsEntries.length - 1 ? ', ' : '}'
})
mapLiteral = (addSpaceInfrontIfMapIsNotEmpty && mapLiteral.length > 0 ? ' ' : '') + mapLiteral
return mapLiteral
}

View File

@ -0,0 +1,47 @@
import { mergeWith, isArray } from 'lodash'
const getInvisiblePosts = async (context) => {
const session = context.driver.session()
const readTxResultPromise = await session.readTransaction(async (transaction) => {
let cypher = ''
const { user } = context
if (user && user.id) {
cypher = `
MATCH (post:Post)<-[:CANNOT_SEE]-(user:User { id: $userId })
RETURN collect(post.id) AS invisiblePostIds`
} else {
cypher = `
MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
RETURN collect(post.id) AS invisiblePostIds`
}
const invisiblePostIdsResponse = await transaction.run(cypher, {
userId: user ? user.id : null,
})
return invisiblePostIdsResponse.records.map((record) => record.get('invisiblePostIds'))
})
try {
const [invisiblePostIds] = readTxResultPromise
return invisiblePostIds
} finally {
session.close()
}
}
export const filterInvisiblePosts = async (params, context) => {
const invisiblePostIds = await getInvisiblePosts(context)
if (!invisiblePostIds.length) return params
params.filter = mergeWith(
params.filter,
{
id_not_in: invisiblePostIds,
},
(objValue, srcValue) => {
if (isArray(objValue)) {
return objValue.concat(srcValue)
}
},
)
return params
}

View File

@ -5,6 +5,7 @@ import { UserInputError } from 'apollo-server'
import { mergeImage, deleteImage } from './images/images'
import Resolver from './helpers/Resolver'
import { filterForMutedUsers } from './helpers/filterForMutedUsers'
import { filterInvisiblePosts } from './helpers/filterInvisiblePosts'
import CONFIG from '../../config'
const maintainPinnedPosts = (params) => {
@ -20,15 +21,13 @@ const maintainPinnedPosts = (params) => {
export default {
Query: {
Post: async (object, params, context, resolveInfo) => {
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
params = await maintainPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo)
},
findPosts: async (object, params, context, resolveInfo) => {
params = await filterForMutedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo)
},
profilePagePosts: async (object, params, context, resolveInfo) => {
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo)
},
@ -77,13 +76,37 @@ export default {
},
Mutation: {
CreatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
const { categoryIds, groupId } = params
const { image: imageInput } = params
delete params.categoryIds
delete params.image
delete params.groupId
params.id = params.id || uuid()
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let groupCypher = ''
if (groupId) {
groupCypher = `
WITH post MATCH (group:Group { id: $groupId })
MERGE (post)-[:IN]->(group)`
const groupTypeResponse = await transaction.run(
`
MATCH (group:Group { id: $groupId }) RETURN group.groupType AS groupType`,
{ groupId },
)
const [groupType] = groupTypeResponse.records.map((record) => record.get('groupType'))
if (groupType !== 'public')
groupCypher += `
WITH post, group
MATCH (user:User)-[membership:MEMBER_OF]->(group)
WHERE group.groupType IN ['closed', 'hidden']
AND membership.role IN ['usual', 'admin', 'owner']
WITH post, collect(user.id) AS userIds
OPTIONAL MATCH path =(restricted:User) WHERE NOT restricted.id IN userIds
FOREACH (user IN nodes(path) |
MERGE (user)-[:CANNOT_SEE]->(post)
)`
}
const categoriesCypher =
CONFIG.CATEGORIES_ACTIVE && categoryIds
? `WITH post
@ -103,9 +126,10 @@ export default {
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
${categoriesCypher}
${groupCypher}
RETURN post {.*}
`,
{ userId: context.user.id, params, categoryIds },
{ userId: context.user.id, categoryIds, groupId, params },
)
const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
if (imageInput) {
@ -131,11 +155,11 @@ export default {
delete params.image
const session = context.driver.session()
let updatePostCypher = `
MATCH (post:Post {id: $params.id})
SET post += $params
SET post.updatedAt = toString(datetime())
WITH post
`
MATCH (post:Post {id: $params.id})
SET post += $params
SET post.updatedAt = toString(datetime())
WITH post
`
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
@ -367,6 +391,7 @@ export default {
author: '<-[:WROTE]-(related:User)',
pinnedBy: '<-[:PINNED]-(related:User)',
image: '-[:HERO_IMAGE]->(related:Image)',
group: '-[:IN]->(related:Group)',
},
count: {
commentsCount:

View File

@ -368,7 +368,7 @@ describe('UpdatePost', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { UpdatePost: null },
})

File diff suppressed because it is too large Load Diff

View File

@ -72,19 +72,19 @@ const signupCypher = (inviteCode) => {
(inviteCode:InviteCode {code: $inviteCode})<-[:GENERATED]-(host:User)
`
optionalMerge = `
MERGE(user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE(host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE(user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE(host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE (host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
`
}
const cypher = `
MATCH(email:EmailAddress {nonce: $nonce, email: $email})
MATCH (email:EmailAddress {nonce: $nonce, email: $email})
WHERE NOT (email)-[:BELONGS_TO]->()
${optionalMatch}
CREATE (user:User)
MERGE(user)-[:PRIMARY_EMAIL]->(email)
MERGE(user)<-[:BELONGS_TO]-(email)
MERGE (user)-[:PRIMARY_EMAIL]->(email)
MERGE (user)<-[:BELONGS_TO]-(email)
${optionalMerge}
SET user += $args
SET user.id = randomUUID()
@ -95,6 +95,13 @@ const signupCypher = (inviteCode) => {
SET user.showShoutsPublicly = false
SET user.sendNotificationEmails = true
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
WITH user, collect(post) AS invisiblePosts
FOREACH (invisiblePost IN invisiblePosts |
MERGE (user)-[:CANNOT_SEE]->(invisiblePost)
)
RETURN user {.*}
`
return cypher

View File

@ -23,12 +23,15 @@ const postWhereClause = `WHERE score >= 0.0
AND NOT (
author.deleted = true OR author.disabled = true
OR resource.deleted = true OR resource.disabled = true
OR (:User {id: $userId})-[:MUTED]->(author)
)`
) AND block IS NULL AND restriction IS NULL`
const searchPostsSetup = {
fulltextIndex: 'post_fulltext_search',
match: 'MATCH (resource:Post)<-[:WROTE]-(author:User)',
match: `MATCH (resource:Post)<-[:WROTE]-(author:User)
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[block:MUTED]->(author)
OPTIONAL MATCH (user)-[restriction:CANNOT_SEE]->(resource)
WITH user, resource, author, block, restriction`,
whereClause: postWhereClause,
withClause: `WITH resource, author,
[(resource)<-[:COMMENTS]-(comment:Comment) | comment] AS comments,
@ -63,6 +66,21 @@ const searchHashtagsSetup = {
limit: 'LIMIT $limit',
}
const searchGroupsSetup = {
fulltextIndex: 'group_fulltext_search',
match: `MATCH (resource:Group)
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[membership:MEMBER_OF]->(resource)
WITH user, resource, membership`,
whereClause: `WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
AND (resource.groupType IN ['public', 'closed']
OR membership.role IN ['usual', 'admin', 'owner'])`,
withClause: 'WITH resource, membership',
returnClause: 'resource { .*, myRole: membership.role, __typename: labels(resource)[0] }',
limit: 'LIMIT $limit',
}
const countSetup = {
returnClause: 'toString(size(collect(resource)))',
limit: '',
@ -80,6 +98,10 @@ const countHashtagsSetup = {
...searchHashtagsSetup,
...countSetup,
}
const countGroupsSetup = {
...searchGroupsSetup,
...countSetup,
}
const searchResultPromise = async (session, setup, params) => {
return session.readTransaction(async (transaction) => {
@ -110,14 +132,15 @@ const multiSearchMap = [
{ symbol: '!', setup: searchPostsSetup, resultName: 'posts' },
{ symbol: '@', setup: searchUsersSetup, resultName: 'users' },
{ symbol: '#', setup: searchHashtagsSetup, resultName: 'hashtags' },
{ symbol: '&', setup: searchGroupsSetup, resultName: 'groups' },
]
export default {
Query: {
searchPosts: async (_parent, args, context, _resolveInfo) => {
const { query, postsOffset, firstPosts } = args
const { id: userId } = context.user
let userId = null
if (context.user) userId = context.user.id
return {
postCount: getSearchResults(
context,
@ -175,12 +198,36 @@ export default {
}),
}
},
searchGroups: async (_parent, args, context, _resolveInfo) => {
const { query, groupsOffset, firstGroups } = args
let userId = null
if (context.user) userId = context.user.id
return {
groupCount: getSearchResults(
context,
countGroupsSetup,
{
query: queryString(query),
skip: 0,
userId,
},
countResultCallback,
),
groups: getSearchResults(context, searchGroupsSetup, {
query: queryString(query),
skip: groupsOffset,
limit: firstGroups,
userId,
}),
}
},
searchResults: async (_parent, args, context, _resolveInfo) => {
const { query, limit } = args
const { id: userId } = context.user
let userId = null
if (context.user) userId = context.user.id
const searchType = query.replace(/^([!@#]?).*$/, '$1')
const searchString = query.replace(/^([!@#])/, '')
const searchType = query.replace(/^([!@#&]?).*$/, '$1')
const searchString = query.replace(/^([!@#&])/, '')
const params = {
query: queryString(searchString),
@ -193,6 +240,7 @@ export default {
return [
...(await getSearchResults(context, searchPostsSetup, params)),
...(await getSearchResults(context, searchUsersSetup, params)),
...(await getSearchResults(context, searchGroupsSetup, params)),
...(await getSearchResults(context, searchHashtagsSetup, params)),
]

View File

@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken'
import CONFIG from './../../config'
import Factory, { cleanDatabase } from '../../db/factories'
import { gql } from '../../helpers/jest'
import { loginMutation } from '../../graphql/userManagement'
import { createTestClient } from 'apollo-server-testing'
import createServer, { context } from '../../server'
import encode from '../../jwt/encode'
@ -225,12 +226,6 @@ describe('currentUser', () => {
})
describe('login', () => {
const loginMutation = gql`
mutation ($email: String!, $password: String!) {
login(email: $email, password: $password)
}
`
const respondsWith = async (expected) => {
await expect(mutate({ mutation: loginMutation, variables })).resolves.toMatchObject(expected)
}

View File

@ -4,7 +4,7 @@ import { UserInputError, ForbiddenError } from 'apollo-server'
import { mergeImage, deleteImage } from './images/images'
import Resolver from './helpers/Resolver'
import log from './helpers/databaseLogger'
import createOrUpdateLocations from './users/location'
import { createOrUpdateLocations } from './users/location'
const neode = getNeode()
@ -139,9 +139,10 @@ export default {
return blockedUser.toJson()
},
UpdateUser: async (_parent, params, context, _resolveInfo) => {
const { termsAndConditionsAgreedVersion } = params
const { avatar: avatarInput } = params
delete params.avatar
params.locationName = params.locationName === '' ? null : params.locationName
const { termsAndConditionsAgreedVersion } = params
if (termsAndConditionsAgreedVersion) {
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)
if (!regEx.test(termsAndConditionsAgreedVersion)) {
@ -169,7 +170,8 @@ export default {
})
try {
const user = await writeTxResultPromise
await createOrUpdateLocations(params.id, params.locationName, session)
// TODO: put in a middleware, see "CreateGroup", "UpdateGroup"
await createOrUpdateLocations('User', params.id, params.locationName, session)
return user
} catch (error) {
throw new UserInputError(error.message)

View File

@ -161,7 +161,7 @@ describe('UpdateUser', () => {
$id: ID!
$name: String
$termsAndConditionsAgreedVersion: String
$locationName: String
$locationName: String # empty string '' sets it to null
) {
UpdateUser(
id: $id
@ -174,6 +174,11 @@ describe('UpdateUser', () => {
termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt
locationName
location {
name
nameDE
nameEN
}
}
}
`
@ -289,11 +294,39 @@ describe('UpdateUser', () => {
expect(errors[0]).toHaveProperty('message', 'Invalid version format!')
})
it('supports updating location', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States' } },
errors: undefined,
describe('supports updating location', () => {
describe('change location to "Hamburg, New Jersey, United States"', () => {
it('has updated location to "Hamburg, New Jersey, United States"', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: {
UpdateUser: {
locationName: 'Hamburg, New Jersey, United States',
location: expect.objectContaining({
name: 'Hamburg',
nameDE: 'Hamburg',
nameEN: 'Hamburg',
}),
},
},
errors: undefined,
})
})
})
describe('change location to unset location', () => {
it('has updated location to unset location', async () => {
variables = { ...variables, locationName: '' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: {
UpdateUser: {
locationName: null,
location: null,
},
},
errors: undefined,
})
})
})
})
})

View File

@ -1,6 +1,5 @@
import request from 'request'
import { UserInputError } from 'apollo-server'
import isEmpty from 'lodash/isEmpty'
import Debug from 'debug'
import asyncForEach from '../../../helpers/asyncForEach'
import CONFIG from '../../../config'
@ -62,77 +61,86 @@ const createLocation = async (session, mapboxData) => {
})
}
const createOrUpdateLocations = async (userId, locationName, session) => {
if (isEmpty(locationName)) {
return
}
const res = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
locationName,
)}.json?access_token=${CONFIG.MAPBOX_TOKEN}&types=region,place,country&language=${locales.join(
',',
)}`,
)
export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, session) => {
if (locationName === undefined) return
debug(res)
let locationId
if (!res || !res.features || !res.features[0]) {
throw new UserInputError('locationName is invalid')
}
if (locationName !== null) {
const res = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
locationName,
)}.json?access_token=${
CONFIG.MAPBOX_TOKEN
}&types=region,place,country&language=${locales.join(',')}`,
)
let data
debug(res)
res.features.forEach((item) => {
if (item.matching_place_name === locationName) {
data = item
if (!res || !res.features || !res.features[0]) {
throw new UserInputError('locationName is invalid')
}
})
if (!data) {
data = res.features[0]
}
if (!data || !data.place_type || !data.place_type.length) {
throw new UserInputError('locationName is invalid')
}
let data
if (data.place_type.length > 1) {
data.id = 'region.' + data.id.split('.')[1]
}
await createLocation(session, data)
let parent = data
if (data.context) {
await asyncForEach(data.context, async (ctx) => {
await createLocation(session, ctx)
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId})
MERGE (child)<-[:IS_IN]-(parent)
RETURN child.id, parent.id
`,
{
parentId: parent.id,
childId: ctx.id,
},
)
})
parent = ctx
res.features.forEach((item) => {
if (item.matching_place_name === locationName) {
data = item
}
})
if (!data) {
data = res.features[0]
}
if (!data || !data.place_type || !data.place_type.length) {
throw new UserInputError('locationName is invalid')
}
if (data.place_type.length > 1) {
data.id = 'region.' + data.id.split('.')[1]
}
await createLocation(session, data)
let parent = data
if (data.context) {
await asyncForEach(data.context, async (ctx) => {
await createLocation(session, ctx)
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId})
MERGE (child)<-[:IS_IN]-(parent)
RETURN child.id, parent.id
`,
{
parentId: parent.id,
childId: ctx.id,
},
)
})
parent = ctx
})
}
locationId = data.id
} else {
locationId = 'non-existent-id'
}
// delete all current locations from user and add new location
// delete all current locations from node and add new location
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (user:User {id: $userId})-[relationship:IS_IN]->(location:Location)
DETACH DELETE relationship
WITH user
MATCH (location:Location {id: $locationId})
MERGE (user)-[:IS_IN]->(location)
RETURN location.id, user.id
`,
{ userId: userId, locationId: data.id },
MATCH (node:${nodeLabel} {id: $nodeId})
OPTIONAL MATCH (node)-[relationship:IS_IN]->(:Location)
DELETE relationship
WITH node
MATCH (location:Location {id: $locationId})
MERGE (node)-[:IS_IN]->(location)
RETURN location.id, node.id
`,
{ nodeId, locationId },
)
})
}
@ -147,5 +155,3 @@ export const queryLocations = async ({ place, lang }) => {
}
return res.features
}
export default createOrUpdateLocations

View File

@ -0,0 +1,7 @@
enum GroupActionRadius {
regional
national
continental
global
interplanetary
}

View File

@ -0,0 +1,6 @@
enum GroupMemberRole {
pending
usual
admin
owner
}

View File

@ -0,0 +1,5 @@
enum GroupType {
public
closed
hidden
}

View File

@ -0,0 +1,135 @@
enum _GroupOrdering {
id_asc
id_desc
name_asc
name_desc
slug_asc
slug_desc
locationName_asc
locationName_desc
about_asc
about_desc
createdAt_asc
createdAt_desc
updatedAt_asc
updatedAt_desc
}
type Group {
id: ID!
name: String! # title
slug: String!
createdAt: String!
updatedAt: String!
deleted: Boolean
disabled: Boolean
avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT")
about: String # goal
description: String!
descriptionExcerpt: String!
groupType: GroupType!
actionRadius: GroupActionRadius!
locationName: String
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
myRole: GroupMemberRole # if 'null' then the current user is no member
posts: [Post] @relation(name: "IN", direction: "IN")
}
input _GroupFilter {
AND: [_GroupFilter!]
OR: [_GroupFilter!]
name_contains: String
slug_contains: String
about_contains: String
description_contains: String
groupType_in: [GroupType!]
actionRadius_in: [GroupActionRadius!]
myRole_in: [GroupMemberRole!]
id: ID
id_not: ID
id_in: [ID!]
id_not_in: [ID!]
}
type Query {
Group(
isMember: Boolean # if 'undefined' or 'null' then get all groups
id: ID
slug: String
first: Int
offset: Int
# orderBy: [_GroupOrdering] # not implemented yet
# filter: _GroupFilter # not implemented yet
): [Group]
GroupMembers(
id: ID!
# first: Int # not implemented yet
# offset: Int # not implemented yet
# orderBy: [_UserOrdering] # not implemented yet
# filter: _UserFilter # not implemented yet
): [User]
GroupCount(isMember: Boolean): Int
# AvailableGroupTypes: [GroupType]!
# AvailableGroupActionRadii: [GroupActionRadius]!
# AvailableGroupMemberRoles: [GroupMemberRole]!
}
type Mutation {
CreateGroup(
id: ID
name: String!
slug: String
about: String
description: String!
groupType: GroupType!
actionRadius: GroupActionRadius!
categoryIds: [ID]
# avatar: ImageInput # a group can not be created with an avatar
locationName: String # empty string '' sets it to null
): Group
UpdateGroup(
id: ID!
name: String
slug: String
about: String
description: String
# groupType: GroupType # is not possible at the moment and has to be discussed. may be in the stronger direction: public → closed → hidden
actionRadius: GroupActionRadius
categoryIds: [ID]
avatar: ImageInput # test this as result
locationName: String # empty string '' sets it to null
): Group
# DeleteGroup(id: ID!): Group
JoinGroup(
groupId: ID!
userId: ID!
): User
LeaveGroup(
groupId: ID!
userId: ID!
): User
ChangeGroupMemberRole(
groupId: ID!
userId: ID!
roleInGroup: GroupMemberRole!
): User
}

View File

@ -0,0 +1,5 @@
type MEMBER_OF {
createdAt: String!
updatedAt: String!
role: GroupMemberRole!
}

View File

@ -81,6 +81,7 @@ input _PostFilter {
emotions_none: _PostEMOTEDFilter
emotions_single: _PostEMOTEDFilter
emotions_every: _PostEMOTEDFilter
group: _GroupFilter
}
enum _PostOrdering {
@ -167,6 +168,8 @@ type Post {
emotions: [EMOTED]
emotionsCount: Int!
@cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)")
group: Group @relation(name: "IN", direction: "OUT")
}
input _PostInput {
@ -184,6 +187,7 @@ type Mutation {
language: String
categoryIds: [ID]
contentExcerpt: String
groupId: ID
): Post
UpdatePost(
id: ID!
@ -225,18 +229,4 @@ type Query {
PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int!
PostsEmotionsByCurrentUser(postId: ID!): [String]
profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post]
findPosts(query: String!, limit: Int = 10, filter: _PostFilter): [Post]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('post_fulltext_search', $query)
YIELD node as post, score
MATCH (post)<-[:WROTE]-(user:User)
WHERE score >= 0.2
AND NOT user.deleted = true AND NOT user.disabled = true
AND NOT post.deleted = true AND NOT post.disabled = true
AND NOT user.id in COALESCE($filter.author_not.id_in, [])
RETURN post
LIMIT $limit
"""
)
}

View File

@ -1,4 +1,4 @@
union SearchResult = Post | User | Tag
union SearchResult = Post | User | Tag | Group
type postSearchResults {
postCount: Int
@ -15,9 +15,15 @@ type hashtagSearchResults {
hashtags: [Tag]!
}
type groupSearchResults {
groupCount: Int
groups: [Group]!
}
type Query {
searchPosts(query: String!, firstPosts: Int, postsOffset: Int): postSearchResults!
searchUsers(query: String!, firstUsers: Int, usersOffset: Int): userSearchResults!
searchGroups(query: String!, firstGroups: Int, groupsOffset: Int): groupSearchResults!
searchHashtags(query: String!, firstHashtags: Int, hashtagsOffset: Int): hashtagSearchResults!
searchResults(query: String!, limit: Int = 5): [SearchResult]!
}

View File

@ -33,8 +33,8 @@ type User {
invitedBy: User @relation(name: "INVITED", direction: "IN")
invited: [User] @relation(name: "INVITED", direction: "OUT")
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
locationName: String
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
about: String
socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN")
@ -122,6 +122,8 @@ type User {
RETURN collect(category.id)
"""
)
myRoleInGroup: GroupMemberRole
}
@ -164,19 +166,19 @@ input _UserFilter {
type Query {
User(
id: ID
email: String # admins need to search for a user sometimes
name: String
slug: String
role: UserRole
locationName: String
about: String
createdAt: String
updatedAt: String
first: Int
offset: Int
orderBy: [_UserOrdering]
filter: _UserFilter
id: ID
email: String # admins need to search for a user sometimes
name: String
slug: String
role: UserRole
locationName: String
about: String
createdAt: String
updatedAt: String
first: Int
offset: Int
orderBy: [_UserOrdering]
filter: _UserFilter
): [User]
availableRoles: [UserRole]!
@ -184,18 +186,6 @@ type Query {
blockedUsers: [User]
isLoggedIn: Boolean!
currentUser: User
findUsers(query: String!,limit: Int = 10, filter: _UserFilter): [User]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('user_fulltext_search', $query)
YIELD node as post, score
MATCH (user)
WHERE score >= 0.2
AND NOT user.deleted = true AND NOT user.disabled = true
RETURN user
LIMIT $limit
"""
)
}
enum Deletable {
@ -205,19 +195,19 @@ enum Deletable {
type Mutation {
UpdateUser (
id: ID!
name: String
email: String
slug: String
avatar: ImageInput
locationName: String
about: String
termsAndConditionsAgreedVersion: String
termsAndConditionsAgreedAt: String
allowEmbedIframes: Boolean
showShoutsPublicly: Boolean
sendNotificationEmails: Boolean
locale: String
id: ID!
name: String
email: String
slug: String
avatar: ImageInput
locationName: String # empty string '' sets it to null
about: String
termsAndConditionsAgreedVersion: String
termsAndConditionsAgreedAt: String
allowEmbedIframes: Boolean
showShoutsPublicly: Boolean
sendNotificationEmails: Boolean
locale: String
): User
DeleteUser(id: ID!, resource: [Deletable]): User

View File

@ -997,9 +997,9 @@
tslib "1.11.1"
"@hapi/address@2.x.x":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.2.tgz#1c794cd6dbf2354d1eb1ef10e0303f573e1c7222"
integrity sha512-O4QDrx+JoGKZc6aN64L04vqa7e41tIiLU+OvKdcYaEMP97UttL0f9GIi9/0A4WAMx0uBd6SidDIhktZhgOcN8Q==
version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
integrity sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ==
"@hapi/address@^4.0.1":
version "4.0.1"
@ -1018,10 +1018,10 @@
resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128"
integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==
"@hapi/hoek@8.x.x":
version "8.2.4"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.2.4.tgz#684a14f4ca35d46f44abc87dfc696e5e4fe8a020"
integrity sha512-Ze5SDNt325yZvNO7s5C4fXDscjJ6dcqLFXJQ/M7dZRQCewuDj2iDUuBi6jLQt+APbW9RjjVEvLr35FXuOEqjow==
"@hapi/hoek@8.x.x", "@hapi/hoek@^8.3.0":
version "8.5.1"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06"
integrity sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow==
"@hapi/hoek@^9.0.0":
version "9.0.0"
@ -1055,11 +1055,11 @@
integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==
"@hapi/topo@3.x.x":
version "3.1.3"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.3.tgz#c7a02e0d936596d29f184e6d7fdc07e8b5efce11"
integrity sha512-JmS9/vQK6dcUYn7wc2YZTqzIKubAQcJKu2KCKAru6es482U5RT5fP1EXCPtlXpiK7PR0On/kpQKI4fRKkzpZBQ==
version "3.1.6"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.6.tgz#68d935fa3eae7fdd5ab0d7f953f3205d8b2bfc29"
integrity sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ==
dependencies:
"@hapi/hoek" "8.x.x"
"@hapi/hoek" "^8.3.0"
"@hapi/topo@^5.0.0":
version "5.0.0"
@ -2681,6 +2681,11 @@ base64-js@^1.0.2:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base@^0.11.1:
version "0.11.2"
resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
@ -2850,6 +2855,14 @@ buffer@4.9.1:
ieee754 "^1.1.4"
isarray "^1.0.0"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.2.1"
busboy@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.3.1.tgz#170899274c5bf38aae27d5c62b71268cd585fd1b"
@ -3929,7 +3942,7 @@ dot-prop@^4.1.0:
dotenv@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d"
integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0=
integrity sha512-XcaMACOr3JMVcEv0Y/iUM2XaOsATRZ3U1In41/1jjK6vJZ2PZbQ1bzCG8uvaByfaBpl9gqc9QWJovpUGBXLLYQ==
dotenv@^6.1.0:
version "6.2.0"
@ -5516,6 +5529,11 @@ ieee754@1.1.13, ieee754@^1.1.4:
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ienoopen@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974"
@ -7528,18 +7546,19 @@ negotiator@0.6.2:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
neo4j-driver-bolt-connection@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.3.4.tgz#de642bb6a62ffc6ae2e280dccf21395b4d1705a2"
integrity sha512-yxbvwGav+N7EYjcEAINqL6D3CZV+ee2qLInpAhx+iNurwbl3zqtBGiVP79SZ+7tU++y3Q1fW5ofikH06yc+LqQ==
neo4j-driver-bolt-connection@^4.4.7:
version "4.4.7"
resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.4.7.tgz#0582d54de1f213e60c374209193d1f645ba523ea"
integrity sha512-6Q4hCtvWE6gzN64N09UqZqf/3rDl7FUWZZXiVQL0ZRbaMkJpZNC2NmrDIgGXYE05XEEbRBexf2tVv5OTYZYrow==
dependencies:
neo4j-driver-core "^4.3.4"
text-encoding-utf-8 "^1.0.2"
buffer "^6.0.3"
neo4j-driver-core "^4.4.7"
string_decoder "^1.3.0"
neo4j-driver-core@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.3.4.tgz#b445a4fbf94dce8441075099bd6ac3133c1cf5ee"
integrity sha512-3tn3j6IRUNlpXeehZ9Xv7dLTZPB4a7APaoJ+xhQyMmYQO3ujDM4RFHc0pZcG+GokmaltT5pUCIPTDYx6ODdhcA==
neo4j-driver-core@^4.4.7:
version "4.4.7"
resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.4.7.tgz#d2475e107b3fea2b9d1c36b0c273da5c5a291c37"
integrity sha512-NhvVuQYgG7eO/vXxRaoJfkWUNkjvIpmCIS9UWU9Bbhb4V+wCOyX/MVOXqD0Yizhs4eyIkD7x90OXb79q+vi+oA==
neo4j-driver@^4.0.1, neo4j-driver@^4.0.2:
version "4.0.2"
@ -7552,13 +7571,13 @@ neo4j-driver@^4.0.1, neo4j-driver@^4.0.2:
uri-js "^4.2.2"
neo4j-driver@^4.2.2:
version "4.3.4"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.3.4.tgz#a54f0562f868ee94dff7509df74e3eb2c1f95a85"
integrity sha512-AGrsFFqnoZv4KhJdmKt4mOBV5mnxmV3+/t8KJTOM68jQuEWoy+RlmAaRRaCSU4eY586OFN/R8lg9MrJpZdSFjw==
version "4.4.7"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.4.7.tgz#51b3fb48241e66eb3be94e90032cc494c44e59f3"
integrity sha512-N7GddPhp12gVJe4eB84u5ik5SmrtRv8nH3rK47Qy7IUKnJkVEos/F1QjOJN6zt1jLnDXwDcGzCKK8XklYpzogw==
dependencies:
"@babel/runtime" "^7.5.5"
neo4j-driver-bolt-connection "^4.3.4"
neo4j-driver-core "^4.3.4"
neo4j-driver-bolt-connection "^4.4.7"
neo4j-driver-core "^4.4.7"
rxjs "^6.6.3"
neo4j-graphql-js@^2.11.5:
@ -9603,7 +9622,7 @@ string.prototype.trimstart@^1.0.1:
define-properties "^1.1.3"
es-abstract "^1.17.5"
string_decoder@^1.1.1:
string_decoder@^1.1.1, string_decoder@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==

View File

@ -1,7 +1,7 @@
import { When } from "cypress-cucumber-preprocessor/steps";
When('I click on the author', () => {
cy.get('.user-teaser')
cy.get('[data-test="avatarUserLink"]')
.click()
.url().should('include', '/profile/')
})

View File

@ -5,7 +5,7 @@ Then("I should see my comment", () => {
.should("contain", "Ocelot.social rocks")
.get(".user-teaser span.slug")
.should("contain", "@peter-pan") // specific enough
.get(".user-avatar img")
.get(".profile-avatar img")
.should("have.attr", "src")
.and("contain", 'https://') // some url
.get(".user-teaser > .info > .text")

View File

@ -4,5 +4,5 @@ Then("I cannot upload a picture", () => {
cy.get(".base-card")
.children()
.should("not.have.id", "customdropzone")
.should("have.class", "user-avatar");
.should("have.class", "profile-avatar");
});

View File

@ -9,7 +9,7 @@ Then("I should be able to change my profile picture", () => {
{ subjectType: "drag-n-drop", force: true }
);
});
cy.get(".profile-avatar img")
cy.get(".profile-page-avatar img")
.should("have.attr", "src")
.and("contains", "onourjourney");
cy.contains(".iziToast-message", "Upload successful")

View File

@ -1,6 +1,6 @@
{
"name": "ocelot-social",
"version": "1.1.1",
"version": "2.2.0",
"description": "Free and open source software program code available to run social networks.",
"author": "ocelot.social Community",
"license": "MIT",

View File

@ -4,4 +4,4 @@ PUBLIC_REGISTRATION=false
INVITE_REGISTRATION=true
WEBSOCKETS_URI=ws://localhost:3000/api/graphql
GRAPHQL_URI=http://localhost:4000/
CATEGORIES_ACTIVE=false
CATEGORIES_ACTIVE=false

View File

@ -268,6 +268,7 @@ $size-avatar-large: 114px;
* @presenter Spacing
*/
$size-button-large: 50px;
$size-button-base: 36px;
$size-button-small: 26px;

View File

@ -42,9 +42,9 @@ describe('AvatarMenu.vue', () => {
wrapper = Wrapper()
})
it('renders the UserAvatar component', () => {
it('renders the ProfileAvatar component', () => {
wrapper.find('.avatar-menu-trigger').trigger('click')
expect(wrapper.find('.user-avatar').exists()).toBe(true)
expect(wrapper.find('.profile-avatar').exists()).toBe(true)
})
describe('given a userName', () => {
@ -90,6 +90,13 @@ describe('AvatarMenu.vue', () => {
expect(profileLink.exists()).toBe(true)
})
it('displays a link to "Groups"', () => {
const profileLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex((route) => route.path === '/groups'))
expect(profileLink.exists()).toBe(true)
})
it('displays a link to the notifications page', () => {
const notificationsLink = wrapper
.findAll('.ds-menu-item span')
@ -103,6 +110,11 @@ describe('AvatarMenu.vue', () => {
.at(wrapper.vm.routes.findIndex((route) => route.path === '/settings'))
expect(settingsLink.exists()).toBe(true)
})
it('displays a total of 4 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(4)
})
})
describe('role moderator', () => {
@ -125,9 +137,9 @@ describe('AvatarMenu.vue', () => {
expect(moderationLink.exists()).toBe(true)
})
it('displays a total of 4 links', () => {
it('displays a total of 5 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(4)
expect(allLinks).toHaveLength(5)
})
})
@ -151,9 +163,9 @@ describe('AvatarMenu.vue', () => {
expect(adminLink.exists()).toBe(true)
})
it('displays a total of 5 links', () => {
it('displays a total of 6 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(5)
expect(allLinks).toHaveLength(6)
})
})
})

View File

@ -11,7 +11,7 @@
"
@click.prevent="toggleMenu"
>
<user-avatar :user="user" size="small" />
<profile-avatar :profile="user" size="small" />
<base-icon class="dropdown-arrow" name="angle-down" />
</a>
</template>
@ -50,12 +50,12 @@
<script>
import { mapGetters } from 'vuex'
import Dropdown from '~/components/Dropdown'
import UserAvatar from '~/components/_new/generic/UserAvatar/UserAvatar'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
export default {
components: {
Dropdown,
UserAvatar,
ProfileAvatar,
},
props: {
placement: { type: String, default: 'top-end' },
@ -72,10 +72,15 @@ export default {
}
const routes = [
{
name: this.$t('profile.name'),
name: this.$t('header.avatarMenu.myProfile'),
path: `/profile/${this.user.id}/${this.user.slug}`,
icon: 'user',
},
{
name: this.$t('header.avatarMenu.Groups'),
path: '/groups',
icon: 'users',
},
{
name: this.$t('notifications.pageLink'),
path: '/notifications',
@ -130,7 +135,7 @@ export default {
align-items: center;
padding-left: $space-xx-small;
> .user-avatar {
> .profile-avatar {
margin-right: $space-xx-small;
}
}

View File

@ -19,7 +19,6 @@ import { followUserMutation, unfollowUserMutation } from '~/graphql/User'
export default {
name: 'HcFollowButton',
props: {
followId: { type: String, default: null },
isFollowed: { type: Boolean, default: false },

View File

@ -0,0 +1,136 @@
<template>
<base-button
class="join-leave-button"
:disabled="disabled"
:loading="localLoading"
:icon="icon"
:filled="isMember && !hovered"
:danger="isMember && hovered"
@mouseenter.native="onHover"
@mouseleave.native="hovered = false"
@click.prevent="toggle"
>
{{ label }}
</base-button>
</template>
<script>
import { mapMutations } from 'vuex'
import { joinGroupMutation, leaveGroupMutation } from '~/graphql/groups'
export default {
name: 'JoinLeaveButton',
props: {
group: { type: Object, required: true },
userId: { type: String, required: true },
isMember: { type: Boolean, required: true },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
},
data() {
return {
localLoading: this.loading,
hovered: false,
}
},
computed: {
icon() {
if (this.isMember && this.hovered) {
return 'close'
} else {
return this.isMember ? 'check' : 'plus'
}
},
label() {
if (this.isMember) {
return this.$t('group.joinLeaveButton.iAmMember')
} else {
return this.$t('group.joinLeaveButton.join')
}
},
},
watch: {
isMember() {
this.localLoading = false
this.hovered = false
},
loading() {
this.localLoading = this.loading
},
},
methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
}),
onHover() {
if (!this.disabled && !this.localLoading) {
this.hovered = true
}
},
toggle() {
if (this.isMember) {
this.openLeaveModal()
} else {
this.joinLeave()
}
},
openLeaveModal() {
this.commitModalData(this.leaveModalData())
},
leaveModalData() {
return {
name: 'confirm',
data: {
type: '',
resource: { id: '' },
modalData: {
titleIdent: 'group.leaveModal.title',
messageIdent: 'group.leaveModal.message',
messageParams: {
name: this.group.name,
},
buttons: {
confirm: {
danger: true,
icon: 'sign-out',
textIdent: 'group.leaveModal.confirmButton',
callback: this.joinLeave,
},
cancel: {
icon: 'close',
textIdent: 'actions.cancel',
callback: () => {},
},
},
},
},
}
},
async joinLeave() {
const join = !this.isMember
const mutation = join ? joinGroupMutation() : leaveGroupMutation()
this.hovered = false
this.$emit('prepare', join)
try {
const { data } = await this.$apollo.mutate({
mutation,
variables: { groupId: this.group.id, userId: this.userId },
})
const joinedLeftGroupResult = join ? data.JoinGroup : data.LeaveGroup
this.$emit('update', joinedLeftGroupResult)
} catch (error) {
this.$toast.error(error.message)
}
},
},
}
</script>
<style lang="scss">
.join-leave-button {
display: block;
width: 100%;
}
</style>

View File

@ -12,7 +12,6 @@
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
delay: { show: 1500 },
}"
>
{{ $t(`contribution.category.name.${category.slug}`) }}
@ -22,6 +21,7 @@
<script>
import CategoryQuery from '~/graphql/CategoryQuery'
import { CATEGORIES_MAX } from '~/constants/categories.js'
import xor from 'lodash/xor'
export default {
@ -37,7 +37,7 @@ export default {
data() {
return {
categories: null,
selectedMax: 3,
selectedMax: CATEGORIES_MAX,
selectedCategoryIds: this.existingCategoryIds,
}
},

View File

@ -7,7 +7,6 @@
icon="ellipsis-v"
size="small"
circle
ghost
@click.prevent="toggleMenu()"
/>
</slot>

View File

@ -0,0 +1,38 @@
import { config, mount } from '@vue/test-utils'
import GroupContentMenu from './GroupContentMenu.vue'
const localVue = global.localVue
config.stubs['router-link'] = '<span><slot /></span>'
const propsData = {
usage: 'groupTeaser',
resource: {},
group: {},
resourceType: 'group',
}
describe('GroupContentMenu', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupContentMenu, { propsData, mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.findAll('.group-content-menu')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,98 @@
<template>
<dropdown class="group-content-menu" :placement="placement" offset="5">
<template #default="{ toggleMenu }">
<slot name="button" :toggleMenu="toggleMenu">
<base-button
icon="ellipsis-v"
size="small"
circle
@click.prevent="toggleMenu()"
data-test="group-menu-button"
/>
</slot>
</template>
<template #popover="{ toggleMenu }">
<div class="group-menu-popover">
<ds-menu :routes="routes">
<template #menuitem="item">
{{ item.parents }}
<ds-menu-item
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<base-icon :name="item.route.icon" />
{{ item.route.label }}
</ds-menu-item>
</template>
</ds-menu>
</div>
</template>
</dropdown>
</template>
<script>
import Dropdown from '~/components/Dropdown'
export default {
name: 'GroupContentMenu',
components: {
Dropdown,
},
props: {
usage: {
type: String,
required: true,
validator: (value) => {
return value.match(/(groupTeaser|groupProfile)/)
},
},
group: { type: Object, required: true },
placement: { type: String, default: 'bottom-end' },
},
computed: {
routes() {
const routes = []
if (this.usage !== 'groupProfile') {
routes.push({
label: this.$t('group.contentMenu.visitGroupPage'),
icon: 'home',
name: 'group-id-slug',
params: { id: this.group.id, slug: this.group.slug },
})
}
if (this.group.myRole === 'owner') {
routes.push({
label: this.$t('admin.settings.name'),
path: `/group/edit/${this.group.id}`,
icon: 'edit',
})
}
return routes
},
},
methods: {
openItem(route, toggleMenu) {
if (route.callback) {
route.callback()
} else {
this.$router.push(route)
}
toggleMenu()
},
},
}
</script>
<style lang="scss">
.group-menu-popover {
nav {
margin-top: -$space-xx-small;
margin-bottom: -$space-xx-small;
margin-left: -$space-x-small;
margin-right: -$space-x-small;
}
}
</style>

View File

@ -4,7 +4,7 @@ import ContributionForm from './ContributionForm.vue'
import Vuex from 'vuex'
import PostMutations from '~/graphql/PostMutations.js'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
import ImageUploader from '~/components/Uploader/ImageUploader'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
@ -138,6 +138,7 @@ describe('ContributionForm.vue', () => {
categoryIds: [],
id: null,
image: null,
groupId: null,
},
}
postTitleInput = wrapper.find('.ds-input')
@ -260,6 +261,7 @@ describe('ContributionForm.vue', () => {
content: propsData.contribution.content,
categoryIds: [],
id: propsData.contribution.id,
groupId: null,
image: {
sensitive: false,
},

View File

@ -83,7 +83,7 @@ import { mapGetters } from 'vuex'
import HcEditor from '~/components/Editor/Editor'
import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
import ImageUploader from '~/components/Uploader/ImageUploader'
import links from '~/constants/links.js'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
@ -99,6 +99,10 @@ export default {
type: Object,
default: () => ({}),
},
groupId: {
type: String,
default: () => null,
},
},
data() {
const { title, content, image, categories } = this.contribution
@ -173,6 +177,7 @@ export default {
categoryIds,
id: this.contribution.id || null,
image,
groupId: this.groupId,
},
})
.then(({ data }) => {

View File

@ -59,20 +59,22 @@ describe('CategoriesFilter.vue', () => {
describe('mount', () => {
it('starts with all categories button active', () => {
const allCategoriesButton = wrapper.find('.categories-filter .sidebar .base-button')
const allCategoriesButton = wrapper.find('.categories-filter .item-all-topics .base-button')
expect(allCategoriesButton.attributes().class).toContain('--filled')
})
it('sets category button attribute `filled` when corresponding category is filtered', async () => {
getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9'])
wrapper = await Wrapper()
democracyAndPoliticsButton = wrapper.findAll('.categories-filter .item .base-button').at(2)
democracyAndPoliticsButton = wrapper.find('.categories-filter .item-save-topics .base-button')
expect(democracyAndPoliticsButton.attributes().class).toContain('--filled')
})
describe('click on an "catetories-buttons" button', () => {
it('calls TOGGLE_CATEGORY when clicked', () => {
environmentAndNatureButton = wrapper.findAll('.categories-filter .item .base-button').at(0)
environmentAndNatureButton = wrapper
.findAll('.categories-filter .item-category .base-button')
.at(0)
environmentAndNatureButton.trigger('click')
expect(mutations['posts/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat4')
})
@ -82,7 +84,7 @@ describe('CategoriesFilter.vue', () => {
it('when all button is clicked', async () => {
getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9'])
wrapper = await Wrapper()
const allCategoriesButton = wrapper.find('.categories-filter .sidebar .base-button')
const allCategoriesButton = wrapper.find('.categories-filter .item-all-topics .base-button')
allCategoriesButton.trigger('click')
expect(mutations['posts/RESET_CATEGORIES']).toHaveBeenCalledTimes(1)
})
@ -91,7 +93,7 @@ describe('CategoriesFilter.vue', () => {
describe('save categories', () => {
it('calls the API', async () => {
wrapper = await Wrapper()
const saveButton = wrapper.findAll('.categories-filter .sidebar .base-button').at(1)
const saveButton = wrapper.find('.categories-filter .item-save-topics .base-button')
saveButton.trigger('click')
expect(apolloMutationMock).toBeCalled()
})

View File

@ -1,17 +1,23 @@
<template>
<filter-menu-section :title="$t('filter-menu.categories')" class="categories-filter">
<template #sidebar>
<labeled-button
:filled="!filteredCategoryIds.length"
:label="$t('filter-menu.all')"
icon="check"
@click="resetCategories"
/>
<template #filter-topics>
<li class="item item-all-topics">
<labeled-button
:filled="!filteredCategoryIds.length"
:label="$t('filter-menu.all')"
icon="check"
@click="resetCategories"
/>
</li>
<li class="item item-save-topics">
<labeled-button filled :label="$t('actions.save')" icon="save" @click="saveCategories" />
</li>
<hr />
<labeled-button filled :label="$t('actions.save')" icon="save" @click="saveCategories" />
<ds-space margin="base" />
</template>
<template #filter-list>
<li v-for="category in categories" :key="category.id" class="item">
<li v-for="category in categories" :key="category.id" class="item item-category">
<labeled-button
:icon="category.icon"
:filled="filteredCategoryIds.includes(category.id)"
@ -20,7 +26,6 @@
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
delay: { show: 1500 },
}"
/>
</li>
@ -40,6 +45,9 @@ export default {
FilterMenuSection,
LabeledButton,
},
props: {
showMobileMenu: { type: Boolean, default: false },
},
data() {
return {
categories: [],

View File

@ -1,64 +1,79 @@
import { mount } from '@vue/test-utils'
import Vuex from 'vuex'
// import Vuex from 'vuex'
import EmotionsFilter from './EmotionsFilter'
const localVue = global.localVue
let wrapper, happyEmotionButton
describe('EmotionsFilter', () => {
const mutations = {
'posts/TOGGLE_EMOTION': jest.fn(),
'posts/RESET_EMOTIONS': jest.fn(),
}
const getters = {
'posts/filteredByEmotions': jest.fn(() => []),
}
const mocks = {
$t: jest.fn((string) => string),
}
// let wrapper, happyEmotionButton
describe('mount', () => {
let wrapper
const Wrapper = () => {
const store = new Vuex.Store({ mutations, getters })
return mount(EmotionsFilter, { mocks, localVue, store })
return mount(EmotionsFilter, { localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('mount', () => {
it('starts with all emotions button active', () => {
const allEmotionsButton = wrapper.find('.emotions-filter .sidebar .base-button')
expect(allEmotionsButton.attributes().class).toContain('--filled')
})
describe('click on an "emotion-button" button', () => {
it('calls TOGGLE_EMOTION when clicked', () => {
const wrapper = Wrapper()
happyEmotionButton = wrapper.findAll('.emotion-button > .base-button').at(1)
happyEmotionButton.trigger('click')
expect(mutations['posts/TOGGLE_EMOTION']).toHaveBeenCalledWith({}, 'happy')
})
it('sets the attribute `src` to colorized image', () => {
getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
const wrapper = Wrapper()
happyEmotionButton = wrapper.findAll('.emotion-button > .base-button').at(1)
const happyEmotionButtonImage = happyEmotionButton.find('img')
expect(happyEmotionButtonImage.attributes().src).toEqual('/img/svg/emoji/happy_color.svg')
})
})
describe('clears filter', () => {
it('when all button is clicked', async () => {
getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
wrapper = await Wrapper()
const allEmotionsButton = wrapper.find('.emotions-filter .sidebar .base-button')
allEmotionsButton.trigger('click')
expect(mutations['posts/RESET_EMOTIONS']).toHaveBeenCalledTimes(1)
})
})
it('renders button DIV', () => {
expect(wrapper.find('div').exists()).toBe(true)
})
})
// describe('EmotionsFilter', () => {
// const mutations = {
// 'posts/TOGGLE_EMOTION': jest.fn(),
// 'posts/RESET_EMOTIONS': jest.fn(),
// }
// const getters = {
// 'posts/filteredByEmotions': jest.fn(() => []),
// }
// const mocks = {
// $t: jest.fn((string) => string),
// }
// const Wrapper = () => {
// const store = new Vuex.Store({ mutations, getters })
// return mount(EmotionsFilter, { mocks, localVue, store })
// }
// beforeEach(() => {
// wrapper = Wrapper()
// })
// describe('mount', () => {
// it('starts with all emotions button active', () => {
// const allEmotionsButton = wrapper.find('.emotions-filter .sidebar .base-button')
// expect(allEmotionsButton.attributes().class).toContain('--filled')
// })
// describe('click on an "emotion-button" button', () => {
// it('calls TOGGLE_EMOTION when clicked', () => {
// const wrapper = Wrapper()
// happyEmotionButton = wrapper.findAll('.emotion-button > .base-button').at(1)
// happyEmotionButton.trigger('click')
// expect(mutations['posts/TOGGLE_EMOTION']).toHaveBeenCalledWith({}, 'happy')
// })
// it('sets the attribute `src` to colorized image', () => {
// getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
// const wrapper = Wrapper()
// happyEmotionButton = wrapper.findAll('.emotion-button > .base-button').at(1)
// const happyEmotionButtonImage = happyEmotionButton.find('img')
// expect(happyEmotionButtonImage.attributes().src).toEqual('/img/svg/emoji/happy_color.svg')
// })
// })
// describe('clears filter', () => {
// it('when all button is clicked', async () => {
// getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
// wrapper = await Wrapper()
// const allEmotionsButton = wrapper.find('.emotions-filter .sidebar .base-button')
// allEmotionsButton.trigger('click')
// expect(mutations['posts/RESET_EMOTIONS']).toHaveBeenCalledTimes(1)
// })
// })
// })
// })

View File

@ -1,5 +1,6 @@
<template>
<filter-menu-section :title="$t('filter-menu.emotions')" class="emotions-filter">
<div>
<!-- <filter-menu-section :title="$t('filter-menu.emotions')" class="emotions-filter">
<template #sidebar>
<labeled-button
:filled="!filteredByEmotions.length"
@ -17,43 +18,44 @@
/>
</li>
</template>
</filter-menu-section>
</filter-menu-section> -->
</div>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
import EmotionButton from '~/components/EmotionButton/EmotionButton'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
// import { mapGetters, mapMutations } from 'vuex'
// import EmotionButton from '~/components/EmotionButton/EmotionButton'
// import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
// import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
export default {
components: {
EmotionButton,
FilterMenuSection,
LabeledButton,
},
data() {
return {
emotionsArray: ['funny', 'happy', 'surprised', 'cry', 'angry'],
}
},
computed: {
...mapGetters({
filteredByEmotions: 'posts/filteredByEmotions',
currentUser: 'auth/user',
}),
},
methods: {
...mapMutations({
resetEmotions: 'posts/RESET_EMOTIONS',
toogleFilteredByEmotions: 'posts/TOGGLE_EMOTION',
}),
iconPath(emotion) {
if (this.filteredByEmotions.includes(emotion)) {
return `/img/svg/emoji/${emotion}_color.svg`
}
return `/img/svg/emoji/${emotion}.svg`
},
},
}
// export default {
// components: {
// EmotionButton,
// FilterMenuSection,
// LabeledButton,
// },
// data() {
// return {
// emotionsArray: ['funny', 'happy', 'surprised', 'cry', 'angry'],
// }
// },
// computed: {
// ...mapGetters({
// filteredByEmotions: 'posts/filteredByEmotions',
// currentUser: 'auth/user',
// }),
// },
// methods: {
// ...mapMutations({
// resetEmotions: 'posts/RESET_EMOTIONS',
// toogleFilteredByEmotions: 'posts/TOGGLE_EMOTION',
// }),
// iconPath(emotion) {
// if (this.filteredByEmotions.includes(emotion)) {
// return `/img/svg/emoji/${emotion}_color.svg`
// }
// return `/img/svg/emoji/${emotion}.svg`
// },
// },
// }
</script>

View File

@ -14,6 +14,7 @@
<div class="filter-menu-options">
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
<following-filter />
<categories-filter v-if="categoriesActive" :showMobileMenu="showMobileMenu" />
</div>
<div class="filter-menu-options">
<h2 class="title">{{ $t('filter-menu.order-by') }}</h2>
@ -28,16 +29,24 @@ import Dropdown from '~/components/Dropdown'
import { mapGetters } from 'vuex'
import FollowingFilter from './FollowingFilter'
import OrderByFilter from './OrderByFilter'
import CategoriesFilter from './CategoriesFilter'
export default {
components: {
Dropdown,
FollowingFilter,
OrderByFilter,
CategoriesFilter,
},
data() {
return {
categoriesActive: this.$env.CATEGORIES_ACTIVE,
}
},
props: {
placement: { type: String },
offset: { type: [String, Number] },
showMobileMenu: { type: Boolean, default: false },
},
computed: {
...mapGetters({

View File

@ -1,10 +1,17 @@
<template>
<section class="filter-menu-section">
<h3 v-if="title" class="title">{{ title }}</h3>
<aside class="sidebar">
<ul class="filter-list">
<slot name="filter-follower" />
</ul>
<ul class="filter-list">
<slot name="filter-topics" />
</ul>
<!-- <aside class="sidebar">
<slot name="sidebar" />
</aside>
<div v-if="divider" class="divider" />
</aside> -->
<!-- <div v-if="divider" class="divider" /> -->
<ul class="filter-list">
<slot name="filter-list" />
</ul>
@ -14,10 +21,10 @@
<script>
export default {
props: {
divider: {
type: Boolean,
default: true,
},
// divider: {
// type: Boolean,
// default: true,
// },
title: {
type: String,
},
@ -37,42 +44,35 @@ export default {
font-size: $font-size-base;
}
> .sidebar {
display: flex;
flex-wrap: wrap;
flex-basis: 80%;
flex-grow: 1;
max-width: $size-width-filter-sidebar;
}
// > .sidebar {
// display: flex;
// flex-wrap: wrap;
// flex-basis: 80%;
// flex-grow: 1;
// max-width: $size-width-filter-sidebar;
// }
> .divider {
border-left: $border-size-base solid $border-color-soft;
margin: $space-small;
margin-left: 0;
}
// > .divider {
// // border-left: $border-size-base solid $border-color-soft;
// margin: $space-small;
// margin-left: 0;
// border-top: $border-size-base solid $border-color-soft;
// }
> .filter-list {
display: flex;
flex-wrap: wrap;
flex-basis: 80%;
flex-basis: 100%;
flex-grow: 1;
> .item {
width: 50%;
width: 30%;
padding: 0 $space-x-small;
margin-bottom: $space-small;
text-align: center;
@media only screen and (max-width: 800px) {
width: 50%;
}
@media only screen and (max-width: 630px) {
width: 40%;
}
@media only screen and (max-width: 440px) {
width: 30%;
@media only screen and (min-width: 800px) {
width: 20%;
}
}
}
@ -84,14 +84,14 @@ export default {
text-align: center;
}
> .sidebar {
max-width: none;
}
// > .sidebar {
// max-width: none;
// }
> .divider {
border-top: $border-size-base solid $border-color-soft;
margin: $space-small;
}
// > .divider {
// border-top: $border-size-base solid $border-color-soft;
// margin: $space-small;
// }
}
}
</style>

View File

@ -35,12 +35,16 @@ describe('FollowingFilter', () => {
it('sets "filter-by-followed" button attribute `filled`', () => {
getters['posts/filteredByUsersFollowed'] = jest.fn(() => true)
const wrapper = Wrapper()
expect(wrapper.find('.following-filter .sidebar .base-button').classes('--filled')).toBe(true)
expect(
wrapper
.find('.following-filter .filter-list .follower-item .base-button')
.classes('--filled'),
).toBe(true)
})
describe('click "filter-by-followed" button', () => {
it('calls TOGGLE_FILTER_BY_FOLLOWED', () => {
wrapper.find('.following-filter .sidebar .base-button').trigger('click')
wrapper.find('.following-filter .filter-list .follower-item .base-button').trigger('click')
expect(mutations['posts/TOGGLE_FILTER_BY_FOLLOWED']).toHaveBeenCalledWith({}, 'u34')
})
})

View File

@ -1,13 +1,15 @@
<template>
<filter-menu-section :divider="false" class="following-filter">
<template #sidebar>
<labeled-button
icon="user-plus"
:label="$t('filter-menu.following')"
:filled="filteredByUsersFollowed"
:title="$t('contribution.filterFollow')"
@click="toggleFilteredByFollowed(currentUser.id)"
/>
<template #filter-follower>
<li class="item follower-item">
<labeled-button
icon="user-plus"
:label="$t('filter-menu.following')"
:filled="filteredByUsersFollowed"
:title="$t('contribution.filterFollow')"
@click="toggleFilteredByFollowed(currentUser.id)"
/>
</li>
</template>
</filter-menu-section>
</template>

View File

@ -1,70 +1,85 @@
import { mount } from '@vue/test-utils'
import Vuex from 'vuex'
import locales from '~/locales'
import orderBy from 'lodash/orderBy'
// import Vuex from 'vuex'
// import locales from '~/locales'
// import orderBy from 'lodash/orderBy'
import LanguagesFilter from './LanguagesFilter'
const localVue = global.localVue
let wrapper, englishButton, spanishButton
// let wrapper, englishButton, spanishButton
const languages = orderBy(locales, 'name')
describe('LanguagesFilter.vue', () => {
const mutations = {
'posts/TOGGLE_LANGUAGE': jest.fn(),
'posts/RESET_LANGUAGES': jest.fn(),
}
const getters = {
'posts/filteredLanguageCodes': jest.fn(() => []),
}
const mocks = {
$t: jest.fn((string) => string),
}
// const languages = orderBy(locales, 'name')
describe('mount', () => {
let wrapper
const Wrapper = () => {
const store = new Vuex.Store({ mutations, getters })
return mount(LanguagesFilter, { mocks, localVue, store })
return mount(LanguagesFilter, { localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('mount', () => {
it('starts with all categories button active', () => {
const allLanguagesButton = wrapper.find('.languages-filter .sidebar .base-button')
expect(allLanguagesButton.attributes().class).toContain('--filled')
})
it('sets language button attribute `filled` when corresponding language is filtered', () => {
getters['posts/filteredLanguageCodes'] = jest.fn(() => ['es'])
const wrapper = Wrapper()
spanishButton = wrapper
.findAll('.languages-filter .item .base-button')
.at(languages.findIndex((l) => l.code === 'es'))
expect(spanishButton.attributes().class).toContain('--filled')
})
describe('click on an "language-button" button', () => {
it('calls TOGGLE_LANGUAGE when clicked', () => {
const wrapper = Wrapper()
englishButton = wrapper
.findAll('.languages-filter .item .base-button')
.at(languages.findIndex((l) => l.code === 'en'))
englishButton.trigger('click')
expect(mutations['posts/TOGGLE_LANGUAGE']).toHaveBeenCalledWith({}, 'en')
})
})
describe('clears filter', () => {
it('when all button is clicked', async () => {
getters['posts/filteredLanguageCodes'] = jest.fn(() => ['en'])
wrapper = await Wrapper()
const allLanguagesButton = wrapper.find('.languages-filter .sidebar .base-button')
allLanguagesButton.trigger('click')
expect(mutations['posts/RESET_LANGUAGES']).toHaveBeenCalledTimes(1)
})
})
it('renders button DIV', () => {
expect(wrapper.find('div').exists()).toBe(true)
})
})
// describe('LanguagesFilter.vue', () => {
// const mutations = {
// 'posts/TOGGLE_LANGUAGE': jest.fn(),
// 'posts/RESET_LANGUAGES': jest.fn(),
// }
// const getters = {
// 'posts/filteredLanguageCodes': jest.fn(() => []),
// }
// const mocks = {
// $t: jest.fn((string) => string),
// }
// const Wrapper = () => {
// const store = new Vuex.Store({ mutations, getters })
// return mount(LanguagesFilter, { mocks, localVue, store })
// }
// beforeEach(() => {
// wrapper = Wrapper()
// })
// describe('mount', () => {
// it('starts with all categories button active', () => {
// const allLanguagesButton = wrapper.find('.languages-filter .sidebar .base-button')
// expect(allLanguagesButton.attributes().class).toContain('--filled')
// })
// it('sets language button attribute `filled` when corresponding language is filtered', () => {
// getters['posts/filteredLanguageCodes'] = jest.fn(() => ['es'])
// const wrapper = Wrapper()
// spanishButton = wrapper
// .findAll('.languages-filter .item .base-button')
// .at(languages.findIndex((l) => l.code === 'es'))
// expect(spanishButton.attributes().class).toContain('--filled')
// })
// describe('click on an "language-button" button', () => {
// it('calls TOGGLE_LANGUAGE when clicked', () => {
// const wrapper = Wrapper()
// englishButton = wrapper
// .findAll('.languages-filter .item .base-button')
// .at(languages.findIndex((l) => l.code === 'en'))
// englishButton.trigger('click')
// expect(mutations['posts/TOGGLE_LANGUAGE']).toHaveBeenCalledWith({}, 'en')
// })
// })
// describe('clears filter', () => {
// it('when all button is clicked', async () => {
// getters['posts/filteredLanguageCodes'] = jest.fn(() => ['en'])
// wrapper = await Wrapper()
// const allLanguagesButton = wrapper.find('.languages-filter .sidebar .base-button')
// allLanguagesButton.trigger('click')
// expect(mutations['posts/RESET_LANGUAGES']).toHaveBeenCalledTimes(1)
// })
// })
// })
// })

View File

@ -1,7 +1,9 @@
<template>
<filter-menu-section :title="$t('filter-menu.languages')" class="languages-filter">
<template #sidebar>
<div>
<!-- <filter-menu-section :title="$t('filter-menu.languages')" class="languages-filter">
<template>
<labeled-button
class="filter-languages"
:filled="!filteredLanguageCodes.length"
:label="$t('filter-menu.all')"
icon="check"
@ -19,36 +21,37 @@
</base-button>
</li>
</template>
</filter-menu-section>
</filter-menu-section> -->
</div>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
import orderBy from 'lodash/orderBy'
import locales from '~/locales'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
// import { mapGetters, mapMutations } from 'vuex'
// import orderBy from 'lodash/orderBy'
// import locales from '~/locales'
// import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
// import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
export default {
components: {
FilterMenuSection,
LabeledButton,
},
computed: {
...mapGetters({
filteredLanguageCodes: 'posts/filteredLanguageCodes',
}),
},
methods: {
...mapMutations({
resetLanguages: 'posts/RESET_LANGUAGES',
toggleLanguage: 'posts/TOGGLE_LANGUAGE',
}),
},
data() {
return {
locales: orderBy(locales, 'name'),
}
},
}
// export default {
// components: {
// FilterMenuSection,
// LabeledButton,
// },
// computed: {
// ...mapGetters({
// filteredLanguageCodes: 'posts/filteredLanguageCodes',
// }),
// },
// methods: {
// ...mapMutations({
// resetLanguages: 'posts/RESET_LANGUAGES',
// toggleLanguage: 'posts/TOGGLE_LANGUAGE',
// }),
// },
// data() {
// return {
// locales: orderBy(locales, 'name'),
// }
// },
// }
</script>

View File

@ -0,0 +1,5 @@
<template>
<div>
<nuxt-link to="/groups"><base-button icon="users" circle ghost /></nuxt-link>
</div>
</template>

View File

@ -0,0 +1,39 @@
import { config, mount } from '@vue/test-utils'
import GroupForm from './GroupForm.vue'
const localVue = global.localVue
config.stubs['nuxt-link'] = '<span><slot /></span>'
const propsData = {
update: false,
group: {},
}
describe('GroupForm', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
$env: {
CATEGORIES_ACTIVE: true,
},
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupForm, { propsData, mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.findAll('.group-form')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,440 @@
<template>
<div>
<ds-form
class="group-form"
ref="groupForm"
v-model="formData"
:schema="formSchema"
@submit="submit"
>
<!-- "errors" is only working if you use a submit event on the form -->
<template #default="{ errors }">
<!-- group Name -->
<ds-input
name="name"
:label="$t('group.name')"
model="name"
autofocus
:placeholder="`${$t('group.name')} …`"
/>
<ds-chip size="base" :color="errors && errors.name ? 'danger' : 'medium'">
{{ `${formData.name.length} / ${formSchema.name.min}${formSchema.name.max}` }}
<base-icon v-if="errors && errors.name" name="warning" />
</ds-chip>
<!-- group Slug -->
<ds-input
v-if="update"
:label="$t('group.labelSlug')"
model="slug"
icon="at"
:placeholder="`${$t('group.labelSlug')} …`"
></ds-input>
<ds-space v-if="update" margin-top="small" />
<!-- groupType -->
<ds-text class="select-label">
{{ $t('group.type') }}
</ds-text>
<!-- TODO: change it has to be implemented later -->
<!-- TODO: move 'ds-select' from style guide to main code and implement missing translation etc. functionality -->
<select
class="select ds-input appearance--auto"
name="groupType"
model="groupType"
:value="formData.groupType"
:disabled="update"
@change="changeGroupType($event)"
>
<option v-for="groupType in groupTypeOptions" :key="groupType" :value="groupType">
{{ $t(`group.types.${groupType}`) }}
</option>
</select>
<ds-chip
size="base"
:color="errors && errors.groupType && formData.groupType === '' ? 'danger' : 'medium'"
>
{{ `${formData.groupType === '' ? 0 : 1} / 1` }}
<base-icon
v-if="errors && errors.groupType && formData.groupType === ''"
name="warning"
/>
</ds-chip>
<!-- goal -->
<ds-input
name="about"
:label="$t('group.goal')"
v-model="formData.about"
:placeholder="$t('group.goal') + ' …'"
rows="3"
/>
<ds-space margin-top="small" />
<!-- description -->
<ds-text class="select-label">
{{ $t('group.description') }}
</ds-text>
<editor
name="description"
model="description"
:users="null"
:value="formData.description"
:hashtags="null"
@input="updateEditorDescription"
/>
<ds-chip size="base" :color="errors && errors.description ? 'danger' : 'medium'">
{{ `${descriptionLength} / ${formSchema.description.min}` }}
<base-icon v-if="errors && errors.description" name="warning" />
</ds-chip>
<!-- actionRadius -->
<ds-text class="select-label">
{{ $t('group.actionRadius') }}
</ds-text>
<!-- TODO: move 'ds-select' from styleguide to main code and implement missing translation etc. functionality -->
<select
class="select ds-input appearance--auto"
name="actionRadius"
:value="formData.actionRadius"
@change="changeActionRadius($event)"
>
<option
v-for="actionRadius in actionRadiusOptions"
:key="actionRadius"
:value="actionRadius"
>
{{ $t(`group.actionRadii.${actionRadius}`) }}
</option>
</select>
<ds-chip
size="base"
:color="
errors && errors.actionRadius && formData.actionRadius === '' ? 'danger' : 'medium'
"
>
{{ `${formData.actionRadius === '' ? 0 : 1} / 1` }}
<base-icon
v-if="errors && errors.actionRadius && formData.actionRadius === ''"
name="warning"
/>
</ds-chip>
<!-- location -->
<ds-select
id="city"
:label="$t('settings.data.labelCity') + locationNameLabelAddOnOldName"
v-model="formData.locationName"
:options="cities"
icon="map-marker"
:icon-right="null"
:placeholder="$t('settings.data.labelCity') + ' …'"
:loading="loadingGeo"
@input.native="handleCityInput"
/>
<base-button
v-if="formLocationName !== ''"
icon="close"
ghost
size="small"
style="position: relative; display: inline-block; right: -96%; top: -33px; width: 26px"
@click="formData.locationName = ''"
></base-button>
<ds-space margin-top="small" />
<!-- category -->
<categories-select
v-if="categoriesActive"
model="categoryIds"
name="categoryIds"
:existingCategoryIds="formData.categoryIds"
/>
<ds-chip
v-if="categoriesActive"
size="base"
:color="errors && errors.categoryIds ? 'danger' : 'medium'"
>
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<!-- submit -->
<ds-space margin-top="large">
<nuxt-link to="/groups">
<ds-button>{{ $t('actions.cancel') }}</ds-button>
</nuxt-link>
<ds-button type="submit" icon="save" primary :disabled="checkFormError(errors)" fill>
{{ update ? $t('group.update') : $t('group.save') }}
</ds-button>
</ds-space>
</template>
</ds-form>
</div>
</template>
<script>
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import { CATEGORIES_MIN, CATEGORIES_MAX } from '~/constants/categories.js'
import {
NAME_LENGTH_MIN,
NAME_LENGTH_MAX,
DESCRIPTION_WITHOUT_HTML_LENGTH_MIN,
} from '~/constants/groups.js'
import Editor from '~/components/Editor/Editor'
import { queryLocations } from '~/graphql/location'
let timeout
export default {
name: 'GroupForm',
components: {
CategoriesSelect,
Editor,
},
props: {
update: {
type: Boolean,
required: false,
default: false,
},
group: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
const { name, slug, groupType, about, description, actionRadius, locationName, categories } =
this.group
return {
categoriesActive: this.$env.CATEGORIES_ACTIVE,
disabled: false,
groupTypeOptions: ['public', 'closed', 'hidden'],
actionRadiusOptions: ['regional', 'national', 'continental', 'global'],
loadingGeo: false,
cities: [],
formData: {
name: name || '',
slug: slug || '',
groupType: groupType || '',
about: about || '',
description: description || '',
// from database 'locationName' comes as "string | null"
// 'formData.locationName':
// see 'created': tries to set it to a "requestGeoData" object and fills the menu if possible
// if user selects one from menu we get a "requestGeoData" object here
// "requestGeoData" object: "{ id: String, label: String, value: String }"
// otherwise it's a string: empty or none empty
locationName: locationName || '',
actionRadius: actionRadius || '',
categoryIds: categories ? categories.map((category) => category.id) : [],
},
formSchema: {
name: { required: true, min: NAME_LENGTH_MIN, max: NAME_LENGTH_MAX },
slug: { required: false, min: NAME_LENGTH_MIN },
groupType: { required: true, min: 1 },
about: { required: false },
description: {
type: 'string',
required: true,
min: DESCRIPTION_WITHOUT_HTML_LENGTH_MIN,
validator: (_, value = '') => {
if (this.$filters.removeHtml(value).length < this.formSchema.description.min) {
return [new Error()]
}
return []
},
},
actionRadius: { required: true, min: 1 },
locationName: { required: false },
categoryIds: {
type: 'array',
required: this.categoriesActive,
validator: (_, value = []) => {
if (
this.categoriesActive &&
(value.length < CATEGORIES_MIN || value.length > CATEGORIES_MAX)
) {
return [new Error(this.$t('common.validations.categories'))]
}
return []
},
},
},
}
},
async created() {
// set to "requestGeoData" object and fill select menu if possible
this.formData.locationName =
(await this.requestGeoData(this.formLocationName)) || this.formLocationName
},
computed: {
formLocationName() {
const isNestedValue =
typeof this.formData.locationName === 'object' &&
typeof this.formData.locationName.value === 'string'
const isDirectString = typeof this.formData.locationName === 'string'
return isNestedValue
? this.formData.locationName.value
: isDirectString
? this.formData.locationName
: ''
},
locationNameLabelAddOnOldName() {
return this.formLocationName !== '' ? ' — ' + this.formLocationName : ''
},
descriptionLength() {
return this.$filters.removeHtml(this.formData.description).length
},
sameLocation() {
const dbLocationName = this.group.locationName || ''
return dbLocationName === this.formLocationName
},
sameCategories() {
if (this.group.categories.length !== this.formData.categoryIds.length) return false
const groupCategories = []
this.group.categories.forEach((categories) => {
groupCategories.push(categories.id)
const some = this.formData.categoryIds.some((item) => item === categories.id)
if (!some) return false
})
return true
},
disableButtonByUpdate() {
if (!this.update) return true
return (
this.group.name === this.formData.name &&
this.group.slug === this.formData.slug &&
this.group.about === this.formData.about &&
this.group.description === this.formData.description &&
this.group.actionRadius === this.formData.actionRadius &&
this.sameLocation &&
this.sameCategories
)
},
},
methods: {
checkFormError(error) {
if (!this.update && error && !!error && this.disableButtonByUpdate) return true
if (this.update && !error && this.disableButtonByUpdate) return true
return false
},
changeGroupType(event) {
this.formData.groupType = event.target.value
},
changeActionRadius(event) {
this.formData.actionRadius = event.target.value
},
updateEditorDescription(value) {
this.$refs.groupForm.update('description', value)
},
submit() {
const { name, slug, about, description, groupType, actionRadius, categoryIds } = this.formData
const variables = {
name,
slug,
about,
description,
groupType,
actionRadius,
locationName: this.formLocationName,
categoryIds,
}
this.update
? this.$emit('updateGroup', {
...variables,
id: this.group.id,
})
: this.$emit('createGroup', variables)
},
handleCityInput(event) {
clearTimeout(timeout)
timeout = setTimeout(
() => this.requestGeoData(event.target ? event.target.value.trim() : ''),
500,
)
},
processLocationsResult(places) {
if (!places.length) {
return []
}
const result = []
places.forEach((place) => {
result.push({
label: place.place_name,
value: place.place_name,
id: place.id,
})
})
return result
},
async requestGeoData(value) {
if (value === '') {
this.cities = []
return
}
this.loadingGeo = true
const place = encodeURIComponent(value)
const lang = this.$i18n.locale()
const {
data: { queryLocations: result },
} = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } })
this.cities = this.processLocationsResult(result)
this.loadingGeo = false
return this.cities.find((city) => city.value === value)
},
},
}
</script>
<style lang="scss">
.appearance--auto {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
.select-label {
margin-bottom: 0;
padding-bottom: 4px;
color: #70677e;
font-size: 1rem;
}
.textarea-label {
padding-bottom: 14px;
}
.group-form {
display: flex;
flex-direction: column;
> .ds-form-item {
margin: 0;
}
> .ds-chip {
align-self: flex-end;
margin: $space-xx-small 0 $space-base;
cursor: default;
}
> .select-field {
align-self: flex-end;
}
> .buttons {
align-self: flex-end;
margin-top: $space-base;
}
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<div>
<div>
<ds-space><h3>Link zur Gruppe</h3></ds-space>
<ds-space>
<ds-copy-field>Copy Link for Invite Member please!</ds-copy-field>
</ds-space>
</div>
</div>
</template>
<script>
export default {
name: 'GroupLink',
}
</script>

View File

@ -0,0 +1,33 @@
import { mount } from '@vue/test-utils'
import GroupList from './GroupList.vue'
const localVue = global.localVue
const propsData = {
groups: [],
}
describe('GroupList', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupList, { propsData, mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.findAll('.group-list')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,21 @@
<template>
<div class="group-list">
<ds-space margin-bottom="small" v-for="group in groups" :key="group.id">
<group-teaser :group="group" />
</ds-space>
</div>
</template>
<script>
import GroupTeaser from '~/components/Group/GroupTeaser'
export default {
name: 'GroupList',
components: {
GroupTeaser,
},
props: {
groups: { type: Array, default: () => [] },
},
}
</script>

View File

@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import GroupMember from './GroupMember.vue'
const localVue = global.localVue
const propsData = {
groupId: '',
groupMembers: [],
}
describe('GroupMember', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupMember, { propsData, mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.findAll('.group-member')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,226 @@
<template>
<div class="group-member">
<base-card>
<h2 class="title">{{ $t('group.addUser') }}</h2>
<ds-form v-model="form" @submit="submit">
<ds-flex gutter="small">
<ds-flex-item width="90%">
<ds-input
name="query"
model="query"
:placeholder="$t('group.addUserPlaceholder')"
icon="search"
/>
</ds-flex-item>
<ds-flex-item width="30px">
<!-- <base-button filled circle type="submit" icon="search" :loading="$apollo.loading" /> -->
<base-button filled circle type="submit" icon="search" />
</ds-flex-item>
</ds-flex>
</ds-form>
<div v-if="noSlug">Kein User mit diesem Slug gefunden!</div>
<div v-if="slugUser.length > 0">
<ds-space margin="base" />
<ds-flex>
<ds-flex-item>
<ds-avatar online size="small" :name="slugUser[0].name"></ds-avatar>
</ds-flex-item>
<ds-flex-item>{{ slugUser[0].name }}</ds-flex-item>
<ds-flex-item>{{ slugUser[0].slug }}</ds-flex-item>
<ds-flex-item>
<ds-button size="small" primary @click="addMemberToGroup(slugUser)">
{{ $t('group.addMemberToGroup') }}
</ds-button>
</ds-flex-item>
</ds-flex>
<ds-space margin="base" />
</div>
</base-card>
<ds-table :fields="tableFields" :data="groupMembers" condensed>
<template #avatar="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
}"
>
<ds-avatar online size="small" :name="scope.row.name"></ds-avatar>
</nuxt-link>
</template>
<template #name="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
}"
>
<ds-text>
<b>{{ scope.row.name | truncate(20) }}</b>
</ds-text>
</nuxt-link>
</template>
<template #slug="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
}"
>
<ds-text>
<b>{{ `@${scope.row.slug}` | truncate(20) }}</b>
</ds-text>
</nuxt-link>
</template>
<template #roleInGroup="scope">
<select
v-if="scope.row.myRoleInGroup !== 'owner'"
:options="['pending', 'usual', 'admin', 'owner']"
:value="`${scope.row.myRoleInGroup}`"
@change="changeMemberRole(scope.row.id, $event)"
>
<option v-for="role in ['pending', 'usual', 'admin', 'owner']" :key="role" :value="role">
{{ $t(`group.roles.${role}`) }}
</option>
</select>
<ds-chip v-else color="primary">
{{ $t(`group.roles.${scope.row.myRoleInGroup}`) }}
</ds-chip>
</template>
<template #edit="scope">
<ds-button v-if="scope.row.myRoleInGroup !== 'owner'" size="small" primary disabled>
<!-- TODO: implement removal of group members -->
<!-- :disabled="scope.row.myRoleInGroup === 'owner'"
-->
{{ $t('group.removeMemberButton') }}
</ds-button>
</template>
</ds-table>
<!-- TODO: implement removal of group members -->
<!-- TODO: change to ocelot.social modal -->
<!-- <ds-modal
v-if="isOpen"
v-model="isOpen"
:title="`${$t('group.removeMember')}`"
force
extended
:confirm-label="$t('group.removeMember')"
:cancel-label="$t('actions.cancel')"
@confirm="deleteMember(memberId)"
/> -->
</div>
</template>
<script>
import { minimisedUserQuery } from '~/graphql/User'
import { changeGroupMemberRoleMutation } from '~/graphql/groups.js'
export default {
name: 'GroupMember',
props: {
groupId: {
type: String,
required: true,
},
groupMembers: {
type: Array,
required: false,
},
},
data() {
return {
isOpen: false,
memberId: null,
noSlug: false,
slugUser: [],
form: {
query: '',
},
}
},
computed: {
tableFields() {
return {
avatar: {
label: this.$t('group.membersAdministrationList.avatar'),
align: 'left',
},
name: {
label: this.$t('group.membersAdministrationList.name'),
align: 'left',
},
slug: {
label: this.$t('group.membersAdministrationList.slug'),
align: 'left',
},
roleInGroup: {
label: this.$t('group.membersAdministrationList.roleInGroup'),
align: 'left',
},
edit: {
label: '',
align: 'left',
},
}
},
},
methods: {
async changeMemberRole(id, event) {
const newRole = event.target.value
try {
await this.$apollo.mutate({
mutation: changeGroupMemberRoleMutation(),
variables: { groupId: this.groupId, userId: id, roleInGroup: newRole },
})
this.$toast.success(
this.$t('group.changeMemberRole', { role: this.$t(`group.roles.${newRole}`) }),
)
} catch (error) {
this.$toast.error(error.message)
}
},
async addMemberToGroup() {
const newRole = 'usual'
if (this.groupMembers.find((member) => member.id === this.slugUser[0].id)) {
this.$toast.error(
this.$t('group.errors.userAlreadyMember', { slug: this.slugUser[0].slug }),
)
return
}
try {
await this.$apollo.mutate({
mutation: changeGroupMemberRoleMutation(),
variables: { groupId: this.groupId, userId: this.slugUser[0].id, roleInGroup: newRole },
})
this.$emit('loadGroupMembers')
this.slugUser = []
this.form.query = ''
this.$toast.success(
this.$t('group.changeMemberRole', { role: this.$t(`group.roles.${newRole}`) }),
)
} catch (error) {
this.$toast.error(error.message)
}
},
async submit() {
try {
const {
data: { User },
} = await this.$apollo.query({
query: minimisedUserQuery(),
variables: {
slug: this.form.query,
},
})
if (User.length === 0) {
this.noSlug = true
} else {
this.noSlug = false
this.slugUser = User
}
} catch (error) {
this.noSlug = true
} finally {
}
},
},
}
</script>

View File

@ -0,0 +1,178 @@
<template>
<nuxt-link
class="group-teaser"
:to="{ name: 'group-id-slug', params: { id: group.id, slug: group.slug } }"
>
<base-card
:class="{
'disabled-content': group.disabled,
}"
>
<h2 class="title hyphenate-text">{{ group.name }}</h2>
<div class="slug-location">
<!-- group slug -->
<div>
<ds-text color="soft">
<!-- <base-icon name="at" data-test="ampersand" /> -->
{{ `&${group.slug}` }}
</ds-text>
</div>
<!-- group location -->
<div class="location-item">
<ds-text v-if="group && group.location" color="soft">
<base-icon name="map-marker" />
{{ group && group.location ? group.location.name : '' }}
</ds-text>
</div>
</div>
<!-- TODO: replace editor content with tiptap render view -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="content hyphenate-text" v-html="descriptionExcerpt" />
<footer class="footer">
<div>
<!-- group my role in group -->
<ds-chip v-if="group && group.myRole" color="primary">
{{ group && group.myRole ? $t('group.roles.' + group.myRole) : '' }}
</ds-chip>
<!-- group type -->
<ds-chip color="primary">
{{ group && group.groupType ? $t('group.types.' + group.groupType) : '' }}
</ds-chip>
<!-- group action radius -->
<ds-chip color="primary">
{{ group && group.actionRadius ? $t('group.actionRadii.' + group.actionRadius) : '' }}
</ds-chip>
</div>
<!-- group categories -->
<div class="categories" v-if="categoriesActive">
<category
v-for="category in group.categories"
:key="category.id"
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
}"
:icon="category.icon"
/>
</div>
<div v-else class="categories-placeholder"></div>
<!-- group context menu -->
<client-only>
<group-content-menu :usage="'groupTeaser'" :group="group || {}" placement="bottom-end" />
</client-only>
</footer>
<footer class="footer">
<!-- group goal -->
<div class="labeled-chip">
<ds-text class="label-text hyphenate-text" color="soft" size="small">
{{ $t('group.goal') }}
</ds-text>
<div class="chip">
<ds-chip v-if="group && group.about">{{ group ? group.about : '' }}</ds-chip>
</div>
</div>
</footer>
</base-card>
</nuxt-link>
</template>
<script>
import Category from '~/components/Category'
import GroupContentMenu from '~/components/ContentMenu/GroupContentMenu'
export default {
name: 'GroupTeaser',
components: {
Category,
GroupContentMenu,
},
props: {
group: {
type: Object,
required: true,
},
width: {
type: Object,
default: () => {},
},
},
data() {
return {
categoriesActive: this.$env.CATEGORIES_ACTIVE,
}
},
computed: {
descriptionExcerpt() {
return this.$filters.removeLinks(this.group.descriptionExcerpt)
},
},
}
</script>
<style lang="scss" scoped>
.group-teaser,
.group-teaser:hover,
.group-teaser:active {
position: relative;
display: block;
height: 100%;
color: $text-color-base;
> .ribbon {
position: absolute;
top: 50%;
right: -7px;
}
}
.group-teaser > .base-card {
display: flex;
flex-direction: column;
height: 100%;
> .title {
font-size: 32px;
}
> .slug-location {
display: flex;
margin-bottom: $space-small;
> .location-item {
margin-left: $space-small;
}
}
> .content {
flex-grow: 1;
margin-bottom: $space-small;
}
> .footer {
display: flex;
justify-content: space-between;
align-items: center;
> .categories-placeholder {
flex-grow: 1;
}
> .labeled-chip {
margin-top: $space-xx-small;
> .chip {
margin-top: -$space-small + $space-x-small;
}
}
> .content-menu {
position: relative;
z-index: $z-index-post-teaser-link;
}
}
.user-teaser {
margin-bottom: $space-small;
}
}
</style>

View File

@ -0,0 +1,314 @@
<template>
<ds-container class="main-navigation-container" style="padding: 10px 10px">
<div>
<!-- header menu -->
<ds-flex v-if="!showMobileMenu" class="main-navigation-flex">
<!-- logo -->
<ds-flex-item :width="{ base: LOGOS.LOGO_HEADER_WIDTH }" style="margin-right: 20px">
<a
v-if="LOGOS.LOGO_HEADER_CLICK.externalLink"
:href="LOGOS.LOGO_HEADER_CLICK.externalLink.url"
:target="LOGOS.LOGO_HEADER_CLICK.externalLink.target"
>
<logo logoType="header" />
</a>
<nuxt-link
v-else
:to="LOGOS.LOGO_HEADER_CLICK.internalPath.to"
v-scroll-to="LOGOS.LOGO_HEADER_CLICK.internalPath.scrollTo"
>
<logo logoType="header" />
</nuxt-link>
</ds-flex-item>
<!-- dynamic-brand-menu -->
<ds-flex-item
v-for="item in menu"
:key="item.name"
class="branding-menu"
:width="{ base: 'auto' }"
style="margin-right: 20px"
>
<a v-if="item.url" :href="item.url" :target="item.target">
<ds-text size="large" bold>
{{ $t(item.nameIdent) }}
</ds-text>
</a>
<nuxt-link v-else :to="item.path">
<ds-text size="large" bold>
{{ $t(item.nameIdent) }}
</ds-text>
</nuxt-link>
</ds-flex-item>
<!-- search-field -->
<ds-flex-item
v-if="isLoggedIn"
id="nav-search-box"
class="header-search"
:width="{
base: '45%',
sm: '40%',
md: isHeaderMenu ? 'auto' : '40%',
lg: isHeaderMenu ? 'auto' : '50%',
}"
style="flex-shrink: 0; flex-grow: 1"
>
<search-field />
</ds-flex-item>
<!-- filter-menu
TODO: Filter is only visible on index
-->
<ds-flex-item v-if="isLoggedIn" style="flex-grow: 0; flex-basis: auto">
<client-only>
<filter-menu v-show="showFilterMenuDropdown" />
</client-only>
</ds-flex-item>
<!-- locale-switch -->
<ds-flex-item style="flex-basis: auto">
<div class="main-navigation-right" style="flex-basis: auto">
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
<template v-if="isLoggedIn">
<client-only>
<!-- notification-menu -->
<notification-menu placement="top" />
</client-only>
<div v-if="inviteRegistration">
<client-only>
<!-- invite-button -->
<invite-button placement="top" />
</client-only>
</div>
<!-- group button -->
<client-only v-if="SHOW_GROUP_BUTTON_IN_HEADER">
<group-button />
</client-only>
<!-- avatar-menu -->
<client-only>
<avatar-menu placement="top" />
</client-only>
</template>
</div>
</ds-flex-item>
</ds-flex>
<!-- mobile header menu -->
<div v-else class="mobil-header-box">
<!-- logo, hamburger-->
<ds-flex style="align-items: center">
<ds-flex-item :width="{ base: LOGOS.LOGO_HEADER_WIDTH }" style="margin-right: 20px">
<a
v-if="LOGOS.LOGO_HEADER_CLICK.externalLink"
:href="LOGOS.LOGO_HEADER_CLICK.externalLink.url"
:target="LOGOS.LOGO_HEADER_CLICK.externalLink.target"
>
<logo logoType="header" />
</a>
<nuxt-link
v-else
:to="LOGOS.LOGO_HEADER_CLICK.internalPath.to"
v-scroll-to="LOGOS.LOGO_HEADER_CLICK.internalPath.scrollTo"
>
<logo logoType="header" />
</nuxt-link>
</ds-flex-item>
<!-- mobile hamburger menu -->
<ds-flex-item class="mobile-hamburger-menu">
<client-only>
<div style="display: inline-flex; padding-right: 20px">
<notification-menu />
</div>
</client-only>
<base-button icon="bars" @click="toggleMobileMenuView" circle />
</ds-flex-item>
</ds-flex>
<!-- search, filter-->
<ds-flex class="mobile-menu">
<!-- search-field mobile-->
<ds-flex-item
v-if="isLoggedIn"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="padding: 20px"
>
<search-field />
</ds-flex-item>
<!-- filter menu mobile-->
<ds-flex-item
v-if="isLoggedIn"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="flex-grow: 0; flex-basis: auto; padding: 20px 0"
>
<client-only>
<filter-menu v-show="showFilterMenuDropdown" :showMobileMenu="showMobileMenu" />
</client-only>
</ds-flex-item>
</ds-flex>
<!-- switch language, notification, invite, profil -->
<ds-flex style="margin: 0 20px">
<!-- locale-switch mobile-->
<ds-flex-item :class="{ 'hide-mobile-menu': !toggleMobileMenu }">
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
</ds-flex-item>
<!-- invite-button mobile-->
<ds-flex-item
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="text-align: center"
>
<client-only>
<invite-button placement="top" />
</client-only>
</ds-flex-item>
<!-- group button -->
<ds-flex-item
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="text-align: center"
>
<client-only v-if="SHOW_GROUP_BUTTON_IN_HEADER">
<group-button />
</client-only>
</ds-flex-item>
<!-- avatar-menu mobile-->
<ds-flex-item :class="{ 'hide-mobile-menu': !toggleMobileMenu }" style="text-align: end">
<client-only>
<avatar-menu placement="top" />
</client-only>
</ds-flex-item>
</ds-flex>
<div :class="{ 'hide-mobile-menu': !toggleMobileMenu }" class="mobile-menu footer-mobile">
<!-- dynamic branding menu -->
<ul v-if="isHeaderMenu" class="dynamic-branding-mobil">
<li v-for="item in menu" :key="item.name">
<a v-if="item.url" :href="item.url" :target="item.target">
<ds-text size="large" bold>
{{ $t(item.nameIdent) }}
</ds-text>
</a>
<nuxt-link v-else :to="item.path">
<ds-text size="large" bold>
{{ $t(item.nameIdent) }}
</ds-text>
</nuxt-link>
</li>
</ul>
<hr />
<!-- dynamic footer menu in header -->
<ul class="dynamic-footer-mobil">
<li v-for="pageParams in links.FOOTER_LINK_LIST" :key="pageParams.name">
<page-params-link :pageParams="pageParams">
{{ $t(pageParams.internalPage.footerIdent) }}
</page-params-link>
</li>
</ul>
</div>
</div>
</div>
</ds-container>
</template>
<script>
import { mapGetters } from 'vuex'
import { SHOW_GROUP_BUTTON_IN_HEADER } from '~/constants/groups.js'
import LOGOS from '~/constants/logos.js'
import headerMenu from '~/constants/headerMenu.js'
import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
import GroupButton from '~/components/Group/GroupButton'
import InviteButton from '~/components/InviteButton/InviteButton'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
import Logo from '~/components/Logo/Logo'
import SearchField from '~/components/features/SearchField/SearchField.vue'
import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
import links from '~/constants/links.js'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
export default {
components: {
AvatarMenu,
FilterMenu,
GroupButton,
InviteButton,
LocaleSwitch,
Logo,
NotificationMenu,
PageParamsLink,
SearchField,
},
props: {
showMobileMenu: { type: Boolean, default: false },
},
data() {
return {
links,
LOGOS,
SHOW_GROUP_BUTTON_IN_HEADER,
isHeaderMenu: headerMenu.MENU.length > 0,
menu: headerMenu.MENU,
mobileSearchVisible: false,
toggleMobileMenu: false,
inviteRegistration: this.$env.INVITE_REGISTRATION === true, // for 'false' in .env INVITE_REGISTRATION is of type undefined and not(!) boolean false, because of internal handling,
categoriesActive: this.$env.CATEGORIES_ACTIVE,
}
},
computed: {
...mapGetters({
isLoggedIn: 'auth/isLoggedIn',
}),
showFilterMenuDropdown() {
const [firstRoute] = this.$route.matched
return firstRoute && firstRoute.name === 'index'
},
},
methods: {
toggleMobileMenuView() {
this.toggleMobileMenu = !this.toggleMobileMenu
},
},
}
</script>
<style lang="scss">
.margin-right-20 {
margin-right: 20px;
}
.margin-x {
margin-left: 20px;
margin-right: 20px;
white-space: nowrap;
}
.topbar-locale-switch {
display: flex;
margin-right: $space-xx-small;
align-self: center;
display: inline-flex;
}
.main-navigation-flex {
align-items: center;
}
.main-navigation-right {
display: flex;
justify-content: flex-end;
}
.main-navigation-right .desktop-view {
float: right;
}
.ds-flex-item.mobile-hamburger-menu {
margin-left: auto;
text-align: right;
}
.mobile-menu {
margin: 0 20px;
}
.mobile-search {
margin-top: 20px;
}
.dynamic-branding-mobil,
.dynamic-footer-mobil {
line-height: 30px;
font-size: large;
}
.dynamic-branding-mobil li {
margin: 17px 0;
}
.hide-mobile-menu {
display: none;
}
</style>

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