mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge branch 'master' of github.com:Ocelot-Social-Community/Ocelot-Social into 5137-refactor-social-media-and-mysomethinglist
This commit is contained in:
commit
02598e5224
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@ -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.
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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.-->
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/devops_ticket.md
vendored
11
.github/ISSUE_TEMPLATE/devops_ticket.md
vendored
@ -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.-->
|
||||
|
||||
12
.github/ISSUE_TEMPLATE/epic.md
vendored
12
.github/ISSUE_TEMPLATE/epic.md
vendored
@ -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 -->
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/feature_request.md
vendored
11
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -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. -->
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/question.md
vendored
14
.github/ISSUE_TEMPLATE/question.md
vendored
@ -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. -->
|
||||
|
||||
@ -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
13
.github/ISSUE_TEMPLATE/release.md
vendored
Normal 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 -->
|
||||
14
.github/PULL_REQUEST_TEMPLATE.md
vendored
14
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -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
71
.github/workflows/lint_pr.yml
vendored
Normal 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
|
||||
11
.github/workflows/publish.yml
vendored
11
.github/workflows/publish.yml
vendored
@ -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: |
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -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 }}
|
||||
|
||||
##############################################################################
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@ -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)
|
||||
|
||||
|
||||
13
README.md
13
README.md
@ -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 |
|
||||
| :--- | :--- | :--- |
|
||||
|
||||
@ -29,4 +29,4 @@ AWS_BUCKET=
|
||||
EMAIL_DEFAULT_SENDER="devops@ocelot.social"
|
||||
EMAIL_SUPPORT="devops@ocelot.social"
|
||||
|
||||
CATEGORIES_ACTIVE=false
|
||||
CATEGORIES_ACTIVE=false
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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',
|
||||
|
||||
3
backend/src/constants/groups.js
Normal file
3
backend/src/constants/groups.js
Normal 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
|
||||
@ -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)),
|
||||
)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
30
backend/src/graphql/authentications.js
Normal file
30
backend/src/graphql/authentications.js
Normal 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
|
||||
15
backend/src/graphql/comments.js
Normal file
15
backend/src/graphql/comments.js
Normal 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
|
||||
203
backend/src/graphql/groups.js
Normal file
203
backend/src/graphql/groups.js
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
88
backend/src/graphql/posts.js
Normal file
88
backend/src/graphql/posts.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
13
backend/src/graphql/userManagement.js
Normal file
13
backend/src/graphql/userManagement.js
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import slugify from 'slug'
|
||||
|
||||
export default async function uniqueSlug(string, isUnique) {
|
||||
const slug = slugify(string || 'anonymous', {
|
||||
lower: true,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
|
||||
46
backend/src/models/Group.js
Normal file
46
backend/src/models/Group.js
Normal 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',
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
`)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -20,6 +20,7 @@ export default makeAugmentedSchema({
|
||||
'FILED',
|
||||
'REVIEWED',
|
||||
'Report',
|
||||
'Group',
|
||||
],
|
||||
},
|
||||
mutation: false,
|
||||
|
||||
385
backend/src/schema/resolvers/groups.js
Normal file
385
backend/src/schema/resolvers/groups.js
Normal 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)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
2984
backend/src/schema/resolvers/groups.spec.js
Normal file
2984
backend/src/schema/resolvers/groups.spec.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
|
||||
47
backend/src/schema/resolvers/helpers/filterInvisiblePosts.js
Normal file
47
backend/src/schema/resolvers/helpers/filterInvisiblePosts.js
Normal 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
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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 },
|
||||
})
|
||||
|
||||
1681
backend/src/schema/resolvers/postsInGroups.spec.js
Normal file
1681
backend/src/schema/resolvers/postsInGroups.spec.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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)),
|
||||
]
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
7
backend/src/schema/types/enum/GroupActionRadius.gql
Normal file
7
backend/src/schema/types/enum/GroupActionRadius.gql
Normal file
@ -0,0 +1,7 @@
|
||||
enum GroupActionRadius {
|
||||
regional
|
||||
national
|
||||
continental
|
||||
global
|
||||
interplanetary
|
||||
}
|
||||
6
backend/src/schema/types/enum/GroupMemberRole.gql
Normal file
6
backend/src/schema/types/enum/GroupMemberRole.gql
Normal file
@ -0,0 +1,6 @@
|
||||
enum GroupMemberRole {
|
||||
pending
|
||||
usual
|
||||
admin
|
||||
owner
|
||||
}
|
||||
5
backend/src/schema/types/enum/GroupType.gql
Normal file
5
backend/src/schema/types/enum/GroupType.gql
Normal file
@ -0,0 +1,5 @@
|
||||
enum GroupType {
|
||||
public
|
||||
closed
|
||||
hidden
|
||||
}
|
||||
135
backend/src/schema/types/type/Group.gql
Normal file
135
backend/src/schema/types/type/Group.gql
Normal 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
|
||||
}
|
||||
5
backend/src/schema/types/type/MEMBER_OF.gql
Normal file
5
backend/src/schema/types/type/MEMBER_OF.gql
Normal file
@ -0,0 +1,5 @@
|
||||
type MEMBER_OF {
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
role: GroupMemberRole!
|
||||
}
|
||||
@ -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
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
@ -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]!
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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==
|
||||
|
||||
@ -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/')
|
||||
})
|
||||
@ -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")
|
||||
|
||||
@ -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");
|
||||
});
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -268,6 +268,7 @@ $size-avatar-large: 114px;
|
||||
* @presenter Spacing
|
||||
*/
|
||||
|
||||
$size-button-large: 50px;
|
||||
$size-button-base: 36px;
|
||||
$size-button-small: 26px;
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
136
webapp/components/Button/JoinLeaveButton.vue
Normal file
136
webapp/components/Button/JoinLeaveButton.vue
Normal 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>
|
||||
@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
icon="ellipsis-v"
|
||||
size="small"
|
||||
circle
|
||||
ghost
|
||||
@click.prevent="toggleMenu()"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
38
webapp/components/ContentMenu/GroupContentMenu.spec.js
Normal file
38
webapp/components/ContentMenu/GroupContentMenu.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
98
webapp/components/ContentMenu/GroupContentMenu.vue
Normal file
98
webapp/components/ContentMenu/GroupContentMenu.vue
Normal 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>
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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)
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
|
||||
@ -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>
|
||||
|
||||
5
webapp/components/Group/GroupButton.vue
Normal file
5
webapp/components/Group/GroupButton.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<nuxt-link to="/groups"><base-button icon="users" circle ghost /></nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
39
webapp/components/Group/GroupForm.spec.js
Normal file
39
webapp/components/Group/GroupForm.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
440
webapp/components/Group/GroupForm.vue
Normal file
440
webapp/components/Group/GroupForm.vue
Normal 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>
|
||||
15
webapp/components/Group/GroupLink.vue
Normal file
15
webapp/components/Group/GroupLink.vue
Normal 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>
|
||||
33
webapp/components/Group/GroupList.spec.js
Normal file
33
webapp/components/Group/GroupList.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
21
webapp/components/Group/GroupList.vue
Normal file
21
webapp/components/Group/GroupList.vue
Normal 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>
|
||||
34
webapp/components/Group/GroupMember.spec.js
Normal file
34
webapp/components/Group/GroupMember.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
226
webapp/components/Group/GroupMember.vue
Normal file
226
webapp/components/Group/GroupMember.vue
Normal 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>
|
||||
178
webapp/components/Group/GroupTeaser.vue
Normal file
178
webapp/components/Group/GroupTeaser.vue
Normal 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>
|
||||
314
webapp/components/HeaderMenu/HeaderMenu.vue
Normal file
314
webapp/components/HeaderMenu/HeaderMenu.vue
Normal 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
Loading…
x
Reference in New Issue
Block a user