diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index ac333604ec..2a2e73dc69 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -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: |
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9564aa2f7e..cc4d2deb93 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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: 64
token: ${{ github.token }}
##############################################################################
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 940cc77a8e..81762c003f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,18 +4,43 @@ 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.0.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.1.0...2.0.0)
+- 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 +52,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)
+- Refine design and a bit functionality, first step [`13ec4ee`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/13ec4ee776728983e25d87078b804bdaaca66e38)
+- add tets for posts in groups [`a9cd661`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/a9cd661e63181dc5ea207b2e1e656f720e0b10d0)
+- Refine design and functionality of group list and create, edit group [`7b11122`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/7b11122bea4868624dd1c1641219e71070412e20)
#### [1.1.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.9...1.1.0)
diff --git a/backend/.env.template b/backend/.env.template
index 239046dd32..dd46846a99 100644
--- a/backend/.env.template
+++ b/backend/.env.template
@@ -29,4 +29,4 @@ AWS_BUCKET=
EMAIL_DEFAULT_SENDER="devops@ocelot.social"
EMAIL_SUPPORT="devops@ocelot.social"
-CATEGORIES_ACTIVE=false
\ No newline at end of file
+CATEGORIES_ACTIVE=false
diff --git a/backend/README.md b/backend/README.md
index 083606b09b..0af58af488 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -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
```
diff --git a/backend/package.json b/backend/package.json
index bcc91a25a4..fdf029c7bf 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "ocelot-social-backend",
- "version": "1.1.1",
+ "version": "2.0.0",
"description": "GraphQL Backend for ocelot.social",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",
diff --git a/backend/src/constants/categories.js b/backend/src/constants/categories.js
index 3676fed0b4..0d61a041aa 100644
--- a/backend/src/constants/categories.js
+++ b/backend/src/constants/categories.js
@@ -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',
diff --git a/backend/src/constants/groups.js b/backend/src/constants/groups.js
new file mode 100644
index 0000000000..e9c941a897
--- /dev/null
+++ b/backend/src/constants/groups.js
@@ -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
diff --git a/backend/src/db/graphql/authentications.js b/backend/src/db/graphql/authentications.js
new file mode 100644
index 0000000000..91605ec9fe
--- /dev/null
+++ b/backend/src/db/graphql/authentications.js
@@ -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
diff --git a/backend/src/db/graphql/comments.js b/backend/src/db/graphql/comments.js
new file mode 100644
index 0000000000..b408c5e95f
--- /dev/null
+++ b/backend/src/db/graphql/comments.js
@@ -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
diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js
new file mode 100644
index 0000000000..e388b2cd9e
--- /dev/null
+++ b/backend/src/db/graphql/groups.js
@@ -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
+ }
+ }
+ `
+}
diff --git a/backend/src/db/graphql/posts.js b/backend/src/db/graphql/posts.js
new file mode 100644
index 0000000000..2669d6f242
--- /dev/null
+++ b/backend/src/db/graphql/posts.js
@@ -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
+ }
+ }
+ }
+ `
+}
diff --git a/backend/src/db/graphql/userManagement.js b/backend/src/db/graphql/userManagement.js
new file mode 100644
index 0000000000..3cb8a05f84
--- /dev/null
+++ b/backend/src/db/graphql/userManagement.js
@@ -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
diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js
index d6fd25bd89..57a317b475 100644
--- a/backend/src/db/migrate/store.js
+++ b/backend/src/db/migrate/store.js
@@ -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)),
)
diff --git a/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js b/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js
new file mode 100644
index 0000000000..b87e5632a3
--- /dev/null
+++ b/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js
@@ -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()
+ }
+}
diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js
index 482517e69f..dd8bb59cb7 100644
--- a/backend/src/db/seed.js
+++ b/backend/src/db/seed.js
@@ -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: `
English:
This group is hidden.
What is our group for? This group was created to allow investigative journalists to share and collaborate.
How does it work? Here you can internally share posts and comments about them.
Deutsch:
Diese Gruppe ist verborgen.
Wofür ist unsere Gruppe? Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.
Wie funktioniert das? Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.
`,
+ 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: `English
Our goal 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.
Curiosity For this we need a school that takes up the curiosity of the children, the people, and satisfies it through a lot of experience.
Deutsch
Unser Ziel 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.
Neugier Dazu benötigen wir eine Schule, die die Neugier der Kinder, der Menschen, aufnimmt und durch viel Erfahrung befriedigt.
`,
+ 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: `What Is yoga? Yoga is not just about practicing asanas. It's about how we do it.
And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.
What makes practicing asanas yogic? The important thing is:
`,
+ 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 #NaturphilosophieYoga , it can really help you!'
const hashtagAndMention1 =
'The new physics of #QuantenFlussTheorie can explain #QuantumGravity ! @peter-lustig 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 @jenny-rostock has practiced it for 3 years now.'
const mentionInComment2 =
'Did @peter-lustig 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,
diff --git a/backend/src/helpers/jest.js b/backend/src/helpers/jest.js
index 201d68c141..e3f6a3c84a 100644
--- a/backend/src/helpers/jest.js
+++ b/backend/src/helpers/jest.js
@@ -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)
diff --git a/backend/src/middleware/excerptMiddleware.js b/backend/src/middleware/excerptMiddleware.js
index 40a6a6ae40..68eea9a74b 100644
--- a/backend/src/middleware/excerptMiddleware.js
+++ b/backend/src/middleware/excerptMiddleware.js
@@ -1,26 +1,31 @@
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) => {
+ 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)
},
},
}
diff --git a/backend/src/middleware/helpers/cleanHtml.js b/backend/src/middleware/helpers/cleanHtml.js
index 72976b43cc..ac71f6bdce 100644
--- a/backend/src/middleware/helpers/cleanHtml.js
+++ b/backend/src/middleware/helpers/cleanHtml.js
@@ -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',
diff --git a/backend/src/middleware/languages/languages.js b/backend/src/middleware/languages/languages.js
index 3cf760f310..0872529753 100644
--- a/backend/src/middleware/languages/languages.js
+++ b/backend/src/middleware/languages/languages.js
@@ -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()
diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js
index 71a44f225b..3d698810e7 100644
--- a/backend/src/middleware/permissionsMiddleware.js
+++ b/backend/src/middleware/permissionsMiddleware.js
@@ -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,203 @@ 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 adminId = user.id
+ const { groupId, userId, roleInGroup } = args
+ if (adminId === userId) return false
+ const session = driver.session()
+ const readTxPromise = session.readTransaction(async (transaction) => {
+ const transactionResponse = await transaction.run(
+ `
+ MATCH (admin:User {id: $adminId})-[adminMembership:MEMBER_OF]->(group:Group {id: $groupId})
+ OPTIONAL MATCH (group)<-[userMembership:MEMBER_OF]-(member:User {id: $userId})
+ RETURN group {.*}, admin {.*, myRoleInGroup: adminMembership.role}, member {.*, myRoleInGroup: userMembership.role}
+ `,
+ { groupId, adminId, userId },
+ )
+ return {
+ admin: transactionResponse.records.map((record) => record.get('admin'))[0],
+ group: transactionResponse.records.map((record) => record.get('group'))[0],
+ member: transactionResponse.records.map((record) => record.get('member'))[0],
+ }
+ })
+ try {
+ const { admin, group, member } = await readTxPromise
+ return (
+ !!group &&
+ !!admin &&
+ (!member ||
+ (!!member &&
+ (member.myRoleInGroup === roleInGroup || !['owner'].includes(member.myRoleInGroup)))) &&
+ ((['admin'].includes(admin.myRoleInGroup) &&
+ ['pending', 'usual', 'admin'].includes(roleInGroup)) ||
+ (['owner'].includes(admin.myRoleInGroup) &&
+ ['pending', 'usual', 'admin', 'owner'].includes(roleInGroup)))
+ )
+ } 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 isAuthor = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
@@ -78,7 +275,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 +299,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 +310,8 @@ export default shield(
reports: isModerator,
statistics: allow,
currentUser: allow,
+ Group: isAuthenticated,
+ GroupMembers: isAllowedSeeingGroupMembers,
Post: allow,
profilePagePosts: allow,
Comment: allow,
@@ -140,7 +338,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,
diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js
index 165235be9d..5ef2944be3 100644
--- a/backend/src/middleware/sluggifyMiddleware.js
+++ b/backend/src/middleware/sluggifyMiddleware.js
@@ -26,11 +26,22 @@ 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)
+ },
+ UpdateGroup: async (resolve, root, args, context, info) => {
+ if (args.name) {
+ 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, see condition in 'UpdateGroup' above? may it works accidentally, because args.slug is always send?
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
},
diff --git a/backend/src/middleware/slugify/uniqueSlug.js b/backend/src/middleware/slugify/uniqueSlug.js
index 7cfb89c193..41d58ece3d 100644
--- a/backend/src/middleware/slugify/uniqueSlug.js
+++ b/backend/src/middleware/slugify/uniqueSlug.js
@@ -1,4 +1,5 @@
import slugify from 'slug'
+
export default async function uniqueSlug(string, isUnique) {
const slug = slugify(string || 'anonymous', {
lower: true,
diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js
index 7c6f18ab19..0b022fb534 100644
--- a/backend/src/middleware/slugifyMiddleware.spec.js
+++ b/backend/src/middleware/slugifyMiddleware.spec.js
@@ -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 '../db/graphql/groups'
+import { createPostMutation } from '../db/graphql/posts'
+import { signupVerificationMutation } from '../db/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,296 @@ 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 by group name', () => {
+ it('has the new slug', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: createGroupResult.data.CreateGroup.id,
+ name: 'My Best Group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ name: 'My Best Group',
+ slug: 'my-best-group',
+ about: 'Some about',
+ description: 'Some description' + descriptionAdditional100,
+ groupType: 'closed',
+ actionRadius: 'national',
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ 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 by group name', () => {
+ it('has unique slug "*-1"', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: createGroupResult.data.CreateGroup.id,
+ name: 'Pre-Existing Group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ name: 'Pre-Existing Group',
+ slug: 'pre-existing-group-1',
+ about: 'Some about',
+ description: 'Some description' + descriptionAdditional100,
+ groupType: 'closed',
+ actionRadius: 'national',
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ 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 +362,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 +415,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 +431,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 +470,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 +478,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 +501,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 +549,7 @@ describe('slugifyMiddleware', () => {
it('chooses another slug', async () => {
await expect(
mutate({
- mutation,
+ mutation: signupVerificationMutation,
variables,
}),
).resolves.toMatchObject({
@@ -246,6 +558,7 @@ describe('slugifyMiddleware', () => {
slug: 'i-am-a-user-1',
},
},
+ errors: undefined,
})
})
@@ -260,7 +573,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({
diff --git a/backend/src/middleware/userInteractions.js b/backend/src/middleware/userInteractions.js
index 553aefe785..62e8e47f71 100644
--- a/backend/src/middleware/userInteractions.js
+++ b/backend/src/middleware/userInteractions.js
@@ -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)
diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js
new file mode 100644
index 0000000000..a75ad518f9
--- /dev/null
+++ b/backend/src/models/Group.js
@@ -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',
+ },
+}
diff --git a/backend/src/models/User.spec.js b/backend/src/models/User.spec.js
index 102acde6ab..c64d1fd374 100644
--- a/backend/src/models/User.spec.js
+++ b/backend/src/models/User.spec.js
@@ -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
`)
}
})
diff --git a/backend/src/models/index.js b/backend/src/models/index.js
index 8d6a021ab0..d476e5f9b1 100644
--- a/backend/src/models/index.js
+++ b/backend/src/models/index.js
@@ -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,
diff --git a/backend/src/schema/index.js b/backend/src/schema/index.js
index 612487147d..06e150c862 100644
--- a/backend/src/schema/index.js
+++ b/backend/src/schema/index.js
@@ -20,6 +20,7 @@ export default makeAugmentedSchema({
'FILED',
'REVIEWED',
'Report',
+ 'Group',
],
},
mutation: false,
diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js
new file mode 100644
index 0000000000..5e22bd7437
--- /dev/null
+++ b/backend/src/schema/resolvers/groups.js
@@ -0,0 +1,347 @@
+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 } = params
+ 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}
+ `
+ } 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}
+ `
+ } 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}
+ `
+ }
+ }
+ 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()
+ }
+ },
+ },
+ 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)',
+ },
+ }),
+ },
+}
diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js
new file mode 100644
index 0000000000..d707440a42
--- /dev/null
+++ b/backend/src/schema/resolvers/groups.spec.js
@@ -0,0 +1,2984 @@
+import { createTestClient } from 'apollo-server-testing'
+import Factory, { cleanDatabase } from '../../db/factories'
+import {
+ createGroupMutation,
+ updateGroupMutation,
+ joinGroupMutation,
+ leaveGroupMutation,
+ changeGroupMemberRoleMutation,
+ groupMembersQuery,
+ groupQuery,
+} from '../../db/graphql/groups'
+import { getNeode, getDriver } from '../../db/neo4j'
+import createServer from '../../server'
+import CONFIG from '../../config'
+
+const driver = getDriver()
+const neode = getNeode()
+
+let authenticatedUser
+let user
+let noMemberUser
+let pendingMemberUser
+let usualMemberUser
+let adminMemberUser
+let ownerMemberUser
+let secondOwnerMemberUser
+
+const categoryIds = ['cat9', 'cat4', 'cat15']
+const descriptionAdditional100 =
+ ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789'
+let variables = {}
+
+const { server } = createServer({
+ context: () => {
+ return {
+ driver,
+ neode,
+ user: authenticatedUser,
+ }
+ },
+})
+const { mutate, query } = createTestClient(server)
+
+const seedBasicsAndClearAuthentication = async () => {
+ variables = {}
+ user = await Factory.build(
+ 'user',
+ {
+ id: 'current-user',
+ name: 'TestUser',
+ },
+ {
+ email: 'test@example.org',
+ password: '1234',
+ },
+ )
+ await Promise.all([
+ neode.create('Category', {
+ id: 'cat4',
+ name: 'Environment & Nature',
+ slug: 'environment-nature',
+ icon: 'tree',
+ }),
+ neode.create('Category', {
+ id: 'cat9',
+ name: 'Democracy & Politics',
+ slug: 'democracy-politics',
+ icon: 'university',
+ }),
+ neode.create('Category', {
+ id: 'cat15',
+ name: 'Consumption & Sustainability',
+ slug: 'consumption-sustainability',
+ icon: 'shopping-cart',
+ }),
+ neode.create('Category', {
+ id: 'cat27',
+ name: 'Animal Protection',
+ slug: 'animal-protection',
+ icon: 'paw',
+ }),
+ ])
+ authenticatedUser = null
+}
+
+const seedComplexScenarioAndClearAuthentication = async () => {
+ await seedBasicsAndClearAuthentication()
+ // create users
+ noMemberUser = await Factory.build(
+ 'user',
+ {
+ id: 'none-member-user',
+ name: 'None Member TestUser',
+ },
+ {
+ email: 'none-member-user@example.org',
+ password: '1234',
+ },
+ )
+ pendingMemberUser = await Factory.build(
+ 'user',
+ {
+ id: 'pending-member-user',
+ name: 'Pending Member TestUser',
+ },
+ {
+ email: 'pending-member-user@example.org',
+ password: '1234',
+ },
+ )
+ usualMemberUser = await Factory.build(
+ 'user',
+ {
+ id: 'usual-member-user',
+ name: 'Usual Member TestUser',
+ },
+ {
+ email: 'usual-member-user@example.org',
+ password: '1234',
+ },
+ )
+ adminMemberUser = await Factory.build(
+ 'user',
+ {
+ id: 'admin-member-user',
+ name: 'Admin Member TestUser',
+ },
+ {
+ email: 'admin-member-user@example.org',
+ password: '1234',
+ },
+ )
+ ownerMemberUser = await Factory.build(
+ 'user',
+ {
+ id: 'owner-member-user',
+ name: 'Owner Member TestUser',
+ },
+ {
+ email: 'owner-member-user@example.org',
+ password: '1234',
+ },
+ )
+ secondOwnerMemberUser = await Factory.build(
+ 'user',
+ {
+ id: 'second-owner-member-user',
+ name: 'Second Owner Member TestUser',
+ },
+ {
+ email: 'second-owner-member-user@example.org',
+ password: '1234',
+ },
+ )
+ // create groups
+ // public-group
+ authenticatedUser = await usualMemberUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'public-group',
+ name: 'The Best Group',
+ about: 'We will change the world!',
+ description: 'Some description' + descriptionAdditional100,
+ groupType: 'public',
+ actionRadius: 'regional',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'owner-of-closed-group',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'owner-of-hidden-group',
+ },
+ })
+ // closed-group
+ authenticatedUser = await ownerMemberUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'closed-group',
+ name: 'Uninteresting Group',
+ about: 'We will change nothing!',
+ description: 'We love it like it is!?' + descriptionAdditional100,
+ groupType: 'closed',
+ actionRadius: 'national',
+ categoryIds,
+ },
+ })
+ // hidden-group
+ authenticatedUser = await adminMemberUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'hidden-group',
+ name: 'Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'admin-member-user',
+ roleInGroup: 'usual',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'second-owner-member-user',
+ roleInGroup: 'usual',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'admin-member-user',
+ roleInGroup: 'usual',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'second-owner-member-user',
+ roleInGroup: 'usual',
+ },
+ })
+
+ authenticatedUser = null
+}
+
+beforeAll(async () => {
+ await cleanDatabase()
+})
+
+afterAll(async () => {
+ await cleanDatabase()
+})
+
+describe('in mode', () => {
+ describe('clean db after each single test', () => {
+ beforeEach(async () => {
+ await seedBasicsAndClearAuthentication()
+ })
+
+ // TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543
+ afterEach(async () => {
+ await cleanDatabase()
+ })
+
+ describe('CreateGroup', () => {
+ beforeEach(() => {
+ variables = {
+ ...variables,
+ id: 'g589',
+ name: 'The Best Group',
+ slug: 'the-group',
+ about: 'We will change the world!',
+ description: 'Some description' + descriptionAdditional100,
+ groupType: 'public',
+ actionRadius: 'regional',
+ categoryIds,
+ locationName: 'Hamburg, Germany',
+ }
+ })
+
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({ mutation: createGroupMutation(), variables })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('authenticated', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ it('creates a group', async () => {
+ await expect(
+ mutate({ mutation: createGroupMutation(), variables }),
+ ).resolves.toMatchObject({
+ data: {
+ CreateGroup: {
+ name: 'The Best Group',
+ slug: 'the-group',
+ about: 'We will change the world!',
+ description: 'Some description' + descriptionAdditional100,
+ descriptionExcerpt: 'Some description' + descriptionAdditional100,
+ groupType: 'public',
+ actionRadius: 'regional',
+ locationName: 'Hamburg, Germany',
+ location: expect.objectContaining({
+ name: 'Hamburg',
+ nameDE: 'Hamburg',
+ nameEN: 'Hamburg',
+ }),
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('assigns the authenticated user as owner', async () => {
+ await expect(
+ mutate({ mutation: createGroupMutation(), variables }),
+ ).resolves.toMatchObject({
+ data: {
+ CreateGroup: {
+ name: 'The Best Group',
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('has "disabled" and "deleted" default to "false"', async () => {
+ await expect(
+ mutate({ mutation: createGroupMutation(), variables }),
+ ).resolves.toMatchObject({
+ data: { CreateGroup: { disabled: false, deleted: false } },
+ })
+ })
+
+ describe('description', () => {
+ describe('length without HTML', () => {
+ describe('less then 100 chars', () => {
+ it('throws error: "Description too short!"', async () => {
+ const { errors } = await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ ...variables,
+ description:
+ '0123456789' +
+ '0123456789 ',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Description too short!')
+ })
+ })
+ })
+ })
+
+ describe('categories', () => {
+ beforeEach(() => {
+ CONFIG.CATEGORIES_ACTIVE = true
+ })
+
+ describe('with matching amount of categories', () => {
+ it('has new categories', async () => {
+ await expect(
+ mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ ...variables,
+ categoryIds: ['cat4', 'cat27'],
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ CreateGroup: {
+ categories: expect.arrayContaining([
+ expect.objectContaining({ id: 'cat4' }),
+ expect.objectContaining({ id: 'cat27' }),
+ ]),
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('not even one', () => {
+ describe('by "categoryIds: null"', () => {
+ it('throws error: "Too view categories!"', async () => {
+ const { errors } = await mutate({
+ mutation: createGroupMutation(),
+ variables: { ...variables, categoryIds: null },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Too view categories!')
+ })
+ })
+
+ describe('by "categoryIds: []"', () => {
+ it('throws error: "Too view categories!"', async () => {
+ const { errors } = await mutate({
+ mutation: createGroupMutation(),
+ variables: { ...variables, categoryIds: [] },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Too view categories!')
+ })
+ })
+ })
+
+ describe('four', () => {
+ it('throws error: "Too many categories!"', async () => {
+ const { errors } = await mutate({
+ mutation: createGroupMutation(),
+ variables: { ...variables, categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'] },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Too many categories!')
+ })
+ })
+ })
+ })
+ })
+ })
+
+ describe('building up – clean db after each resolver', () => {
+ describe('Group', () => {
+ beforeAll(async () => {
+ await seedBasicsAndClearAuthentication()
+ })
+
+ afterAll(async () => {
+ await cleanDatabase()
+ })
+
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ const { errors } = await query({ query: groupQuery(), variables: {} })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('authenticated', () => {
+ let otherUser
+ let ownerOfHiddenGroupUser
+
+ beforeAll(async () => {
+ otherUser = await Factory.build(
+ 'user',
+ {
+ id: 'other-user',
+ name: 'Other TestUser',
+ },
+ {
+ email: 'other-user@example.org',
+ password: '1234',
+ },
+ )
+ ownerOfHiddenGroupUser = await Factory.build(
+ 'user',
+ {
+ id: 'owner-of-hidden-group',
+ name: 'Owner Of Hidden Group',
+ },
+ {
+ email: 'owner-of-hidden-group@example.org',
+ password: '1234',
+ },
+ )
+ authenticatedUser = await otherUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'others-group',
+ name: 'Uninteresting Group',
+ about: 'We will change nothing!',
+ description: 'We love it like it is!?' + descriptionAdditional100,
+ groupType: 'closed',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ authenticatedUser = await ownerOfHiddenGroupUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'hidden-group',
+ name: 'Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'second-hidden-group',
+ name: 'Second Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'second-hidden-group',
+ userId: 'current-user',
+ roleInGroup: 'pending',
+ },
+ })
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'third-hidden-group',
+ name: 'Third Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'third-hidden-group',
+ userId: 'current-user',
+ roleInGroup: 'usual',
+ },
+ })
+ authenticatedUser = await user.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'my-group',
+ name: 'The Best Group',
+ about: 'We will change the world!',
+ description: 'Some description' + descriptionAdditional100,
+ groupType: 'public',
+ actionRadius: 'regional',
+ categoryIds,
+ locationName: 'Hamburg, Germany',
+ },
+ })
+ })
+
+ describe('query groups', () => {
+ describe('in general finds only listed groups – no hidden groups where user is none or pending member', () => {
+ describe('without any filters', () => {
+ it('finds all listed groups – including the set descriptionExcerpts and locations', async () => {
+ const result = await query({ query: groupQuery(), variables: {} })
+ expect(result).toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'my-group',
+ slug: 'the-best-group',
+ descriptionExcerpt: 'Some description' + descriptionAdditional100,
+ locationName: 'Hamburg, Germany',
+ location: expect.objectContaining({
+ name: 'Hamburg',
+ nameDE: 'Hamburg',
+ nameEN: 'Hamburg',
+ }),
+ myRole: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'others-group',
+ slug: 'uninteresting-group',
+ descriptionExcerpt: 'We love it like it is!?' + descriptionAdditional100,
+ locationName: null,
+ location: null,
+ myRole: null,
+ }),
+ expect.objectContaining({
+ id: 'third-hidden-group',
+ slug: 'third-investigative-journalism-group',
+ descriptionExcerpt: 'We research …' + descriptionAdditional100,
+ myRole: 'usual',
+ locationName: null,
+ location: null,
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(3)
+ })
+
+ describe('categories', () => {
+ beforeEach(() => {
+ CONFIG.CATEGORIES_ACTIVE = true
+ })
+
+ it('has set categories', async () => {
+ await expect(
+ query({ query: groupQuery(), variables: {} }),
+ ).resolves.toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'my-group',
+ slug: 'the-best-group',
+ categories: expect.arrayContaining([
+ expect.objectContaining({ id: 'cat4' }),
+ expect.objectContaining({ id: 'cat9' }),
+ expect.objectContaining({ id: 'cat15' }),
+ ]),
+ myRole: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'others-group',
+ slug: 'uninteresting-group',
+ categories: expect.arrayContaining([
+ expect.objectContaining({ id: 'cat4' }),
+ expect.objectContaining({ id: 'cat9' }),
+ expect.objectContaining({ id: 'cat15' }),
+ ]),
+ myRole: null,
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('with given id', () => {
+ describe("id = 'my-group'", () => {
+ it('finds only the listed group with this id', async () => {
+ const result = await query({ query: groupQuery(), variables: { id: 'my-group' } })
+ expect(result).toMatchObject({
+ data: {
+ Group: [
+ expect.objectContaining({
+ id: 'my-group',
+ slug: 'the-best-group',
+ myRole: 'owner',
+ }),
+ ],
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(1)
+ })
+ })
+
+ describe("id = 'third-hidden-group'", () => {
+ it("finds only the hidden group where I'm 'usual' member", async () => {
+ const result = await query({
+ query: groupQuery(),
+ variables: { id: 'third-hidden-group' },
+ })
+ expect(result).toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'third-hidden-group',
+ slug: 'third-investigative-journalism-group',
+ myRole: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(1)
+ })
+ })
+
+ describe("id = 'second-hidden-group'", () => {
+ it("finds no hidden group where I'm 'pending' member", async () => {
+ const result = await query({
+ query: groupQuery(),
+ variables: { id: 'second-hidden-group' },
+ })
+ expect(result.data.Group.length).toBe(0)
+ })
+ })
+
+ describe("id = 'hidden-group'", () => {
+ it("finds no hidden group where I'm not(!) a member at all", async () => {
+ const result = await query({
+ query: groupQuery(),
+ variables: { id: 'hidden-group' },
+ })
+ expect(result.data.Group.length).toBe(0)
+ })
+ })
+ })
+
+ describe('with given slug', () => {
+ describe("slug = 'the-best-group'", () => {
+ it('finds only the listed group with this slug', async () => {
+ const result = await query({
+ query: groupQuery(),
+ variables: { slug: 'the-best-group' },
+ })
+ expect(result).toMatchObject({
+ data: {
+ Group: [
+ expect.objectContaining({
+ id: 'my-group',
+ slug: 'the-best-group',
+ myRole: 'owner',
+ }),
+ ],
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(1)
+ })
+ })
+
+ describe("slug = 'third-investigative-journalism-group'", () => {
+ it("finds only the hidden group where I'm 'usual' member", async () => {
+ const result = await query({
+ query: groupQuery(),
+ variables: { slug: 'third-investigative-journalism-group' },
+ })
+ expect(result).toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'third-hidden-group',
+ slug: 'third-investigative-journalism-group',
+ myRole: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(1)
+ })
+ })
+
+ describe("slug = 'second-investigative-journalism-group'", () => {
+ it("finds no hidden group where I'm 'pending' member", async () => {
+ const result = await query({
+ query: groupQuery(),
+ variables: { slug: 'second-investigative-journalism-group' },
+ })
+ expect(result.data.Group.length).toBe(0)
+ })
+ })
+
+ describe("slug = 'investigative-journalism-group'", () => {
+ it("finds no hidden group where I'm not(!) a member at all", async () => {
+ const result = await query({
+ query: groupQuery(),
+ variables: { slug: 'investigative-journalism-group' },
+ })
+ expect(result.data.Group.length).toBe(0)
+ })
+ })
+ })
+
+ describe('isMember = true', () => {
+ it('finds only listed groups where user is member', async () => {
+ const result = await query({ query: groupQuery(), variables: { isMember: true } })
+ expect(result).toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'my-group',
+ slug: 'the-best-group',
+ myRole: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'third-hidden-group',
+ slug: 'third-investigative-journalism-group',
+ myRole: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(2)
+ })
+ })
+
+ describe('isMember = false', () => {
+ it('finds only listed groups where user is not(!) member', async () => {
+ const result = await query({ query: groupQuery(), variables: { isMember: false } })
+ expect(result).toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'others-group',
+ slug: 'uninteresting-group',
+ myRole: null,
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(1)
+ })
+ })
+ })
+ })
+ })
+ })
+
+ describe('JoinGroup', () => {
+ beforeAll(async () => {
+ await seedBasicsAndClearAuthentication()
+ })
+
+ afterAll(async () => {
+ await cleanDatabase()
+ })
+
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'not-existing-group',
+ userId: 'current-user',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('authenticated', () => {
+ let ownerOfClosedGroupUser
+ let ownerOfHiddenGroupUser
+
+ beforeAll(async () => {
+ // create users
+ ownerOfClosedGroupUser = await Factory.build(
+ 'user',
+ {
+ id: 'owner-of-closed-group',
+ name: 'Owner Of Closed Group',
+ },
+ {
+ email: 'owner-of-closed-group@example.org',
+ password: '1234',
+ },
+ )
+ ownerOfHiddenGroupUser = await Factory.build(
+ 'user',
+ {
+ id: 'owner-of-hidden-group',
+ name: 'Owner Of Hidden Group',
+ },
+ {
+ email: 'owner-of-hidden-group@example.org',
+ password: '1234',
+ },
+ )
+ // create groups
+ // public-group
+ authenticatedUser = await ownerOfClosedGroupUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'closed-group',
+ name: 'Uninteresting Group',
+ about: 'We will change nothing!',
+ description: 'We love it like it is!?' + descriptionAdditional100,
+ groupType: 'closed',
+ actionRadius: 'national',
+ categoryIds,
+ },
+ })
+ authenticatedUser = await ownerOfHiddenGroupUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'hidden-group',
+ name: 'Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ authenticatedUser = await user.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'public-group',
+ name: 'The Best Group',
+ about: 'We will change the world!',
+ description: 'Some description' + descriptionAdditional100,
+ groupType: 'public',
+ actionRadius: 'regional',
+ categoryIds,
+ },
+ })
+ })
+
+ describe('public group', () => {
+ describe('joined by "owner-of-closed-group"', () => {
+ it('has "usual" as membership role', async () => {
+ await expect(
+ mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'owner-of-closed-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ JoinGroup: {
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'usual',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('joined by its owner', () => {
+ describe('does not create additional "MEMBER_OF" relation and therefore', () => {
+ it('has still "owner" as membership role', async () => {
+ await expect(
+ mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'current-user',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ JoinGroup: {
+ id: 'current-user',
+ myRoleInGroup: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+ })
+
+ describe('closed group', () => {
+ describe('joined by "current-user"', () => {
+ it('has "pending" as membership role', async () => {
+ await expect(
+ mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'current-user',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ JoinGroup: {
+ id: 'current-user',
+ myRoleInGroup: 'pending',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('joined by its owner', () => {
+ describe('does not create additional "MEMBER_OF" relation and therefore', () => {
+ it('has still "owner" as membership role', async () => {
+ await expect(
+ mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'owner-of-closed-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ JoinGroup: {
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+ })
+
+ describe('hidden group', () => {
+ describe('joined by "owner-of-closed-group"', () => {
+ it('throws authorization error', async () => {
+ const { errors } = await query({
+ query: joinGroupMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'owner-of-closed-group',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('joined by its owner', () => {
+ describe('does not create additional "MEMBER_OF" relation and therefore', () => {
+ it('has still "owner" as membership role', async () => {
+ await expect(
+ mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'owner-of-hidden-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ JoinGroup: {
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+ })
+ })
+ })
+
+ describe('GroupMembers', () => {
+ beforeAll(async () => {
+ await seedBasicsAndClearAuthentication()
+ })
+
+ afterAll(async () => {
+ await cleanDatabase()
+ })
+
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ variables = {
+ id: 'not-existing-group',
+ }
+ const { errors } = await query({ query: groupMembersQuery(), variables })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('authenticated', () => {
+ let otherUser
+ let pendingUser
+ let ownerOfClosedGroupUser
+ let ownerOfHiddenGroupUser
+
+ beforeAll(async () => {
+ // create users
+ otherUser = await Factory.build(
+ 'user',
+ {
+ id: 'other-user',
+ name: 'Other TestUser',
+ },
+ {
+ email: 'other-user@example.org',
+ password: '1234',
+ },
+ )
+ pendingUser = await Factory.build(
+ 'user',
+ {
+ id: 'pending-user',
+ name: 'Pending TestUser',
+ },
+ {
+ email: 'pending@example.org',
+ password: '1234',
+ },
+ )
+ ownerOfClosedGroupUser = await Factory.build(
+ 'user',
+ {
+ id: 'owner-of-closed-group',
+ name: 'Owner Of Closed Group',
+ },
+ {
+ email: 'owner-of-closed-group@example.org',
+ password: '1234',
+ },
+ )
+ ownerOfHiddenGroupUser = await Factory.build(
+ 'user',
+ {
+ id: 'owner-of-hidden-group',
+ name: 'Owner Of Hidden Group',
+ },
+ {
+ email: 'owner-of-hidden-group@example.org',
+ password: '1234',
+ },
+ )
+ // create groups
+ // public-group
+ authenticatedUser = await user.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'public-group',
+ name: 'The Best Group',
+ about: 'We will change the world!',
+ description: 'Some description' + descriptionAdditional100,
+ groupType: 'public',
+ actionRadius: 'regional',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'owner-of-closed-group',
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'owner-of-hidden-group',
+ },
+ })
+ // closed-group
+ authenticatedUser = await ownerOfClosedGroupUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'closed-group',
+ name: 'Uninteresting Group',
+ about: 'We will change nothing!',
+ description: 'We love it like it is!?' + descriptionAdditional100,
+ groupType: 'closed',
+ actionRadius: 'national',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: joinGroupMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'current-user',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'owner-of-hidden-group',
+ roleInGroup: 'usual',
+ },
+ })
+ // hidden-group
+ authenticatedUser = await ownerOfHiddenGroupUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'hidden-group',
+ name: 'Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'pending-user',
+ roleInGroup: 'pending',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'current-user',
+ roleInGroup: 'usual',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'owner-of-closed-group',
+ roleInGroup: 'admin',
+ },
+ })
+
+ authenticatedUser = null
+ })
+
+ describe('public group', () => {
+ beforeEach(async () => {
+ variables = {
+ id: 'public-group',
+ }
+ })
+
+ describe('query group members', () => {
+ describe('by owner "current-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ it('finds all members', async () => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables,
+ })
+ expect(result).toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'current-user',
+ myRoleInGroup: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'usual',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.GroupMembers.length).toBe(3)
+ })
+ })
+
+ describe('by usual member "owner-of-closed-group"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerOfClosedGroupUser.toJson()
+ })
+
+ it('finds all members', async () => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables,
+ })
+ expect(result).toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'current-user',
+ myRoleInGroup: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'usual',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.GroupMembers.length).toBe(3)
+ })
+ })
+
+ describe('by none member "other-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await otherUser.toJson()
+ })
+
+ it('finds all members', async () => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables,
+ })
+ expect(result).toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'current-user',
+ myRoleInGroup: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'usual',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.GroupMembers.length).toBe(3)
+ })
+ })
+ })
+ })
+
+ describe('closed group', () => {
+ beforeEach(async () => {
+ variables = {
+ id: 'closed-group',
+ }
+ })
+
+ describe('query group members', () => {
+ describe('by owner "owner-of-closed-group"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerOfClosedGroupUser.toJson()
+ })
+
+ it('finds all members', async () => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables,
+ })
+ expect(result).toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'current-user',
+ myRoleInGroup: 'pending',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.GroupMembers.length).toBe(3)
+ })
+ })
+
+ describe('by usual member "owner-of-hidden-group"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerOfHiddenGroupUser.toJson()
+ })
+
+ it('finds all members', async () => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables,
+ })
+ expect(result).toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'current-user',
+ myRoleInGroup: 'pending',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.GroupMembers.length).toBe(3)
+ })
+ })
+
+ describe('by pending member "current-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await query({ query: groupMembersQuery(), variables })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('by none member "other-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await otherUser.toJson()
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await query({ query: groupMembersQuery(), variables })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+ })
+
+ describe('hidden group', () => {
+ beforeEach(async () => {
+ variables = {
+ id: 'hidden-group',
+ }
+ })
+
+ describe('query group members', () => {
+ describe('by owner "owner-of-hidden-group"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerOfHiddenGroupUser.toJson()
+ })
+
+ it('finds all members', async () => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables,
+ })
+ expect(result).toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'pending-user',
+ myRoleInGroup: 'pending',
+ }),
+ expect.objectContaining({
+ id: 'current-user',
+ myRoleInGroup: 'usual',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'admin',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'owner',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.GroupMembers.length).toBe(4)
+ })
+ })
+
+ describe('by usual member "current-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ it('finds all members', async () => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables,
+ })
+ expect(result).toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'pending-user',
+ myRoleInGroup: 'pending',
+ }),
+ expect.objectContaining({
+ id: 'current-user',
+ myRoleInGroup: 'usual',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'admin',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'owner',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.GroupMembers.length).toBe(4)
+ })
+ })
+
+ describe('by admin member "owner-of-closed-group"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerOfClosedGroupUser.toJson()
+ })
+
+ it('finds all members', async () => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables,
+ })
+ expect(result).toMatchObject({
+ data: {
+ GroupMembers: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'pending-user',
+ myRoleInGroup: 'pending',
+ }),
+ expect.objectContaining({
+ id: 'current-user',
+ myRoleInGroup: 'usual',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-closed-group',
+ myRoleInGroup: 'admin',
+ }),
+ expect.objectContaining({
+ id: 'owner-of-hidden-group',
+ myRoleInGroup: 'owner',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.GroupMembers.length).toBe(4)
+ })
+ })
+
+ describe('by pending member "pending-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await query({ query: groupMembersQuery(), variables })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('by none member "other-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await otherUser.toJson()
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await query({ query: groupMembersQuery(), variables })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+ })
+ })
+ })
+
+ describe('ChangeGroupMemberRole', () => {
+ beforeAll(async () => {
+ await seedComplexScenarioAndClearAuthentication()
+ })
+
+ afterAll(async () => {
+ await cleanDatabase()
+ })
+
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'not-existing-group',
+ userId: 'current-user',
+ roleInGroup: 'pending',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('authenticated', () => {
+ describe('in all group types – here "closed-group" for example', () => {
+ beforeEach(async () => {
+ variables = {
+ groupId: 'closed-group',
+ }
+ })
+
+ describe('join the members and give them their prospective roles', () => {
+ describe('by owner "owner-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ })
+
+ describe('for "usual-member-user"', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ userId: 'usual-member-user',
+ }
+ })
+
+ describe('as usual', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'usual',
+ }
+ })
+
+ it('has role usual', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'usual-member-user',
+ myRoleInGroup: 'usual',
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ // the GQL mutation needs this fields in the result for testing
+ it.todo('has "updatedAt" newer as "createdAt"')
+ })
+ })
+
+ describe('for "admin-member-user"', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ userId: 'admin-member-user',
+ }
+ })
+
+ describe('as admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('has role admin', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'admin-member-user',
+ myRoleInGroup: 'admin',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('for "second-owner-member-user"', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ userId: 'second-owner-member-user',
+ }
+ })
+
+ describe('as owner', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'owner',
+ }
+ })
+
+ it('has role owner', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'second-owner-member-user',
+ myRoleInGroup: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+ })
+ })
+
+ describe('switch role', () => {
+ describe('of owner "owner-member-user"', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ userId: 'owner-member-user',
+ }
+ })
+
+ describe('by owner themself "owner-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ })
+
+ describe('to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ // shall this be possible in the future?
+ // or shall only an owner who gave the second owner the owner role downgrade themself for savety?
+ // otherwise the first owner who downgrades the other one has the victory over the group!
+ describe('by second owner "second-owner-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await secondOwnerMemberUser.toJson()
+ })
+
+ describe('to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('to same role owner', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'owner',
+ }
+ })
+
+ it('has role owner still', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'owner-member-user',
+ myRoleInGroup: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('by admin "admin-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await adminMemberUser.toJson()
+ })
+
+ describe('to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by usual member "usual-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await usualMemberUser.toJson()
+ })
+
+ describe('to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by still pending member "pending-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await pendingMemberUser.toJson()
+ })
+
+ describe('to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+ })
+
+ describe('of admin "admin-member-user"', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ userId: 'admin-member-user',
+ }
+ })
+
+ describe('by owner "owner-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ })
+
+ describe('to owner', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'owner',
+ }
+ })
+
+ it('has role owner', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'admin-member-user',
+ myRoleInGroup: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('back to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by usual member "usual-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await usualMemberUser.toJson()
+ })
+
+ describe('upgrade to owner', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'owner',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('degrade to usual', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'usual',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by still pending member "pending-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await pendingMemberUser.toJson()
+ })
+
+ describe('upgrade to owner', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'owner',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('degrade to usual', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'usual',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by none member "current-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ describe('upgrade to owner', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'owner',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('degrade to pending again', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'pending',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+ })
+
+ describe('of usual member "usual-member-user"', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ userId: 'usual-member-user',
+ }
+ })
+
+ describe('by owner "owner-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ })
+
+ describe('to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('has role admin', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'usual-member-user',
+ myRoleInGroup: 'admin',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('back to usual', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'usual',
+ }
+ })
+
+ it('has role usual again', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'usual-member-user',
+ myRoleInGroup: 'usual',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('by usual member "usual-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await usualMemberUser.toJson()
+ })
+
+ describe('upgrade to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('degrade to pending', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'pending',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by still pending member "pending-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await pendingMemberUser.toJson()
+ })
+
+ describe('upgrade to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('degrade to pending', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'pending',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by none member "current-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ describe('upgrade to admin', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'admin',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('degrade to pending again', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'pending',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+ })
+
+ describe('of still pending member "pending-member-user"', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ userId: 'pending-member-user',
+ }
+ })
+
+ describe('by owner "owner-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ })
+
+ describe('to usual', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'usual',
+ }
+ })
+
+ it('has role usual', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'pending-member-user',
+ myRoleInGroup: 'usual',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('back to pending', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'pending',
+ }
+ })
+
+ it('has role usual again', async () => {
+ await expect(
+ mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ ChangeGroupMemberRole: {
+ id: 'pending-member-user',
+ myRoleInGroup: 'pending',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('by usual member "usual-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await usualMemberUser.toJson()
+ })
+
+ describe('upgrade to usual', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'usual',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by still pending member "pending-member-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await pendingMemberUser.toJson()
+ })
+
+ describe('upgrade to usual', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'usual',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+
+ describe('by none member "current-user"', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ describe('upgrade to usual', () => {
+ beforeEach(async () => {
+ variables = {
+ ...variables,
+ roleInGroup: 'usual',
+ }
+ })
+
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables,
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+ })
+ })
+ })
+ })
+ })
+
+ describe('LeaveGroup', () => {
+ beforeAll(async () => {
+ await seedComplexScenarioAndClearAuthentication()
+ // closed-group
+ authenticatedUser = await ownerMemberUser.toJson()
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'pending-member-user',
+ roleInGroup: 'pending',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'usual-member-user',
+ roleInGroup: 'usual',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'admin-member-user',
+ roleInGroup: 'admin',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'second-owner-member-user',
+ roleInGroup: 'owner',
+ },
+ })
+
+ authenticatedUser = null
+ })
+
+ afterAll(async () => {
+ await cleanDatabase()
+ })
+
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ groupId: 'not-existing-group',
+ userId: 'current-user',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('authenticated', () => {
+ describe('in all group types', () => {
+ describe('here "closed-group" for example', () => {
+ const memberInGroup = async (userId, groupId) => {
+ const result = await query({
+ query: groupMembersQuery(),
+ variables: {
+ id: groupId,
+ },
+ })
+ return result.data && result.data.GroupMembers
+ ? !!result.data.GroupMembers.find((member) => member.id === userId)
+ : null
+ }
+
+ beforeEach(async () => {
+ authenticatedUser = null
+ variables = {
+ groupId: 'closed-group',
+ }
+ })
+
+ describe('left by "pending-member-user"', () => {
+ it('has "null" as membership role, was in the group, and left the group', async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ expect(await memberInGroup('pending-member-user', 'closed-group')).toBe(true)
+ authenticatedUser = await pendingMemberUser.toJson()
+ await expect(
+ mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ ...variables,
+ userId: 'pending-member-user',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ LeaveGroup: {
+ id: 'pending-member-user',
+ myRoleInGroup: null,
+ },
+ },
+ errors: undefined,
+ })
+ authenticatedUser = await ownerMemberUser.toJson()
+ expect(await memberInGroup('pending-member-user', 'closed-group')).toBe(false)
+ })
+ })
+
+ describe('left by "usual-member-user"', () => {
+ it('has "null" as membership role, was in the group, and left the group', async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ expect(await memberInGroup('usual-member-user', 'closed-group')).toBe(true)
+ authenticatedUser = await usualMemberUser.toJson()
+ await expect(
+ mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ ...variables,
+ userId: 'usual-member-user',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ LeaveGroup: {
+ id: 'usual-member-user',
+ myRoleInGroup: null,
+ },
+ },
+ errors: undefined,
+ })
+ authenticatedUser = await ownerMemberUser.toJson()
+ expect(await memberInGroup('usual-member-user', 'closed-group')).toBe(false)
+ })
+ })
+
+ describe('left by "admin-member-user"', () => {
+ it('has "null" as membership role, was in the group, and left the group', async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ expect(await memberInGroup('admin-member-user', 'closed-group')).toBe(true)
+ authenticatedUser = await adminMemberUser.toJson()
+ await expect(
+ mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ ...variables,
+ userId: 'admin-member-user',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ LeaveGroup: {
+ id: 'admin-member-user',
+ myRoleInGroup: null,
+ },
+ },
+ errors: undefined,
+ })
+ authenticatedUser = await ownerMemberUser.toJson()
+ expect(await memberInGroup('admin-member-user', 'closed-group')).toBe(false)
+ })
+ })
+
+ describe('left by "owner-member-user"', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ const { errors } = await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ ...variables,
+ userId: 'owner-member-user',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('left by "second-owner-member-user"', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = await secondOwnerMemberUser.toJson()
+ const { errors } = await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ ...variables,
+ userId: 'second-owner-member-user',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('left by "none-member-user"', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = await noMemberUser.toJson()
+ const { errors } = await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ ...variables,
+ userId: 'none-member-user',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('as "owner-member-user" try to leave member "usual-member-user"', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = await ownerMemberUser.toJson()
+ const { errors } = await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ ...variables,
+ userId: 'usual-member-user',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('as "usual-member-user" try to leave member "admin-member-user"', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = await usualMemberUser.toJson()
+ const { errors } = await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ ...variables,
+ userId: 'admin-member-user',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+ })
+ })
+ })
+
+ describe('UpdateGroup', () => {
+ beforeAll(async () => {
+ await seedBasicsAndClearAuthentication()
+ })
+
+ afterAll(async () => {
+ await cleanDatabase()
+ })
+
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ const { errors } = await mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ slug: 'my-best-group',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('authenticated', () => {
+ let noMemberUser
+
+ beforeAll(async () => {
+ noMemberUser = await Factory.build(
+ 'user',
+ {
+ id: 'none-member-user',
+ name: 'None Member TestUser',
+ },
+ {
+ email: 'none-member-user@example.org',
+ password: '1234',
+ },
+ )
+ usualMemberUser = await Factory.build(
+ 'user',
+ {
+ id: 'usual-member-user',
+ name: 'Usual Member TestUser',
+ },
+ {
+ email: 'usual-member-user@example.org',
+ password: '1234',
+ },
+ )
+ authenticatedUser = await noMemberUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'others-group',
+ name: 'Uninteresting Group',
+ about: 'We will change nothing!',
+ description: 'We love it like it is!?' + descriptionAdditional100,
+ groupType: 'closed',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ authenticatedUser = await user.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'my-group',
+ name: 'The Best Group',
+ about: 'We will change the world!',
+ description: 'Some description' + descriptionAdditional100,
+ groupType: 'public',
+ actionRadius: 'regional',
+ categoryIds,
+ locationName: 'Berlin, Germany',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'my-group',
+ userId: 'usual-member-user',
+ roleInGroup: 'usual',
+ },
+ })
+ })
+
+ describe('change group settings', () => {
+ describe('as owner', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ describe('all standard settings – excluding location', () => {
+ it('has updated the settings', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ name: 'The New Group For Our Country',
+ about: 'We will change the land!',
+ description: 'Some country relevant description' + descriptionAdditional100,
+ actionRadius: 'national',
+ // avatar, // test this as result
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ id: 'my-group',
+ name: 'The New Group For Our Country',
+ slug: 'the-new-group-for-our-country', // changing the slug is tested in the slugifyMiddleware
+ about: 'We will change the land!',
+ description: 'Some country relevant description' + descriptionAdditional100,
+ descriptionExcerpt:
+ 'Some country relevant description' + descriptionAdditional100,
+ actionRadius: 'national',
+ // avatar, // test this as result
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('location', () => {
+ describe('"locationName" is undefined – shall not change location', () => {
+ it('has left locaton unchanged as "Berlin"', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ id: 'my-group',
+ locationName: 'Berlin, Germany',
+ location: expect.objectContaining({
+ name: 'Berlin',
+ nameDE: 'Berlin',
+ nameEN: 'Berlin',
+ }),
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('"locationName" is null – shall change location "Berlin" to unset location', () => {
+ it('has updated the location to unset location', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ locationName: null,
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ id: 'my-group',
+ locationName: null,
+ location: null,
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('change unset location to "Paris"', () => {
+ it('has updated the location to "Paris"', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ locationName: 'Paris, France',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ id: 'my-group',
+ locationName: 'Paris, France',
+ location: expect.objectContaining({
+ name: 'Paris',
+ nameDE: 'Paris',
+ nameEN: 'Paris',
+ }),
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('change location "Paris" to "Hamburg"', () => {
+ it('has updated the location to "Hamburg"', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ locationName: 'Hamburg, Germany',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ id: 'my-group',
+ locationName: 'Hamburg, Germany',
+ location: expect.objectContaining({
+ name: 'Hamburg',
+ nameDE: 'Hamburg',
+ nameEN: 'Hamburg',
+ }),
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('"locationName" is empty string – shall change location "Hamburg" to unset location ', () => {
+ it('has updated the location to unset', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ locationName: '', // empty string '' sets it to null
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ id: 'my-group',
+ locationName: null,
+ location: null,
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('description', () => {
+ describe('length without HTML', () => {
+ describe('less then 100 chars', () => {
+ it('throws error: "Description too short!"', async () => {
+ const { errors } = await mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ description:
+ '0123456789' +
+ '0123456789 ',
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Description too short!')
+ })
+ })
+ })
+ })
+
+ describe('categories', () => {
+ beforeEach(async () => {
+ CONFIG.CATEGORIES_ACTIVE = true
+ })
+
+ describe('with matching amount of categories', () => {
+ it('has new categories', async () => {
+ await expect(
+ mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ categoryIds: ['cat4', 'cat27'],
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UpdateGroup: {
+ id: 'my-group',
+ categories: expect.arrayContaining([
+ expect.objectContaining({ id: 'cat4' }),
+ expect.objectContaining({ id: 'cat27' }),
+ ]),
+ myRole: 'owner',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('not even one', () => {
+ describe('by "categoryIds: []"', () => {
+ it('throws error: "Too view categories!"', async () => {
+ const { errors } = await mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ categoryIds: [],
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Too view categories!')
+ })
+ })
+ })
+
+ describe('four', () => {
+ it('throws error: "Too many categories!"', async () => {
+ const { errors } = await mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'],
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Too many categories!')
+ })
+ })
+ })
+ })
+
+ describe('as "usual-member-user" member, no(!) owner', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = await usualMemberUser.toJson()
+ const { errors } = await mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ name: 'The New Group For Our Country',
+ about: 'We will change the land!',
+ description: 'Some country relevant description' + descriptionAdditional100,
+ actionRadius: 'national',
+ categoryIds: ['cat4', 'cat27'],
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+
+ describe('as "none-member-user"', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = await noMemberUser.toJson()
+ const { errors } = await mutate({
+ mutation: updateGroupMutation(),
+ variables: {
+ id: 'my-group',
+ name: 'The New Group For Our Country',
+ about: 'We will change the land!',
+ description: 'Some country relevant description' + descriptionAdditional100,
+ actionRadius: 'national',
+ categoryIds: ['cat4', 'cat27'],
+ },
+ })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
+ })
+ })
+ })
+ })
+ })
+ })
+})
diff --git a/backend/src/schema/resolvers/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js
index f2861e7a00..6e8211521c 100644
--- a/backend/src/schema/resolvers/helpers/Resolver.js
+++ b/backend/src/schema/resolvers/helpers/Resolver.js
@@ -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
+}
diff --git a/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js b/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js
new file mode 100644
index 0000000000..73dfaad91c
--- /dev/null
+++ b/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js
@@ -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
+}
diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js
index d9a04732c3..78515e6418 100644
--- a/backend/src/schema/resolvers/posts.js
+++ b/backend/src/schema/resolvers/posts.js
@@ -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:
diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js
index 52bd8fcd00..6fc9b57229 100644
--- a/backend/src/schema/resolvers/posts.spec.js
+++ b/backend/src/schema/resolvers/posts.spec.js
@@ -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 },
})
diff --git a/backend/src/schema/resolvers/postsInGroups.spec.js b/backend/src/schema/resolvers/postsInGroups.spec.js
new file mode 100644
index 0000000000..d17c928ecc
--- /dev/null
+++ b/backend/src/schema/resolvers/postsInGroups.spec.js
@@ -0,0 +1,1516 @@
+import { createTestClient } from 'apollo-server-testing'
+import Factory, { cleanDatabase } from '../../db/factories'
+import { getNeode, getDriver } from '../../db/neo4j'
+import createServer from '../../server'
+import {
+ createGroupMutation,
+ changeGroupMemberRoleMutation,
+ leaveGroupMutation,
+} from '../../db/graphql/groups'
+import {
+ createPostMutation,
+ postQuery,
+ filterPosts,
+ profilePagePosts,
+ searchPosts,
+} from '../../db/graphql/posts'
+// eslint-disable-next-line no-unused-vars
+import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups'
+import CONFIG from '../../config'
+import { signupVerificationMutation } from '../../db/graphql/authentications'
+
+CONFIG.CATEGORIES_ACTIVE = false
+
+jest.mock('../../constants/groups', () => {
+ return {
+ __esModule: true,
+ DESCRIPTION_WITHOUT_HTML_LENGTH_MIN: 5,
+ }
+})
+
+const driver = getDriver()
+const neode = getNeode()
+
+let query
+let mutate
+let anyUser
+let allGroupsUser
+let pendingUser
+let publicUser
+let closedUser
+let hiddenUser
+let authenticatedUser
+let newUser
+
+beforeAll(async () => {
+ await cleanDatabase()
+
+ const { server } = createServer({
+ context: () => {
+ return {
+ driver,
+ neode,
+ user: authenticatedUser,
+ }
+ },
+ })
+ query = createTestClient(server).query
+ mutate = createTestClient(server).mutate
+})
+
+afterAll(async () => {
+ await cleanDatabase()
+})
+
+describe('Posts in Groups', () => {
+ beforeAll(async () => {
+ anyUser = await Factory.build('user', {
+ id: 'any-user',
+ name: 'Any User',
+ about: 'I am just an ordinary user and do not belong to any group.',
+ })
+
+ allGroupsUser = await Factory.build('user', {
+ id: 'all-groups-user',
+ name: 'All Groups User',
+ about: 'I am a member of all groups.',
+ })
+ pendingUser = await Factory.build('user', {
+ id: 'pending-user',
+ name: 'Pending User',
+ about: 'I am a pending member of all groups.',
+ })
+ publicUser = await Factory.build('user', {
+ id: 'public-user',
+ name: 'Public User',
+ about: 'I am the owner of the public group.',
+ })
+
+ closedUser = await Factory.build('user', {
+ id: 'closed-user',
+ name: 'Private User',
+ about: 'I am the owner of the closed group.',
+ })
+
+ hiddenUser = await Factory.build('user', {
+ id: 'hidden-user',
+ name: 'Secret User',
+ about: 'I am the owner of the hidden group.',
+ })
+
+ authenticatedUser = await publicUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'public-group',
+ name: 'The Public Group',
+ about: 'The public group!',
+ description: 'Anyone can see the posts of this group.',
+ groupType: 'public',
+ actionRadius: 'regional',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'pending-user',
+ roleInGroup: 'pending',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'all-groups-user',
+ roleInGroup: 'usual',
+ },
+ })
+ authenticatedUser = await closedUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'closed-group',
+ name: 'The Closed Group',
+ about: 'The closed group!',
+ description: 'Only members of this group can see the posts of this group.',
+ groupType: 'closed',
+ actionRadius: 'regional',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'pending-user',
+ roleInGroup: 'pending',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'all-groups-user',
+ roleInGroup: 'usual',
+ },
+ })
+ authenticatedUser = await hiddenUser.toJson()
+ await mutate({
+ mutation: createGroupMutation(),
+ variables: {
+ id: 'hidden-group',
+ name: 'The Hidden Group',
+ about: 'The hidden group!',
+ description: 'Only members of this group can see the posts of this group.',
+ groupType: 'hidden',
+ actionRadius: 'regional',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'pending-user',
+ roleInGroup: 'pending',
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'all-groups-user',
+ roleInGroup: 'usual',
+ },
+ })
+ authenticatedUser = await anyUser.toJson()
+ await mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ })
+ })
+
+ describe('creating posts in groups', () => {
+ describe('without membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await anyUser.toJson()
+ })
+
+ it('throws an error for public groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p2',
+ title: 'A post to a pubic group',
+ content: 'I am posting into a public group without being a member of the group',
+ groupId: 'public-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]),
+ })
+ })
+
+ it('throws an error for closed groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p2',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group without being a member of the group',
+ groupId: 'closed-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]),
+ })
+ })
+
+ it('throws an error for hidden groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p2',
+ title: 'A post to a closed group',
+ content: 'I am posting into a hidden group without being a member of the group',
+ groupId: 'hidden-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]),
+ })
+ })
+ })
+
+ describe('as a pending member of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('throws an error for public groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p2',
+ title: 'A post to a pubic group',
+ content: 'I am posting into a public group with a pending membership',
+ groupId: 'public-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]),
+ })
+ })
+
+ it('throws an error for closed groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p2',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group with a pending membership',
+ groupId: 'closed-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]),
+ })
+ })
+
+ it('throws an error for hidden groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'p2',
+ title: 'A post to a closed group',
+ content: 'I am posting into a hidden group with a pending membership',
+ groupId: 'hidden-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]),
+ })
+ })
+ })
+
+ describe('as a member of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await allGroupsUser.toJson()
+ })
+
+ it('creates a post for public groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ groupId: 'public-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ CreatePost: {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('creates a post for closed groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ groupId: 'closed-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ CreatePost: {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ },
+ errors: undefined,
+ })
+ })
+
+ it('creates a post for hidden groups', async () => {
+ await expect(
+ mutate({
+ mutation: createPostMutation(),
+ variables: {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ groupId: 'hidden-group',
+ },
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ CreatePost: {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('visibility of posts', () => {
+ describe('query post by ID', () => {
+ describe('without authentication', () => {
+ beforeAll(async () => {
+ authenticatedUser = null
+ })
+
+ it('shows a post of the public group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-public-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+
+ it('does not show a post of a closed group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: [],
+ },
+ errors: undefined,
+ })
+ })
+
+ it('does not show a post of a hidden group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: [],
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('as new user', () => {
+ beforeAll(async () => {
+ await Factory.build('emailAddress', {
+ email: 'new-user@example.org',
+ nonce: '12345',
+ verifiedAt: null,
+ })
+ const result = await mutate({
+ mutation: signupVerificationMutation,
+ variables: {
+ name: 'New User',
+ slug: 'new-user',
+ nonce: '12345',
+ password: '1234',
+ about: 'I am a new user!',
+ email: 'new-user@example.org',
+ termsAndConditionsAgreedVersion: '0.0.1',
+ },
+ })
+ newUser = result.data.SignupVerification
+ authenticatedUser = newUser
+ })
+
+ it('shows a post of the public group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-public-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+
+ it('does not show a post of a closed group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: [],
+ },
+ errors: undefined,
+ })
+ })
+
+ it('does not show a post of a hidden group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: [],
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('without membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await anyUser.toJson()
+ })
+
+ it('shows a post of the public group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-public-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+
+ it('does not show a post of a closed group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: [],
+ },
+ errors: undefined,
+ })
+ })
+
+ it('does not show a post of a hidden group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: [],
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('with pending membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('shows a post of the public group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-public-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+
+ it('does not show a post of a closed group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: [],
+ },
+ errors: undefined,
+ })
+ })
+
+ it('does not show a post of a hidden group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: [],
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('as member of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await allGroupsUser.toJson()
+ })
+
+ it('shows post of the public group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-public-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+
+ it('shows post of a closed group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+
+ it('shows post of a hidden group', async () => {
+ await expect(
+ query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }),
+ ).resolves.toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('filter posts', () => {
+ describe('without authentication', () => {
+ beforeAll(async () => {
+ authenticatedUser = null
+ })
+
+ it('shows the post of the public group and the post without group', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('as new user', () => {
+ beforeAll(async () => {
+ authenticatedUser = newUser
+ })
+
+ it('shows the post of the public group and the post without group', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('without membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await anyUser.toJson()
+ })
+
+ it('shows the post of the public group and the post without group', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('with pending membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('shows the post of the public group and the post without group', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('as member of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await allGroupsUser.toJson()
+ })
+
+ it('shows all posts', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(4)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('profile page posts', () => {
+ describe('without authentication', () => {
+ beforeAll(async () => {
+ authenticatedUser = null
+ })
+
+ it('shows the post of the public group and the post without group', async () => {
+ const result = await query({ query: profilePagePosts(), variables: {} })
+ expect(result.data.profilePagePosts).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ profilePagePosts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('as new user', () => {
+ beforeAll(async () => {
+ authenticatedUser = newUser
+ })
+
+ it('shows the post of the public group and the post without group', async () => {
+ const result = await query({ query: profilePagePosts(), variables: {} })
+ expect(result.data.profilePagePosts).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ profilePagePosts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('without membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await anyUser.toJson()
+ })
+
+ it('shows the post of the public group and the post without group', async () => {
+ const result = await query({ query: profilePagePosts(), variables: {} })
+ expect(result.data.profilePagePosts).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ profilePagePosts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('with pending membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('shows the post of the public group and the post without group', async () => {
+ const result = await query({ query: profilePagePosts(), variables: {} })
+ expect(result.data.profilePagePosts).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ profilePagePosts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('as member of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await allGroupsUser.toJson()
+ })
+
+ it('shows all posts', async () => {
+ const result = await query({ query: profilePagePosts(), variables: {} })
+ expect(result.data.profilePagePosts).toHaveLength(4)
+ expect(result).toMatchObject({
+ data: {
+ profilePagePosts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('searchPosts', () => {
+ describe('without authentication', () => {
+ beforeAll(async () => {
+ authenticatedUser = null
+ })
+
+ it('finds nothing', async () => {
+ const result = await query({
+ query: searchPosts(),
+ variables: {
+ query: 'post',
+ postsOffset: 0,
+ firstPosts: 25,
+ },
+ })
+ expect(result.data.searchPosts.posts).toHaveLength(0)
+ expect(result).toMatchObject({
+ data: {
+ searchPosts: {
+ postCount: 0,
+ posts: [],
+ },
+ },
+ })
+ })
+ })
+
+ describe('as new user', () => {
+ beforeAll(async () => {
+ authenticatedUser = newUser
+ })
+
+ it('finds the post of the public group and the post without group', async () => {
+ const result = await query({
+ query: searchPosts(),
+ variables: {
+ query: 'post',
+ postsOffset: 0,
+ firstPosts: 25,
+ },
+ })
+ expect(result.data.searchPosts.posts).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ searchPosts: {
+ postCount: 2,
+ posts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ },
+ })
+ })
+ })
+
+ describe('without membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await anyUser.toJson()
+ })
+
+ it('finds the post of the public group and the post without group', async () => {
+ const result = await query({
+ query: searchPosts(),
+ variables: {
+ query: 'post',
+ postsOffset: 0,
+ firstPosts: 25,
+ },
+ })
+ expect(result.data.searchPosts.posts).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ searchPosts: {
+ postCount: 2,
+ posts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ },
+ })
+ })
+ })
+
+ describe('with pending membership of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('finds the post of the public group and the post without group', async () => {
+ const result = await query({
+ query: searchPosts(),
+ variables: {
+ query: 'post',
+ postsOffset: 0,
+ firstPosts: 25,
+ },
+ })
+ expect(result.data.searchPosts.posts).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ searchPosts: {
+ postCount: 2,
+ posts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ },
+ })
+ })
+ })
+
+ describe('as member of group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await allGroupsUser.toJson()
+ })
+
+ it('finds all posts', async () => {
+ const result = await query({
+ query: searchPosts(),
+ variables: {
+ query: 'post',
+ postsOffset: 0,
+ firstPosts: 25,
+ },
+ })
+ expect(result.data.searchPosts.posts).toHaveLength(4)
+ expect(result).toMatchObject({
+ data: {
+ searchPosts: {
+ postCount: 4,
+ posts: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ },
+ })
+ })
+ })
+ })
+ })
+
+ describe('changes of group membership', () => {
+ describe('pending member becomes usual member', () => {
+ describe('of closed group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await closedUser.toJson()
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'pending-user',
+ roleInGroup: 'usual',
+ },
+ })
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('shows the posts of the closed group', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(3)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('of hidden group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await hiddenUser.toJson()
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'pending-user',
+ roleInGroup: 'usual',
+ },
+ })
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('shows all the posts', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(4)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('usual member becomes pending', () => {
+ describe('of closed group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await closedUser.toJson()
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'pending-user',
+ roleInGroup: 'pending',
+ },
+ })
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('does not show the posts of the closed group anymore', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(3)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('of hidden group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await hiddenUser.toJson()
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'pending-user',
+ roleInGroup: 'pending',
+ },
+ })
+ authenticatedUser = await pendingUser.toJson()
+ })
+
+ it('shows only the public posts', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('usual member leaves', () => {
+ describe('public group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await allGroupsUser.toJson()
+ await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ groupId: 'public-group',
+ userId: 'all-groups-user',
+ },
+ })
+ })
+
+ it('still shows the posts of the public group', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(4)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('closed group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await allGroupsUser.toJson()
+ await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'all-groups-user',
+ },
+ })
+ })
+
+ it('does not show the posts of the closed group anymore', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(3)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('hidden group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await allGroupsUser.toJson()
+ await mutate({
+ mutation: leaveGroupMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'all-groups-user',
+ },
+ })
+ })
+
+ it('does only show the public posts', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(2)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('any user joins', () => {
+ describe('closed group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await closedUser.toJson()
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'closed-group',
+ userId: 'all-groups-user',
+ roleInGroup: 'usual',
+ },
+ })
+ authenticatedUser = await allGroupsUser.toJson()
+ })
+
+ it('does not show the posts of the closed group', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(3)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+
+ describe('hidden group', () => {
+ beforeAll(async () => {
+ authenticatedUser = await hiddenUser.toJson()
+ await mutate({
+ mutation: changeGroupMemberRoleMutation(),
+ variables: {
+ groupId: 'hidden-group',
+ userId: 'all-groups-user',
+ roleInGroup: 'usual',
+ },
+ })
+ authenticatedUser = await allGroupsUser.toJson()
+ })
+
+ it('shows all posts', async () => {
+ const result = await query({ query: filterPosts(), variables: {} })
+ expect(result.data.Post).toHaveLength(4)
+ expect(result).toMatchObject({
+ data: {
+ Post: expect.arrayContaining([
+ {
+ id: 'post-to-public-group',
+ title: 'A post to a public group',
+ content: 'I am posting into a public group as a member of the group',
+ },
+ {
+ id: 'post-without-group',
+ title: 'A post without a group',
+ content: 'I am a user who does not belong to a group yet.',
+ },
+ {
+ id: 'post-to-closed-group',
+ title: 'A post to a closed group',
+ content: 'I am posting into a closed group as a member of the group',
+ },
+ {
+ id: 'post-to-hidden-group',
+ title: 'A post to a hidden group',
+ content: 'I am posting into a hidden group as a member of the group',
+ },
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+ })
+})
diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js
index ea420bc2ae..52c92b0335 100644
--- a/backend/src/schema/resolvers/registration.js
+++ b/backend/src/schema/resolvers/registration.js
@@ -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
diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js
index 60fd4318fc..3fdf22da3c 100644
--- a/backend/src/schema/resolvers/searches.js
+++ b/backend/src/schema/resolvers/searches.js
@@ -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)),
]
diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js
index e7f2f3ed1b..50e6e84bf9 100644
--- a/backend/src/schema/resolvers/user_management.spec.js
+++ b/backend/src/schema/resolvers/user_management.spec.js
@@ -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 '../../db/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)
}
diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js
index 12f00ffb66..1ce3b986f6 100644
--- a/backend/src/schema/resolvers/users.js
+++ b/backend/src/schema/resolvers/users.js
@@ -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)
diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js
index d8fce3b296..87226ec4d5 100644
--- a/backend/src/schema/resolvers/users.spec.js
+++ b/backend/src/schema/resolvers/users.spec.js
@@ -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,
+ })
+ })
})
})
})
diff --git a/backend/src/schema/resolvers/users/location.js b/backend/src/schema/resolvers/users/location.js
index affd3267e4..9d8a11f899 100644
--- a/backend/src/schema/resolvers/users/location.js
+++ b/backend/src/schema/resolvers/users/location.js
@@ -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
diff --git a/backend/src/schema/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql
new file mode 100644
index 0000000000..221ed7f877
--- /dev/null
+++ b/backend/src/schema/types/enum/GroupActionRadius.gql
@@ -0,0 +1,7 @@
+enum GroupActionRadius {
+ regional
+ national
+ continental
+ global
+ interplanetary
+}
diff --git a/backend/src/schema/types/enum/GroupMemberRole.gql b/backend/src/schema/types/enum/GroupMemberRole.gql
new file mode 100644
index 0000000000..dacdd4b526
--- /dev/null
+++ b/backend/src/schema/types/enum/GroupMemberRole.gql
@@ -0,0 +1,6 @@
+enum GroupMemberRole {
+ pending
+ usual
+ admin
+ owner
+}
diff --git a/backend/src/schema/types/enum/GroupType.gql b/backend/src/schema/types/enum/GroupType.gql
new file mode 100644
index 0000000000..2cf2984748
--- /dev/null
+++ b/backend/src/schema/types/enum/GroupType.gql
@@ -0,0 +1,5 @@
+enum GroupType {
+ public
+ closed
+ hidden
+}
diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql
new file mode 100644
index 0000000000..c4890fdce9
--- /dev/null
+++ b/backend/src/schema/types/type/Group.gql
@@ -0,0 +1,133 @@
+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 # not implemented yet
+ # offset: Int # not implemented yet
+ # 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]
+
+ # 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
+}
diff --git a/backend/src/schema/types/type/MEMBER_OF.gql b/backend/src/schema/types/type/MEMBER_OF.gql
new file mode 100644
index 0000000000..edda989f61
--- /dev/null
+++ b/backend/src/schema/types/type/MEMBER_OF.gql
@@ -0,0 +1,5 @@
+type MEMBER_OF {
+ createdAt: String!
+ updatedAt: String!
+ role: GroupMemberRole!
+}
diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql
index 2f9221dd12..9eac00b0ba 100644
--- a/backend/src/schema/types/type/Post.gql
+++ b/backend/src/schema/types/type/Post.gql
@@ -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
- """
- )
}
diff --git a/backend/src/schema/types/type/Search.gql b/backend/src/schema/types/type/Search.gql
index 9537b5a849..5cb68e22d8 100644
--- a/backend/src/schema/types/type/Search.gql
+++ b/backend/src/schema/types/type/Search.gql
@@ -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]!
}
diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql
index c3fcf932bf..fe1ff43f0c 100644
--- a/backend/src/schema/types/type/User.gql
+++ b/backend/src/schema/types/type/User.gql
@@ -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
diff --git a/backend/yarn.lock b/backend/yarn.lock
index 48c05947e6..8283660d55 100644
--- a/backend/yarn.lock
+++ b/backend/yarn.lock
@@ -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==
diff --git a/cypress/integration/Moderation.ReportContent/I_click_on_the_author.js b/cypress/integration/Moderation.ReportContent/I_click_on_the_author.js
index 3a6600ff68..fad21e1a61 100644
--- a/cypress/integration/Moderation.ReportContent/I_click_on_the_author.js
+++ b/cypress/integration/Moderation.ReportContent/I_click_on_the_author.js
@@ -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/')
})
\ No newline at end of file
diff --git a/cypress/integration/Post.Comment/I_should_see_my_comment.js b/cypress/integration/Post.Comment/I_should_see_my_comment.js
index 356593f9cf..8d439b112f 100644
--- a/cypress/integration/Post.Comment/I_should_see_my_comment.js
+++ b/cypress/integration/Post.Comment/I_should_see_my_comment.js
@@ -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")
diff --git a/cypress/integration/UserProfile.Avatar/I_cannot_upload_a_picture.js b/cypress/integration/UserProfile.Avatar/I_cannot_upload_a_picture.js
index d20a181f2b..8b501f3f5c 100644
--- a/cypress/integration/UserProfile.Avatar/I_cannot_upload_a_picture.js
+++ b/cypress/integration/UserProfile.Avatar/I_cannot_upload_a_picture.js
@@ -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");
});
\ No newline at end of file
diff --git a/cypress/integration/UserProfile.Avatar/I_should_be_able_to_change_my_profile_picture.js b/cypress/integration/UserProfile.Avatar/I_should_be_able_to_change_my_profile_picture.js
index f92789ef82..366fd82928 100644
--- a/cypress/integration/UserProfile.Avatar/I_should_be_able_to_change_my_profile_picture.js
+++ b/cypress/integration/UserProfile.Avatar/I_should_be_able_to_change_my_profile_picture.js
@@ -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")
diff --git a/package.json b/package.json
index 54560aebd6..c45380bf34 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ocelot-social",
- "version": "1.1.1",
+ "version": "2.0.0",
"description": "Free and open source software program code available to run social networks.",
"author": "ocelot.social Community",
"license": "MIT",
diff --git a/webapp/.env.template b/webapp/.env.template
index 0a4c3405fe..9776fcea29 100644
--- a/webapp/.env.template
+++ b/webapp/.env.template
@@ -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
\ No newline at end of file
+CATEGORIES_ACTIVE=false
diff --git a/webapp/assets/_new/styles/tokens.scss b/webapp/assets/_new/styles/tokens.scss
index 6aa6410df7..180f9c8205 100644
--- a/webapp/assets/_new/styles/tokens.scss
+++ b/webapp/assets/_new/styles/tokens.scss
@@ -268,6 +268,7 @@ $size-avatar-large: 114px;
* @presenter Spacing
*/
+ $size-button-large: 50px;
$size-button-base: 36px;
$size-button-small: 26px;
diff --git a/webapp/components/AvatarMenu/AvatarMenu.spec.js b/webapp/components/AvatarMenu/AvatarMenu.spec.js
index 85f5c32a8c..15f536ee7f 100644
--- a/webapp/components/AvatarMenu/AvatarMenu.spec.js
+++ b/webapp/components/AvatarMenu/AvatarMenu.spec.js
@@ -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 "My groups"', () => {
+ const profileLink = wrapper
+ .findAll('.ds-menu-item span')
+ .at(wrapper.vm.routes.findIndex((route) => route.path === '/my-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)
})
})
})
diff --git a/webapp/components/AvatarMenu/AvatarMenu.vue b/webapp/components/AvatarMenu/AvatarMenu.vue
index d47eb2d680..5caec07f26 100644
--- a/webapp/components/AvatarMenu/AvatarMenu.vue
+++ b/webapp/components/AvatarMenu/AvatarMenu.vue
@@ -11,7 +11,7 @@
"
@click.prevent="toggleMenu"
>
-
+
@@ -50,12 +50,12 @@
+
+
diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.vue b/webapp/components/CategoriesSelect/CategoriesSelect.vue
index 92779444f5..7fc9bcf1ca 100644
--- a/webapp/components/CategoriesSelect/CategoriesSelect.vue
+++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue
@@ -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 @@
+
+
diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js
index f5083a8a0f..1ef1777fee 100644
--- a/webapp/components/ContributionForm/ContributionForm.spec.js
+++ b/webapp/components/ContributionForm/ContributionForm.spec.js
@@ -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,
},
diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue
index 3d4bb8e7c0..0428b2e23b 100644
--- a/webapp/components/ContributionForm/ContributionForm.vue
+++ b/webapp/components/ContributionForm/ContributionForm.vue
@@ -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 }) => {
diff --git a/webapp/components/FilterMenu/CategoriesFilter.vue b/webapp/components/FilterMenu/CategoriesFilter.vue
index 552aa26a05..fcbb682662 100644
--- a/webapp/components/FilterMenu/CategoriesFilter.vue
+++ b/webapp/components/FilterMenu/CategoriesFilter.vue
@@ -20,7 +20,6 @@
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
- delay: { show: 1500 },
}"
/>
diff --git a/webapp/components/Group/GroupButton.vue b/webapp/components/Group/GroupButton.vue
new file mode 100644
index 0000000000..2000e3046d
--- /dev/null
+++ b/webapp/components/Group/GroupButton.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/webapp/components/Group/GroupForm.spec.js b/webapp/components/Group/GroupForm.spec.js
new file mode 100644
index 0000000000..0300bacd02
--- /dev/null
+++ b/webapp/components/Group/GroupForm.spec.js
@@ -0,0 +1,39 @@
+import { config, mount } from '@vue/test-utils'
+import GroupForm from './GroupForm.vue'
+
+const localVue = global.localVue
+
+config.stubs['nuxt-link'] = ' '
+
+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)
+ })
+ })
+})
diff --git a/webapp/components/Group/GroupForm.vue b/webapp/components/Group/GroupForm.vue
new file mode 100644
index 0000000000..e0166bb452
--- /dev/null
+++ b/webapp/components/Group/GroupForm.vue
@@ -0,0 +1,440 @@
+
+
+
+
+
+
+
+
+ {{ `${formData.name.length} / ${formSchema.name.min}–${formSchema.name.max}` }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('group.type') }}
+
+
+
+
+
+ {{ $t(`group.types.${groupType}`) }}
+
+
+
+ {{ `${formData.groupType === '' ? 0 : 1} / 1` }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('group.description') }}
+
+
+
+ {{ `${descriptionLength} / ${formSchema.description.min}` }}
+
+
+
+
+
+ {{ $t('group.actionRadius') }}
+
+
+
+
+ {{ $t(`group.actionRadii.${actionRadius}`) }}
+
+
+
+ {{ `${formData.actionRadius === '' ? 0 : 1} / 1` }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formData.categoryIds.length }} / 3
+
+
+
+
+
+
+ {{ $t('actions.cancel') }}
+
+
+ {{ update ? $t('group.update') : $t('group.save') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/components/Group/GroupLink.vue b/webapp/components/Group/GroupLink.vue
new file mode 100644
index 0000000000..4fcc7983d8
--- /dev/null
+++ b/webapp/components/Group/GroupLink.vue
@@ -0,0 +1,15 @@
+
+
+
+ Link zur Gruppe
+
+ Copy Link for Invite Member please!
+
+
+
+
+
diff --git a/webapp/components/Group/GroupList.spec.js b/webapp/components/Group/GroupList.spec.js
new file mode 100644
index 0000000000..ca6909de79
--- /dev/null
+++ b/webapp/components/Group/GroupList.spec.js
@@ -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)
+ })
+ })
+})
diff --git a/webapp/components/Group/GroupList.vue b/webapp/components/Group/GroupList.vue
new file mode 100644
index 0000000000..7618e5b579
--- /dev/null
+++ b/webapp/components/Group/GroupList.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/webapp/components/Group/GroupMember.spec.js b/webapp/components/Group/GroupMember.spec.js
new file mode 100644
index 0000000000..0c0b46e43b
--- /dev/null
+++ b/webapp/components/Group/GroupMember.spec.js
@@ -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)
+ })
+ })
+})
diff --git a/webapp/components/Group/GroupMember.vue b/webapp/components/Group/GroupMember.vue
new file mode 100644
index 0000000000..0a99aa21cd
--- /dev/null
+++ b/webapp/components/Group/GroupMember.vue
@@ -0,0 +1,234 @@
+
+
+
+ {{ $t('group.addUser') }}
+
+
+
+
+
+
+
+
+
+
+
+ Kein User mit diesem Slug gefunden!
+
+
+
+
+
+
+ {{ slugUser[0].name }}
+ {{ slugUser[0].slug }}
+
+
+ {{ $t('group.addMemberToGroup') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ scope.row.name | truncate(20) }}
+
+
+
+
+
+
+ {{ `@${scope.row.slug}` | truncate(20) }}
+
+
+
+
+
+
+ {{ $t(`group.roles.${role}`) }}
+
+
+
+ {{ $t(`group.roles.${scope.row.myRoleInGroup}`) }}
+
+
+
+
+
+
+ {{ $t('group.removeMemberButton') }}
+
+
+
+
+
+
+
+
+
diff --git a/webapp/components/Group/GroupTeaser.vue b/webapp/components/Group/GroupTeaser.vue
new file mode 100644
index 0000000000..75a1150d6b
--- /dev/null
+++ b/webapp/components/Group/GroupTeaser.vue
@@ -0,0 +1,178 @@
+
+
+
+ {{ group.name }}
+
+
+
+
+
+ {{ group.slug }}
+
+
+
+
+
+
+ {{ group && group.location ? group.location.name : '' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue
index 75eefbfb27..2bd63e6ed1 100644
--- a/webapp/components/PostTeaser/PostTeaser.vue
+++ b/webapp/components/PostTeaser/PostTeaser.vue
@@ -15,13 +15,12 @@
-
+
{{ post.title }}
-
+
-
diff --git a/webapp/components/Upload/spec.js b/webapp/components/Uploader/AvatarUploader.spec.js
similarity index 88%
rename from webapp/components/Upload/spec.js
rename to webapp/components/Uploader/AvatarUploader.spec.js
index baf1a3b59b..070302038d 100644
--- a/webapp/components/Upload/spec.js
+++ b/webapp/components/Uploader/AvatarUploader.spec.js
@@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import Vue from 'vue'
-import Upload from '.'
+import AvatarUploader from './AvatarUploader'
const localVue = global.localVue
-describe('Upload', () => {
+describe('AvatarUploader', () => {
let wrapper
const mocks = {
@@ -26,21 +26,22 @@ describe('Upload', () => {
}
const propsData = {
- user: {
+ profile: {
avatar: { url: '/api/generic.jpg' },
},
+ updateMutation: jest.fn(),
}
beforeEach(() => {
jest.useFakeTimers()
- wrapper = shallowMount(Upload, { localVue, propsData, mocks })
+ wrapper = shallowMount(AvatarUploader, { localVue, propsData, mocks })
})
afterEach(() => {
jest.clearAllMocks()
})
- it('sends a the UpdateUser mutation when vddrop is called', () => {
+ it('sends the UpdateUser mutation when vddrop is called', () => {
wrapper.vm.vddrop([{ filename: 'avatar.jpg' }])
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
diff --git a/webapp/components/Upload/index.vue b/webapp/components/Uploader/AvatarUploader.vue
similarity index 83%
rename from webapp/components/Upload/index.vue
rename to webapp/components/Uploader/AvatarUploader.vue
index f2ef4e712a..eacb44d3a8 100644
--- a/webapp/components/Upload/index.vue
+++ b/webapp/components/Uploader/AvatarUploader.vue
@@ -1,5 +1,5 @@
-
+
-
-
+
@@ -21,14 +21,15 @@
@@ -365,6 +429,10 @@ export default {
opacity: 0.8;
}
}
+
+ .group-teaser-card-wrapper {
+ padding: 0;
+ }
}
.grid-total-search-results {
diff --git a/webapp/components/_new/generic/BaseButton/BaseButton.vue b/webapp/components/_new/generic/BaseButton/BaseButton.vue
index d87598d76d..6b8ad4f23f 100644
--- a/webapp/components/_new/generic/BaseButton/BaseButton.vue
+++ b/webapp/components/_new/generic/BaseButton/BaseButton.vue
@@ -1,7 +1,7 @@
$emit('click', event)"
>
@@ -46,7 +46,7 @@ export default {
type: String,
default: 'regular',
validator(value) {
- return value.match(/(small|regular)/)
+ return value.match(/(small|regular|large)/)
},
},
type: {
@@ -56,6 +56,10 @@ export default {
return value.match(/(button|submit)/)
},
},
+ disabled: {
+ // type: Boolean, // makes some errors that an Object was passed instead a Boolean and could not find how to solve in a acceptable time
+ default: false,
+ },
},
computed: {
buttonClass() {
@@ -66,6 +70,7 @@ export default {
if (this.danger) buttonClass += ' --danger'
if (this.loading) buttonClass += ' --loading'
if (this.size === 'small') buttonClass += ' --small'
+ if (this.size === 'large') buttonClass += ' --large'
if (this.filled) buttonClass += ' --filled'
else if (this.ghost) buttonClass += ' --ghost'
@@ -123,6 +128,15 @@ export default {
}
}
+ &.--large {
+ height: $size-button-large;
+ font-size: $font-size-large;
+
+ &.--circle {
+ width: $size-button-large;
+ }
+ }
+
&:not(.--icon-only) > .base-icon {
margin-right: $space-xx-small;
}
diff --git a/webapp/components/_new/generic/UserAvatar/UserAvatar.spec.js b/webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.spec.js
similarity index 79%
rename from webapp/components/_new/generic/UserAvatar/UserAvatar.spec.js
rename to webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.spec.js
index 6d12c5ce2d..44d35bd716 100644
--- a/webapp/components/_new/generic/UserAvatar/UserAvatar.spec.js
+++ b/webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.spec.js
@@ -1,10 +1,10 @@
import { mount } from '@vue/test-utils'
-import UserAvatar from './UserAvatar.vue'
+import ProfileAvatar from './ProfileAvatar'
import BaseIcon from '~/components/_new/generic/BaseIcon/BaseIcon'
const localVue = global.localVue
-describe('UserAvatar.vue', () => {
+describe('ProfileAvatar', () => {
let propsData, wrapper
beforeEach(() => {
propsData = {}
@@ -12,7 +12,7 @@ describe('UserAvatar.vue', () => {
})
const Wrapper = () => {
- return mount(UserAvatar, { propsData, localVue })
+ return mount(ProfileAvatar, { propsData, localVue })
}
it('renders no image', () => {
@@ -23,39 +23,39 @@ describe('UserAvatar.vue', () => {
expect(wrapper.find(BaseIcon).exists()).toBe(true)
})
- describe('given a user', () => {
+ describe('given a profile', () => {
describe('with no image', () => {
beforeEach(() => {
propsData = {
- user: {
+ profile: {
name: 'Matt Rider',
},
}
wrapper = Wrapper()
})
- describe('no user name', () => {
+ describe('no profile name', () => {
it('renders an icon', () => {
- propsData = { user: { name: null } }
+ propsData = { profile: { name: null } }
wrapper = Wrapper()
expect(wrapper.find(BaseIcon).exists()).toBe(true)
})
})
- describe("user name is 'Anonymous'", () => {
+ describe("profile name is 'Anonymous'", () => {
it('renders an icon', () => {
- propsData = { user: { name: 'Anonymous' } }
+ propsData = { profile: { name: 'Anonymous' } }
wrapper = Wrapper()
expect(wrapper.find(BaseIcon).exists()).toBe(true)
})
})
- it('displays user initials', () => {
+ it('displays profile initials', () => {
expect(wrapper.find('.initials').text()).toEqual('MR')
})
it('displays no more than 3 initials', () => {
- propsData = { user: { name: 'Ana Paula Nunes Marques' } }
+ propsData = { profile: { name: 'Ana Paula Nunes Marques' } }
wrapper = Wrapper()
expect(wrapper.find('.initials').text()).toEqual('APN')
})
@@ -64,7 +64,7 @@ describe('UserAvatar.vue', () => {
describe('with a relative avatar url', () => {
beforeEach(() => {
propsData = {
- user: {
+ profile: {
name: 'Not Anonymous',
avatar: {
url: '/avatar.jpg',
@@ -82,7 +82,7 @@ describe('UserAvatar.vue', () => {
describe('with an absolute avatar url', () => {
beforeEach(() => {
propsData = {
- user: {
+ profile: {
name: 'Not Anonymous',
avatar: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
diff --git a/webapp/components/_new/generic/UserAvatar/UserAvatar.story.js b/webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.story.js
similarity index 64%
rename from webapp/components/_new/generic/UserAvatar/UserAvatar.story.js
rename to webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.story.js
index d4830f09ad..2b4bccd5ec 100644
--- a/webapp/components/_new/generic/UserAvatar/UserAvatar.story.js
+++ b/webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.story.js
@@ -1,7 +1,7 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import StoryRouter from 'storybook-vue-router'
-import UserAvatar from '~/components/_new/generic/UserAvatar/UserAvatar'
+import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
import helpers from '~/storybook/helpers'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'
import imageFile from './storybook/critical-avatar-white-background.png'
@@ -22,56 +22,56 @@ const userWithAvatar = {
name: 'Jochen Image',
avatar: { url: imageFile },
}
-storiesOf('UserAvatar', module)
+storiesOf('ProfileAvatar', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.addDecorator(StoryRouter())
.add('normal, with image', () => ({
- components: { UserAvatar },
+ components: { ProfileAvatar },
data: () => ({
user: userWithAvatar,
}),
- template: ' ',
+ template: ' ',
}))
.add('normal without image, anonymous user', () => ({
- components: { UserAvatar },
+ components: { ProfileAvatar },
data: () => ({
user: anonymousUser,
}),
- template: ' ',
+ template: ' ',
}))
.add('normal without image, user initials', () => ({
- components: { UserAvatar },
+ components: { ProfileAvatar },
data: () => ({
user: userWithoutAvatar,
}),
- template: ' ',
+ template: ' ',
}))
.add('small, with image', () => ({
- components: { UserAvatar },
+ components: { ProfileAvatar },
data: () => ({
user: userWithAvatar,
}),
- template: ' ',
+ template: ' ',
}))
.add('small', () => ({
- components: { UserAvatar },
+ components: { ProfileAvatar },
data: () => ({
user: userWithoutAvatar,
}),
- template: ' ',
+ template: ' ',
}))
.add('large, with image', () => ({
- components: { UserAvatar },
+ components: { ProfileAvatar },
data: () => ({
user: userWithAvatar,
}),
- template: ' ',
+ template: ' ',
}))
.add('large', () => ({
- components: { UserAvatar },
+ components: { ProfileAvatar },
data: () => ({
user: userWithoutAvatar,
}),
- template: ' ',
+ template: ' ',
}))
diff --git a/webapp/components/_new/generic/UserAvatar/UserAvatar.vue b/webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.vue
similarity index 74%
rename from webapp/components/_new/generic/UserAvatar/UserAvatar.vue
rename to webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.vue
index 08ace07c03..46e3968fc0 100644
--- a/webapp/components/_new/generic/UserAvatar/UserAvatar.vue
+++ b/webapp/components/_new/generic/ProfileAvatar/ProfileAvatar.vue
@@ -1,14 +1,14 @@
-
+
-
{{ userInitials }}
+
{{ profileInitials }}
@@ -16,7 +16,7 @@
diff --git a/webapp/components/generic/SearchableInput/SearchableInput.vue b/webapp/components/generic/SearchableInput/SearchableInput.vue
index afafa716c1..2149732c5f 100644
--- a/webapp/components/generic/SearchableInput/SearchableInput.vue
+++ b/webapp/components/generic/SearchableInput/SearchableInput.vue
@@ -35,6 +35,12 @@
>
+
+
+
{
if (!this.isTag(item)) {
this.$router.push({
- name: this.isPost(item) ? 'post-id-slug' : 'profile-id-slug',
+ name: this.getRouteName(item),
params: { id: item.id, slug: item.slug },
})
} else {
diff --git a/webapp/constants/categories.js b/webapp/constants/categories.js
new file mode 100644
index 0000000000..64ceb9021c
--- /dev/null
+++ b/webapp/constants/categories.js
@@ -0,0 +1,3 @@
+// 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
diff --git a/webapp/constants/groups.js b/webapp/constants/groups.js
new file mode 100644
index 0000000000..1c49d3ff35
--- /dev/null
+++ b/webapp/constants/groups.js
@@ -0,0 +1,5 @@
+// this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js`
+export const NAME_LENGTH_MIN = 3
+export const NAME_LENGTH_MAX = 50
+export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags
+export const SHOW_GROUP_BUTTON_IN_HEADER = true
diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js
index b67851873f..23d2c11d33 100644
--- a/webapp/graphql/Fragments.js
+++ b/webapp/graphql/Fragments.js
@@ -12,6 +12,7 @@ export const userFragment = gql`
deleted
}
`
+
export const locationAndBadgesFragment = (lang) => gql`
fragment locationAndBadges on User {
location {
@@ -61,6 +62,29 @@ export const postFragment = gql`
}
`
+export const groupFragment = gql`
+ fragment group on Group {
+ id
+ groupName: name
+ slug
+ disabled
+ deleted
+ about
+ description
+ descriptionExcerpt
+ groupType
+ actionRadius
+ categories {
+ id
+ slug
+ name
+ icon
+ }
+ locationName
+ myRole
+ }
+`
+
export const postCountsFragment = gql`
fragment postCounts on Post {
commentsCount
diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js
index ee61efc3b8..8880a93b0b 100644
--- a/webapp/graphql/PostMutations.js
+++ b/webapp/graphql/PostMutations.js
@@ -3,8 +3,20 @@ import gql from 'graphql-tag'
export default () => {
return {
CreatePost: gql`
- mutation ($title: String!, $content: String!, $categoryIds: [ID], $image: ImageInput) {
- CreatePost(title: $title, content: $content, categoryIds: $categoryIds, image: $image) {
+ mutation (
+ $title: String!
+ $content: String!
+ $categoryIds: [ID]
+ $image: ImageInput
+ $groupId: ID
+ ) {
+ CreatePost(
+ title: $title
+ content: $content
+ categoryIds: $categoryIds
+ image: $image
+ groupId: $groupId
+ ) {
title
slug
content
diff --git a/webapp/graphql/PostQuery.js b/webapp/graphql/PostQuery.js
index 38c90e4389..1b5c419844 100644
--- a/webapp/graphql/PostQuery.js
+++ b/webapp/graphql/PostQuery.js
@@ -39,6 +39,11 @@ export default (i18n) => {
...locationAndBadges
}
}
+ group {
+ id
+ name
+ slug
+ }
}
}
`
@@ -64,6 +69,11 @@ export const filterPosts = (i18n) => {
...userCounts
...locationAndBadges
}
+ group {
+ id
+ name
+ slug
+ }
}
}
`
@@ -94,6 +104,11 @@ export const profilePagePosts = (i18n) => {
...userCounts
...locationAndBadges
}
+ group {
+ id
+ name
+ slug
+ }
}
}
`
diff --git a/webapp/graphql/Search.js b/webapp/graphql/Search.js
index 56f5d7c4c9..b8c4fcb518 100644
--- a/webapp/graphql/Search.js
+++ b/webapp/graphql/Search.js
@@ -1,9 +1,15 @@
import gql from 'graphql-tag'
-import { userFragment, postFragment, tagsCategoriesAndPinnedFragment } from './Fragments'
+import {
+ userFragment,
+ postFragment,
+ groupFragment,
+ tagsCategoriesAndPinnedFragment,
+} from './Fragments'
export const searchQuery = gql`
${userFragment}
${postFragment}
+ ${groupFragment}
query ($query: String!) {
searchResults(query: $query, limit: 5) {
@@ -24,6 +30,9 @@ export const searchQuery = gql`
... on Tag {
id
}
+ ... on Group {
+ ...group
+ }
}
}
`
@@ -52,6 +61,46 @@ export const searchPosts = gql`
}
`
+export const searchGroups = (i18n) => {
+ const lang = i18n ? i18n.locale().toUpperCase() : 'EN'
+ return gql`
+ query ($query: String!, $firstGroups: Int, $groupsOffset: Int) {
+ searchGroups(query: $query, firstGroups: $firstGroups, groupsOffset: $groupsOffset) {
+ groupCount
+ groups {
+ __typename
+ id
+ groupName: name
+ slug
+ createdAt
+ updatedAt
+ disabled
+ deleted
+ about
+ description
+ descriptionExcerpt
+ groupType
+ actionRadius
+ categories {
+ id
+ slug
+ name
+ icon
+ }
+ avatar {
+ url
+ }
+ locationName
+ location {
+ name: name${lang}
+ }
+ myRole
+ }
+ }
+ }
+ `
+}
+
export const searchUsers = gql`
${userFragment}
diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js
index 053cb022fc..0d3a24d8b0 100644
--- a/webapp/graphql/User.js
+++ b/webapp/graphql/User.js
@@ -49,8 +49,8 @@ export default (i18n) => {
export const minimisedUserQuery = () => {
return gql`
- query {
- User(orderBy: slug_asc) {
+ query ($slug: String) {
+ User(slug: $slug, orderBy: slug_asc) {
id
slug
name
@@ -221,25 +221,25 @@ export const updateUserMutation = () => {
$id: ID!
$slug: String
$name: String
- $locationName: String
$about: String
$allowEmbedIframes: Boolean
$showShoutsPublicly: Boolean
$sendNotificationEmails: Boolean
$termsAndConditionsAgreedVersion: String
$avatar: ImageInput
+ $locationName: String # empty string '' sets it to null
) {
UpdateUser(
id: $id
slug: $slug
name: $name
- locationName: $locationName
about: $about
allowEmbedIframes: $allowEmbedIframes
showShoutsPublicly: $showShoutsPublicly
sendNotificationEmails: $sendNotificationEmails
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatar: $avatar
+ locationName: $locationName
) {
id
slug
diff --git a/webapp/graphql/groups.js b/webapp/graphql/groups.js
new file mode 100644
index 0000000000..5ee5869ceb
--- /dev/null
+++ b/webapp/graphql/groups.js
@@ -0,0 +1,192 @@
+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
+ 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
+ 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 = (i18n) => {
+ const lang = i18n ? i18n.locale().toUpperCase() : 'EN'
+ 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: name${lang}
+ }
+ myRole
+ }
+ }
+ `
+}
+
+export const groupMembersQuery = () => {
+ return gql`
+ query ($id: ID!) {
+ GroupMembers(id: $id) {
+ id
+ name
+ slug
+ myRoleInGroup
+ }
+ }
+ `
+}
diff --git a/webapp/layouts/default.vue b/webapp/layouts/default.vue
index e7095d8b65..e173602235 100644
--- a/webapp/layouts/default.vue
+++ b/webapp/layouts/default.vue
@@ -95,6 +95,10 @@
+
+
+
+
@@ -160,6 +164,16 @@
+
+
+
+
+
+
import { mapGetters } from 'vuex'
+import { SHOW_GROUP_BUTTON_IN_HEADER } from '~/constants/groups.js'
+import links from '~/constants/links.js'
import LOGOS from '../constants/logos.js'
import headerMenu from '../constants/headerMenu.js'
import seo from '~/mixins/seo'
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'
@@ -230,13 +247,13 @@ import SearchField from '~/components/features/SearchField/SearchField.vue'
import Modal from '~/components/Modal'
import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
import PageFooter from '~/components/PageFooter/PageFooter'
-import links from '~/constants/links.js'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
export default {
components: {
AvatarMenu,
FilterMenu,
+ GroupButton,
InviteButton,
LocaleSwitch,
Logo,
@@ -249,15 +266,16 @@ export default {
mixins: [seo],
data() {
return {
- windowWidth: null,
+ 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,
links,
LOGOS,
+ SHOW_GROUP_BUTTON_IN_HEADER,
isHeaderMenu: headerMenu.MENU.length > 0,
menu: headerMenu.MENU,
mobileSearchVisible: false,
+ windowWidth: null,
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: {
diff --git a/webapp/locales/de.json b/webapp/locales/de.json
index 753b2e9d16..67c88e4f4f 100644
--- a/webapp/locales/de.json
+++ b/webapp/locales/de.json
@@ -357,15 +357,16 @@
"placeholder": "Schreib etwas Inspirierendes …"
},
"error-pages": {
- "403-default": "Kein Zugang zu dieser Seite",
- "404-default": "Diese Seite konnte nicht gefunden werden",
- "500-default": "Internal Server Error",
- "503-default": "Dienst steht nicht zur Verfügung",
- "back-to-index": "Zurück zur Startseite",
- "cannot-edit-post": "Dieser Beitrag kann nicht editiert werden",
- "default": "Ein Fehler ist aufgetreten",
- "post-not-found": "Dieser Beitrag konnte nicht gefunden werden",
- "profile-not-found": "Dieses Profil konnte nicht gefunden werden"
+ "403-default": "Kein Zugang zu dieser Seite!",
+ "404-default": "Diese Seite konnte nicht gefunden werden!",
+ "500-default": "Internal Server Error!",
+ "503-default": "Dienst steht nicht zur Verfügung!",
+ "back-to-index": "Zurück zur Startseite!",
+ "cannot-edit-post": "Dieser Beitrag kann nicht editiert werden!",
+ "default": "Ein Fehler ist aufgetreten!",
+ "group-not-found": "Dieses Gruppenprofil konnte nicht gefunden werden!",
+ "post-not-found": "Dieser Beitrag konnte nicht gefunden werden!",
+ "profile-not-found": "Dieses Profil konnte nicht gefunden werden!"
},
"filter-menu": {
"all": "Alle",
@@ -394,11 +395,91 @@
"follow": "Folgen",
"following": "Folge Ich"
},
+ "group": {
+ "actionRadii": {
+ "continental": "Kontinentale Gruppe",
+ "global": "Globale Gruppe",
+ "interplanetary": "Interplanetare Gruppe",
+ "national": "Nationale Gruppe",
+ "regional": "Regionale Gruppe"
+ },
+ "actionRadius": "Aktionsradius",
+ "addMemberToGroup": "Zur Gruppe hinzufügen",
+ "addUser": "Benutzer hinzufügen",
+ "addUserPlaceholder": "eindeutiger Benutzername > @slug-from-user",
+ "categories": "Thema ::: Themen",
+ "changeMemberRole": "Die Rolle wurde auf „{role}“ geändert!",
+ "contentMenu": {
+ "visitGroupPage": "Gruppe anzeigen"
+ },
+ "createNewGroup": {
+ "title": "Erstelle eine neue Gruppe",
+ "tooltip": "Erstelle eine neue Gruppe"
+ },
+ "description": "Beschreibung",
+ "editGroupSettings": {
+ "groupName": "Einstellungen für „{name}“",
+ "title": "Meine Gruppe ändern"
+ },
+ "follow": "Folge",
+ "foundation": "Gründung",
+ "general": "Allgemein",
+ "goal": "Ziel der Gruppe",
+ "groupCreated": "Die Gruppe wurde angelegt!",
+ "in": "in",
+ "joinLeaveButton": {
+ "iAmMember": "Bin Mitglied",
+ "join": "Beitreten"
+ },
+ "labelSlug": "Eindeutiger Gruppenname",
+ "leaveModal": {
+ "confirmButton": "Verlassen",
+ "message": "Eine Gruppe zu verlassen ist möglicherweise nicht rückgängig zu machen! Gruppe „{name}“ verlassen!",
+ "title": "Möchtest du wirklich die Gruppe verlassen?"
+ },
+ "members": "Mitglieder",
+ "membersAdministrationList": {
+ "avatar": "Avatar",
+ "name": "Name",
+ "roleInGroup": "Rolle",
+ "slug": "Eindeutiger Name"
+ },
+ "membersCount": "Mitglied ::: Mitglieder",
+ "membersListTitle": "Gruppenmitglieder",
+ "membersListTitleNotAllowedSeeingGroupMembers": "Gruppenmitglieder unsichtbar",
+ "myGroups": "Meine Gruppen",
+ "name": "Gruppenname",
+ "radius": "Radius",
+ "removeMember": "Mitglied aus der Gruppe entfernen?",
+ "removeMemberButton": "Entfernen",
+ "role": "Deine Rolle in der Gruppe",
+ "roles": {
+ "admin": "Administrator",
+ "owner": "Inhaber",
+ "pending": "Ausstehendes Mitglied",
+ "usual": "Einfaches Mitglied"
+ },
+ "save": "Neue Gruppe anlegen",
+ "type": "Gruppentyp",
+ "types": {
+ "closed": "Geschlossene Gruppe",
+ "hidden": "Versteckte Gruppe",
+ "public": "Öffentliche Gruppe"
+ },
+ "update": "Änderung speichern",
+ "updatedGroup": "Die Gruppendaten wurden geändert!"
+ },
"hashtags-filter": {
"clearSearch": "Suche löschen",
"hashtag-search": "Suche nach #{hashtag}",
"title": "Deine Filterblase"
},
+ "header": {
+ "avatarMenu": {
+ "myGroups": "Mein Gruppen",
+ "myProfile": "Mein Profil"
+ }
+ },
"index": {
"change-filter-settings": "Verändere die Filter-Einstellungen, um mehr Ergebnisse zu erhalten.",
"no-results": "Keine Beiträge gefunden."
@@ -528,7 +609,19 @@
"submitted": "Kommentar gesendet",
"updated": "Änderungen gespeichert"
},
+ "createNewPost": {
+ "forGroup": {
+ "title": "Für die Gruppe „{name}“"
+ },
+ "title": "Erstelle einen neuen Beitrag"
+ },
"edited": "bearbeitet",
+ "editPost": {
+ "forGroup": {
+ "title": "Für die Gruppe „{name}“"
+ },
+ "title": "Bearbeite deinen Beitrag"
+ },
"menu": {
"delete": "Beitrag löschen",
"edit": "Beitrag bearbeiten",
@@ -541,9 +634,18 @@
"pinned": "Meldung",
"takeAction": {
"name": "Aktiv werden"
+ },
+ "viewPost": {
+ "forGroup": {
+ "title": "In der Gruppe „{name}“"
+ },
+ "title": "Beitrag"
}
},
"profile": {
+ "avatar": {
+ "submitted": "Erfolgreich hochgeladen!"
+ },
"commented": "Kommentiert",
"follow": "Folgen",
"followers": "Folgen",
@@ -554,7 +656,6 @@
"title": "Lade jemanden zu {APPLICATION_NAME} ein!"
},
"memberSince": "Mitglied seit",
- "name": "Mein Profil",
"network": {
"andMore": "und {number} weitere …",
"followedBy": "wird gefolgt von:",
@@ -644,11 +745,12 @@
"failed": "Nichts gefunden",
"for": "Suche nach ",
"heading": {
+ "Group": "Gruppe ::: Gruppen",
"Post": "Beitrag ::: Beiträge",
"Tag": "Hashtag ::: Hashtags",
"User": "Benutzer ::: Benutzer"
},
- "hint": "Wonach suchst Du? Nutze !… für Beiträge, @… für Mitglieder, #… für Hashtags",
+ "hint": "Wonach suchst Du? Nutze !… für Beiträge, @… für Mitglieder, &… für Gruppen, #… für Hashtags",
"no-results": "Keine Ergebnisse für \"{search}\" gefunden. Versuch' es mit einem anderen Begriff!",
"page": "Seite",
"placeholder": "Suchen",
@@ -845,10 +947,5 @@
"newTermsAndConditions": "Neue Nutzungsbedingungen",
"termsAndConditionsNewConfirm": "Ich habe die neuen Nutzungsbedingungen durchgelesen und stimme zu.",
"termsAndConditionsNewConfirmText": "Bitte lies Dir die neuen Nutzungsbedingungen jetzt durch!"
- },
- "user": {
- "avatar": {
- "submitted": "Erfolgreich hochgeladen!"
- }
}
}
diff --git a/webapp/locales/en.json b/webapp/locales/en.json
index f2a6c3fc20..ba1d658813 100644
--- a/webapp/locales/en.json
+++ b/webapp/locales/en.json
@@ -357,15 +357,16 @@
"placeholder": "Leave your inspirational thoughts …"
},
"error-pages": {
- "403-default": "Not authorized to this page",
- "404-default": "This page could not be found",
- "500-default": "Internal Server Error",
- "503-default": "Service Unavailable",
- "back-to-index": "Back to index page",
- "cannot-edit-post": "This post cannot be edited",
- "default": "An error occurred",
- "post-not-found": "This post could not be found",
- "profile-not-found": "This profile could not be found"
+ "403-default": "Not authorized to this page!",
+ "404-default": "This page could not be found!",
+ "500-default": "Internal Server Error!",
+ "503-default": "Service Unavailable!",
+ "back-to-index": "Back to index page!",
+ "cannot-edit-post": "This post cannot be edited!",
+ "default": "An error occurred!",
+ "group-not-found": "This group profile could not be found!",
+ "post-not-found": "This post could not be found!",
+ "profile-not-found": "This profile could not be found!"
},
"filter-menu": {
"all": "All",
@@ -394,11 +395,91 @@
"follow": "Follow",
"following": "Following"
},
+ "group": {
+ "actionRadii": {
+ "continental": "Continental Group",
+ "global": "Global Group",
+ "interplanetary": "Interplanetary Group",
+ "national": "National Group",
+ "regional": "Regional Group"
+ },
+ "actionRadius": "Action radius",
+ "addMemberToGroup": "Add to group",
+ "addUser": "Add User",
+ "addUserPlaceholder": "unique username > @slug-from-user",
+ "categories": "Topic ::: Topics",
+ "changeMemberRole": "The role has been changed to “{role}”!",
+ "contentMenu": {
+ "visitGroupPage": "Show group"
+ },
+ "createNewGroup": {
+ "title": "Create A New Group",
+ "tooltip": "Create a new group"
+ },
+ "description": "Description",
+ "editGroupSettings": {
+ "groupName": "Settings Of “{name}”",
+ "title": "Edit My Group"
+ },
+ "follow": "Follow",
+ "foundation": "Foundation",
+ "general": "General",
+ "goal": "Goal of group",
+ "groupCreated": "The group was created!",
+ "in": "in",
+ "joinLeaveButton": {
+ "iAmMember": "I'm a member",
+ "join": "Join"
+ },
+ "labelSlug": "Unique group name",
+ "leaveModal": {
+ "confirmButton": "Leave",
+ "message": "Leaving a group may be irreversible! Leave group “{name}” !",
+ "title": "Do you really want to leave the group?"
+ },
+ "members": "Members",
+ "membersAdministrationList": {
+ "avatar": "Avatar",
+ "name": "Name",
+ "roleInGroup": "Role",
+ "slug": "Unique name"
+ },
+ "membersCount": "Member ::: Members",
+ "membersListTitle": "Group Members",
+ "membersListTitleNotAllowedSeeingGroupMembers": "Group Members invisible",
+ "myGroups": "My Groups",
+ "name": "Group name",
+ "radius": "Radius",
+ "removeMember": "Remove member",
+ "removeMemberButton": "Remove",
+ "role": "Your role in the group",
+ "roles": {
+ "admin": "Administrator",
+ "owner": "Owner",
+ "pending": "Pending Member",
+ "usual": "Simple Member"
+ },
+ "save": "Create new group",
+ "type": "Group type",
+ "types": {
+ "closed": "Closed Group",
+ "hidden": "Hidden Group",
+ "public": "Public Group"
+ },
+ "update": "Save change",
+ "updatedGroup": "The group data has been changed."
+ },
"hashtags-filter": {
"clearSearch": "Clear search",
"hashtag-search": "Searching for #{hashtag}",
"title": "Your filter bubble"
},
+ "header": {
+ "avatarMenu": {
+ "myGroups": "My groups",
+ "myProfile": "My profile"
+ }
+ },
"index": {
"change-filter-settings": "Change your filter settings to get more results.",
"no-results": "No contributions found."
@@ -528,7 +609,19 @@
"submitted": "Comment submitted!",
"updated": "Changes saved!"
},
+ "createNewPost": {
+ "forGroup": {
+ "title": "For The Group “{name}”"
+ },
+ "title": "Create A New Post"
+ },
"edited": "edited",
+ "editPost": {
+ "forGroup": {
+ "title": "For The Group “{name}”"
+ },
+ "title": "Edit Your Post"
+ },
"menu": {
"delete": "Delete post",
"edit": "Edit post",
@@ -541,9 +634,18 @@
"pinned": "Announcement",
"takeAction": {
"name": "Take action"
+ },
+ "viewPost": {
+ "forGroup": {
+ "title": "In The Group “{name}”"
+ },
+ "title": "Post"
}
},
"profile": {
+ "avatar": {
+ "submitted": "Upload successful!"
+ },
"commented": "Commented",
"follow": "Follow",
"followers": "Followers",
@@ -554,7 +656,6 @@
"title": "Invite somebody to {APPLICATION_NAME}!"
},
"memberSince": "Member since",
- "name": "My Profile",
"network": {
"andMore": "and {number} more …",
"followedBy": "is followed by:",
@@ -644,11 +745,12 @@
"failed": "Nothing found",
"for": "Searching for ",
"heading": {
+ "Group": "Group ::: Groups",
"Post": "Post ::: Posts",
"Tag": "Hashtag ::: Hashtags",
"User": "User ::: Users"
},
- "hint": "What are you searching for? Use !… for posts, @… for users, #… for hashtags.",
+ "hint": "What are you searching for? Use !… for posts, @… for users, &… for groups, #… for hashtags.",
"no-results": "No results found for \"{search}\". Try a different search term!",
"page": "Page",
"placeholder": "Search",
@@ -845,10 +947,5 @@
"newTermsAndConditions": "New Terms and Conditions",
"termsAndConditionsNewConfirm": "I have read and agree to the new terms of conditions.",
"termsAndConditionsNewConfirmText": "Please read the new terms of use now!"
- },
- "user": {
- "avatar": {
- "submitted": "Upload successful!"
- }
}
}
diff --git a/webapp/locales/es.json b/webapp/locales/es.json
index 900b2fa5da..63070ee4b5 100644
--- a/webapp/locales/es.json
+++ b/webapp/locales/es.json
@@ -297,6 +297,16 @@
"follow": "Seguir",
"following": "Siguiendo"
},
+ "group": {
+ "foundation": null,
+ "goal": null,
+ "joinLeaveButton": {
+ "iAmMember": null,
+ "join": null
+ },
+ "membersCount": null,
+ "membersListTitle": null
+ },
"hashtags-filter": {
"clearSearch": "Borrar búsqueda",
"hashtag-search": "Buscando a #{hashtag}",
@@ -435,6 +445,9 @@
}
},
"profile": {
+ "avatar": {
+ "submitted": "Carga con éxito"
+ },
"commented": "Comentado",
"follow": "Seguir",
"followers": "Seguidores",
@@ -715,10 +728,5 @@
"newTermsAndConditions": "Nuevos términos de uso",
"termsAndConditionsNewConfirm": "He leído y acepto los nuevos términos de uso.",
"termsAndConditionsNewConfirmText": "¡Por favor, lea los nuevos términos de uso ahora!"
- },
- "user": {
- "avatar": {
- "submitted": "Carga con éxito"
- }
}
}
diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json
index 684f080b7d..91b91abe99 100644
--- a/webapp/locales/fr.json
+++ b/webapp/locales/fr.json
@@ -286,6 +286,16 @@
"follow": "Suivre",
"following": "Je suis les"
},
+ "group": {
+ "foundation": null,
+ "goal": null,
+ "joinLeaveButton": {
+ "iAmMember": null,
+ "join": null
+ },
+ "membersCount": null,
+ "membersListTitle": null
+ },
"hashtags-filter": {
"clearSearch": "Réinitialiser la recherche",
"hashtag-search": "Recherche de #{hashtag}",
@@ -423,6 +433,9 @@
}
},
"profile": {
+ "avatar": {
+ "submitted": "Téléchargement réussi"
+ },
"commented": "Commentais",
"follow": "Suivre",
"followers": "Suiveurs",
@@ -683,10 +696,5 @@
"newTermsAndConditions": "Nouvelles conditions générales",
"termsAndConditionsNewConfirm": "J'ai lu et accepté les nouvelles conditions générales.",
"termsAndConditionsNewConfirmText": "Veuillez lire les nouvelles conditions d'utilisation dès maintenant !"
- },
- "user": {
- "avatar": {
- "submitted": "Téléchargement réussi"
- }
}
}
diff --git a/webapp/locales/it.json b/webapp/locales/it.json
index 71994167be..342550a147 100644
--- a/webapp/locales/it.json
+++ b/webapp/locales/it.json
@@ -294,6 +294,16 @@
"follow": null,
"following": null
},
+ "group": {
+ "foundation": null,
+ "goal": null,
+ "joinLeaveButton": {
+ "iAmMember": null,
+ "join": null
+ },
+ "membersCount": null,
+ "membersListTitle": null
+ },
"hashtags-filter": {
"clearSearch": null,
"hashtag-search": null,
@@ -376,6 +386,9 @@
}
},
"profile": {
+ "avatar": {
+ "submitted": null
+ },
"commented": "Commentato",
"follow": "Seguire",
"followers": "Seguenti",
@@ -633,10 +646,5 @@
"newTermsAndConditions": "Nuovi Termini e Condizioni",
"termsAndConditionsNewConfirm": "Ho letto e accetto le nuove condizioni generali di contratto.",
"termsAndConditionsNewConfirmText": "Si prega di leggere le nuove condizioni d'uso ora!"
- },
- "user": {
- "avatar": {
- "submitted": null
- }
}
}
diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json
index 3c1a8902d6..c7e474a3a8 100644
--- a/webapp/locales/nl.json
+++ b/webapp/locales/nl.json
@@ -82,6 +82,16 @@
"follow": "Volgen",
"following": "Volgt"
},
+ "group": {
+ "foundation": null,
+ "goal": null,
+ "joinLeaveButton": {
+ "iAmMember": null,
+ "join": null
+ },
+ "membersCount": null,
+ "membersListTitle": null
+ },
"login": {
"email": "Uw E-mail",
"hello": "Hallo",
@@ -100,6 +110,9 @@
}
},
"profile": {
+ "avatar": {
+ "submitted": null
+ },
"follow": "Volgen",
"followers": "Volgelingen",
"following": "Volgt",
diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json
index 6ae3e32f7e..840698487f 100644
--- a/webapp/locales/pl.json
+++ b/webapp/locales/pl.json
@@ -166,6 +166,16 @@
"follow": "naśladować",
"following": "w skutek"
},
+ "group": {
+ "foundation": null,
+ "goal": null,
+ "joinLeaveButton": {
+ "iAmMember": null,
+ "join": null
+ },
+ "membersCount": null,
+ "membersListTitle": null
+ },
"hashtags-filter": {
"title": "Twoja bańka filtrująca"
},
@@ -208,6 +218,9 @@
}
},
"profile": {
+ "avatar": {
+ "submitted": "Przesłano pomyślnie"
+ },
"commented": "Skomentuj",
"follow": "Obserwuj",
"followers": "Obserwujący",
@@ -356,10 +369,5 @@
"taxident": "Numer identyfikacyjny podatku od wartości dodanej zgodnie z § 27 a Ustawa o podatku od wartości dodanej (Niemcy)",
"termsAc": "Warunki użytkowania",
"tribunal": "sąd rejestrowy"
- },
- "user": {
- "avatar": {
- "submitted": "Przesłano pomyślnie"
- }
}
}
diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json
index f5f89374a4..bec922edb1 100644
--- a/webapp/locales/pt.json
+++ b/webapp/locales/pt.json
@@ -332,6 +332,16 @@
"follow": "Seguir",
"following": "Seguindo"
},
+ "group": {
+ "foundation": null,
+ "goal": null,
+ "joinLeaveButton": {
+ "iAmMember": null,
+ "join": null
+ },
+ "membersCount": null,
+ "membersListTitle": null
+ },
"hashtags-filter": {
"clearSearch": "Limpar pesquisa",
"hashtag-search": "Procurando por #{hashtag}",
@@ -412,6 +422,9 @@
}
},
"profile": {
+ "avatar": {
+ "submitted": "Carregado com sucesso!"
+ },
"commented": "Comentou",
"follow": "Seguir",
"followers": "Seguidores",
@@ -668,10 +681,5 @@
"newTermsAndConditions": "Novos Termos e Condições",
"termsAndConditionsNewConfirm": "Eu li e concordo com os novos termos de condições.",
"termsAndConditionsNewConfirmText": "Por favor, leia os novos termos de uso agora!"
- },
- "user": {
- "avatar": {
- "submitted": "Carregado com sucesso!"
- }
}
}
diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json
index 5f1368820d..f50121635a 100644
--- a/webapp/locales/ru.json
+++ b/webapp/locales/ru.json
@@ -311,6 +311,16 @@
"follow": "Подписаться",
"following": "Вы подписаны"
},
+ "group": {
+ "foundation": null,
+ "goal": null,
+ "joinLeaveButton": {
+ "iAmMember": null,
+ "join": null
+ },
+ "membersCount": null,
+ "membersListTitle": null
+ },
"hashtags-filter": {
"clearSearch": "Очистить поиск",
"hashtag-search": "Поиск по #{hashtag}",
@@ -449,6 +459,9 @@
}
},
"profile": {
+ "avatar": {
+ "submitted": "Успешная загрузка!"
+ },
"commented": "Прокомментированные",
"follow": "Подписаться",
"followers": "Подписчики",
@@ -729,10 +742,5 @@
"newTermsAndConditions": "Новые условия и положения",
"termsAndConditionsNewConfirm": "Я прочитал(а) и согласен(на) с новыми условиями.",
"termsAndConditionsNewConfirmText": "Пожалуйста, ознакомьтесь с новыми условиями использования!"
- },
- "user": {
- "avatar": {
- "submitted": "Успешная загрузка!"
- }
}
}
diff --git a/webapp/maintenance/source/package.json b/webapp/maintenance/source/package.json
index 127fba57c6..e368229579 100644
--- a/webapp/maintenance/source/package.json
+++ b/webapp/maintenance/source/package.json
@@ -1,6 +1,6 @@
{
"name": "@ocelot-social/maintenance",
- "version": "1.1.1",
+ "version": "2.0.0",
"description": "Maintenance page for ocelot.social",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",
diff --git a/webapp/mixins/persistentLinks.js b/webapp/mixins/persistentLinks.js
index 0abe37c037..164ccea41f 100644
--- a/webapp/mixins/persistentLinks.js
+++ b/webapp/mixins/persistentLinks.js
@@ -10,21 +10,23 @@ export default function (options = {}) {
} = context
const idOrSlug = id || slug
- const variables = { idOrSlug }
- const client = apolloProvider.defaultClient
+ if (idOrSlug) {
+ const variables = { idOrSlug }
+ const client = apolloProvider.defaultClient
- let response
- let resource
- response = await client.query({ query: queryId, variables })
- resource = response.data[Object.keys(response.data)[0]][0]
- if (resource && resource.slug === slug) return // all good
- if (resource && resource.slug !== slug) {
- return redirect(`/${path}/${resource.id}/${resource.slug}`)
- }
+ let response
+ let resource
+ response = await client.query({ query: queryId, variables })
+ resource = response.data[Object.keys(response.data)[0]][0]
+ if (resource && resource.slug === slug) return // all good
+ if (resource && resource.slug !== slug) {
+ return redirect(`/${path}/${resource.id}/${resource.slug}`)
+ }
- response = await client.query({ query: querySlug, variables })
- resource = response.data[Object.keys(response.data)[0]][0]
- if (resource) return redirect(`/${path}/${resource.id}/${resource.slug}`)
+ response = await client.query({ query: querySlug, variables })
+ resource = response.data[Object.keys(response.data)[0]][0]
+ if (resource) return redirect(`/${path}/${resource.id}/${resource.slug}`)
+ }
return error({ statusCode: 404, key: message })
},
diff --git a/webapp/package.json b/webapp/package.json
index f62efebab3..d4c50c97e0 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -1,6 +1,6 @@
{
"name": "ocelot-social-webapp",
- "version": "1.1.1",
+ "version": "2.0.0",
"description": "ocelot.social Frontend",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",
diff --git a/webapp/pages/group/_id.spec.js b/webapp/pages/group/_id.spec.js
new file mode 100644
index 0000000000..bafd5c3925
--- /dev/null
+++ b/webapp/pages/group/_id.spec.js
@@ -0,0 +1,33 @@
+import { config, mount } from '@vue/test-utils'
+import _id from './_id.vue'
+
+const localVue = global.localVue
+
+config.stubs['nuxt-child'] = ' '
+
+describe('Group profile _id.vue', () => {
+ let wrapper
+ let Wrapper
+ let mocks
+
+ beforeEach(() => {
+ mocks = {}
+ })
+
+ describe('mount', () => {
+ Wrapper = () => {
+ return mount(_id, {
+ mocks,
+ localVue,
+ })
+ }
+
+ beforeEach(() => {
+ wrapper = Wrapper()
+ })
+
+ it('renders', () => {
+ expect(wrapper.findAll('.nuxt-child')).toHaveLength(1)
+ })
+ })
+})
diff --git a/webapp/pages/group/_id.vue b/webapp/pages/group/_id.vue
new file mode 100644
index 0000000000..d743633d1f
--- /dev/null
+++ b/webapp/pages/group/_id.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
diff --git a/webapp/pages/group/_id/_slug.spec.js b/webapp/pages/group/_id/_slug.spec.js
new file mode 100644
index 0000000000..963952f5e2
--- /dev/null
+++ b/webapp/pages/group/_id/_slug.spec.js
@@ -0,0 +1,1635 @@
+import { config, mount } from '@vue/test-utils'
+import GroupProfileSlug from './_slug.vue'
+
+const localVue = global.localVue
+
+localVue.filter('date', (d) => d)
+
+config.stubs['client-only'] = ' '
+config.stubs['v-popover'] = ' '
+config.stubs['nuxt-link'] = ' '
+config.stubs['infinite-loading'] = ' '
+config.stubs['follow-list'] = ' '
+
+describe('GroupProfileSlug', () => {
+ let wrapper
+ let Wrapper
+ let mocks
+ let yogaPractice
+ let schoolForCitizens
+ let investigativeJournalism
+ let peterLustig
+ let jennyRostock
+ let bobDerBaumeister
+ let huey
+
+ beforeEach(() => {
+ mocks = {
+ $env: {
+ CATEGORIES_ACTIVE: true,
+ },
+ // post: {
+ // id: 'p23',
+ // name: 'It is a post',
+ // },
+ $t: jest.fn((a) => a),
+ $filters: {
+ removeLinks: (c) => c,
+ truncate: (a) => a,
+ },
+ // If you're mocking router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
+ $route: {
+ params: {
+ id: 'g1',
+ slug: 'school-for-citizens',
+ },
+ },
+ $router: {
+ history: {
+ push: jest.fn(),
+ },
+ },
+ $toast: {
+ success: jest.fn(),
+ error: jest.fn(),
+ },
+ $apollo: {
+ loading: false,
+ mutate: jest.fn().mockResolvedValue(),
+ },
+ }
+ yogaPractice = {
+ id: 'g2',
+ name: 'Yoga Practice',
+ slug: 'yoga-practice',
+ about: null,
+ description: `What Is yoga? Yoga is not just about practicing asanas. It's about how we do it.
And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.
What makes practicing asanas yogic? The important thing is:
`,
+ descriptionExcerpt: `What Is yoga? Yoga is not just about practicing asanas. It's about how we do it.
And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.
What makes practicing asanas yogic? The important thing is:
`,
+ groupType: 'public',
+ actionRadius: 'interplanetary',
+ categories: [
+ {
+ id: 'cat4',
+ icon: 'psyche',
+ name: 'psyche',
+ slug: 'psyche',
+ description: 'Seele, Gefühle, Glück',
+ },
+ {
+ id: 'cat5',
+ icon: 'movement',
+ name: 'body-and-excercise',
+ slug: 'body-and-excercise',
+ description: 'Sport, Yoga, Massage, Tanzen, Entspannung',
+ },
+ {
+ id: 'cat17',
+ icon: 'spirituality',
+ name: 'spirituality',
+ slug: 'spirituality',
+ description: 'Religion, Werte, Ethik',
+ },
+ ],
+ locationName: null,
+ location: null,
+ // myRole: 'usual',
+ }
+ schoolForCitizens = {
+ id: 'g1',
+ name: 'School For Citizens',
+ slug: 'school-for-citizens',
+ about: 'Our children shall receive education for life.',
+ description: `English
Our goal 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.
Curiosity For this we need a school that takes up the curiosity of the children, the people, and satisfies it through a lot of experience.
Deutsch
Unser Ziel 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.
Neugier Dazu benötigen wir eine Schule, die die Neugier der Kinder, der Menschen, aufnimmt und durch viel Erfahrung befriedigt.
`,
+ descriptionExcerpt: `English
Our goal 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.
Curiosity For this we need a school that takes up the curiosity of the children, …
`,
+ groupType: 'closed',
+ actionRadius: 'national',
+ categories: [
+ {
+ id: 'cat8',
+ icon: 'child',
+ name: 'children',
+ slug: 'children',
+ description: 'Familie, Pädagogik, Schule, Prägung',
+ },
+ {
+ id: 'cat14',
+ icon: 'science',
+ name: 'science',
+ slug: 'science',
+ description: 'Bildung, Hochschule, Publikationen, ...',
+ },
+ ],
+ locationName: 'France',
+ location: {
+ name: 'Paris',
+ nameDE: 'Paris',
+ nameEN: 'Paris',
+ },
+ // myRole: 'usual',
+ }
+ investigativeJournalism = {
+ id: 'g0',
+ name: 'Investigative Journalism',
+ slug: 'investigative-journalism',
+ about: 'Investigative journalists share ideas and insights and can collaborate.',
+ description: `English:
This group is hidden.
What is our group for? This group was created to allow investigative journalists to share and collaborate.
How does it work? Here you can internally share posts and comments about them.
Deutsch:
Diese Gruppe ist verborgen.
Wofür ist unsere Gruppe? Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.
Wie funktioniert das? Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.
`,
+ descriptionExcerpt:
+ 'English:
This group is hidden.
What is our group for? This group was created to allow investigative journalists to share and collaborate.
How does it work? Here you can internally share posts and comments about them.
Deutsch:
Diese Gruppe ist verborgen.
… ',
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categories: [
+ {
+ id: 'cat6',
+ icon: 'balance-scale',
+ name: 'law',
+ slug: 'law',
+ description: 'Menschenrechte, Gesetze, Verordnungen',
+ },
+ {
+ id: 'cat12',
+ icon: 'politics',
+ name: 'politics',
+ slug: 'politics',
+ description: 'Demokratie, Mitbestimmung, Wahlen, Korruption, Parteien',
+ },
+ {
+ id: 'cat16',
+ icon: 'media',
+ name: 'it-and-media',
+ slug: 'it-and-media',
+ description:
+ 'Nachrichten, Manipulation, Datenschutz, Überwachung, Datenkraken, AI, Software, Apps',
+ },
+ ],
+ locationName: 'Hamburg, Germany',
+ location: {
+ name: 'Hamburg',
+ nameDE: 'Hamburg',
+ nameEN: 'Hamburg',
+ },
+ // myRole: 'usual',
+ }
+ peterLustig = {
+ id: 'u1',
+ name: 'Peter Lustig',
+ slug: 'peter-lustig',
+ role: 'user',
+ }
+ jennyRostock = {
+ id: 'u3',
+ name: 'Jenny Rostock',
+ slug: 'jenny-rostock',
+ role: 'user',
+ }
+ bobDerBaumeister = {
+ id: 'u2',
+ name: 'Bob der Baumeister',
+ slug: 'bob-der-baumeister',
+ role: 'user',
+ }
+ huey = {
+ id: 'u4',
+ name: 'Huey',
+ slug: 'huey',
+ role: 'user',
+ }
+ })
+
+ describe('mount', () => {
+ Wrapper = (data = () => {}) => {
+ return mount(GroupProfileSlug, {
+ mocks,
+ localVue,
+ data,
+ })
+ }
+
+ describe('given a puplic group – "yoga-practice"', () => {
+ describe('given a current user', () => {
+ describe('as group owner – "peter-lustig"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': peterLustig,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...yogaPractice,
+ myRole: 'owner',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('Yoga Practice')
+ })
+
+ it('has AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(true)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(true)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&yoga-practice')
+ })
+
+ describe('displays no(!) group location – because is "null"', () => {
+ it('has no(!) group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(false)
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has members count', () => {
+ expect(wrapper.text()).toContain('group.membersCount')
+ })
+
+ it('has join/leave button disabled(!)', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBe('disabled')
+ })
+
+ it('has group role "owner"', () => {
+ expect(wrapper.text()).toContain('group.role')
+ expect(wrapper.text()).toContain('group.roles.owner')
+ })
+
+ it('has group type "public"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.public')
+ })
+
+ it('has group action radius "interplanetary"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.interplanetary')
+ })
+
+ it('has group categories "psyche", "body-and-excercise", "spirituality"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.psyche')
+ expect(wrapper.text()).toContain('contribution.category.name.body-and-excercise')
+ expect(wrapper.text()).toContain('contribution.category.name.spirituality')
+ })
+
+ it('has no(!) group goal – because is "null"', () => {
+ expect(wrapper.text()).not.toContain('group.goal')
+ })
+
+ it('has ProfileList with members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ expect(profileList.text()).toContain('group.membersListTitle')
+ expect(profileList.text()).not.toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).toContain('Peter Lustig')
+ expect(profileList.text()).toContain('Jenny Rostock')
+ expect(profileList.text()).toContain('Bob der Baumeister')
+ expect(profileList.text()).toContain('Huey')
+ })
+
+ describe('displays description – here as well the functionallity', () => {
+ let groupDescriptionBaseCard
+
+ beforeEach(async () => {
+ groupDescriptionBaseCard = wrapper.find('.group-description')
+ })
+
+ it('has description BaseCard', () => {
+ expect(groupDescriptionBaseCard.exists()).toBe(true)
+ })
+
+ describe('displays descriptionExcerpt first', () => {
+ it('has descriptionExcerpt', () => {
+ expect(groupDescriptionBaseCard.text()).toContain(
+ `What Is yoga?Yoga is not just about practicing asanas. It's about how we do it.And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.What makes practicing asanas yogic?The important thing is:Use the exercises …`,
+ )
+ })
+
+ it('has "show more" button', () => {
+ expect(wrapper.vm.isDescriptionCollapsed).toBe(true)
+ expect(groupDescriptionBaseCard.text()).toContain('comment.show.more')
+ })
+ })
+
+ describe('after "show more" click displays full description', () => {
+ beforeEach(async () => {
+ await groupDescriptionBaseCard.find('.collaps-button').trigger('click')
+ await wrapper.vm.$nextTick()
+ })
+
+ it('has full description', () => {
+ // test if end of full description is visible
+ expect(groupDescriptionBaseCard.text()).toContain(
+ `Use the exercises (consciously) for your personal development.`,
+ )
+ })
+
+ it('has "show less" button', () => {
+ expect(wrapper.vm.isDescriptionCollapsed).toBe(false)
+ expect(groupDescriptionBaseCard.text()).toContain('comment.show.less')
+ })
+ })
+ })
+
+ it('has profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(true)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+
+ describe('as usual member – "jenny-rostock"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': jennyRostock,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...yogaPractice,
+ myRole: 'usual',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('Yoga Practice')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&yoga-practice')
+ })
+
+ describe('displays no(!) group location – because is "null"', () => {
+ it('has no(!) group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(false)
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has members count', () => {
+ expect(wrapper.text()).toContain('group.membersCount')
+ })
+
+ it('has join/leave button enabled', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBeFalsy()
+ })
+
+ it('has group role "usual"', () => {
+ expect(wrapper.text()).toContain('group.role')
+ expect(wrapper.text()).toContain('group.roles.usual')
+ })
+
+ it('has group type "public"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.public')
+ })
+
+ it('has group action radius "interplanetary"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.interplanetary')
+ })
+
+ it('has group categories "psyche", "body-and-excercise", "spirituality"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.psyche')
+ expect(wrapper.text()).toContain('contribution.category.name.body-and-excercise')
+ expect(wrapper.text()).toContain('contribution.category.name.spirituality')
+ })
+
+ it('has no(!) group goal – because is "null"', () => {
+ expect(wrapper.text()).not.toContain('group.goal')
+ })
+
+ it('has ProfileList with members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ expect(profileList.text()).toContain('group.membersListTitle')
+ expect(profileList.text()).not.toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).toContain('Peter Lustig')
+ expect(profileList.text()).toContain('Jenny Rostock')
+ expect(profileList.text()).toContain('Bob der Baumeister')
+ expect(profileList.text()).toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(true)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+
+ describe('as pending member – "bob-der-baumeister"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': bobDerBaumeister,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...yogaPractice,
+ myRole: 'pending',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('Yoga Practice')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&yoga-practice')
+ })
+
+ describe('displays no(!) group location – because is "null"', () => {
+ it('has no(!) group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(false)
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has members count', () => {
+ expect(wrapper.text()).toContain('group.membersCount')
+ })
+
+ it('has join/leave button enabled', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBeFalsy()
+ })
+
+ it('has group role "pending"', () => {
+ expect(wrapper.text()).toContain('group.role')
+ expect(wrapper.text()).toContain('group.roles.pending')
+ })
+
+ it('has group type "public"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.public')
+ })
+
+ it('has group action radius "interplanetary"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.interplanetary')
+ })
+
+ it('has group categories "psyche", "body-and-excercise", "spirituality"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.psyche')
+ expect(wrapper.text()).toContain('contribution.category.name.body-and-excercise')
+ expect(wrapper.text()).toContain('contribution.category.name.spirituality')
+ })
+
+ it('has no(!) group goal – because is "null"', () => {
+ expect(wrapper.text()).not.toContain('group.goal')
+ })
+
+ it('has ProfileList with members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ expect(profileList.text()).toContain('group.membersListTitle')
+ expect(profileList.text()).not.toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).toContain('Peter Lustig')
+ expect(profileList.text()).toContain('Jenny Rostock')
+ expect(profileList.text()).toContain('Bob der Baumeister')
+ expect(profileList.text()).toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has no(!) profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(false)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+
+ describe('as none(!) member – "huey"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': huey,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...yogaPractice,
+ myRole: null,
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('Yoga Practice')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&yoga-practice')
+ })
+
+ describe('displays no(!) group location – because is "null"', () => {
+ it('has no(!) group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(false)
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has members count', () => {
+ expect(wrapper.text()).toContain('group.membersCount')
+ })
+
+ it('has join/leave button enabled', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBeFalsy()
+ })
+
+ it('has no(!) group role', () => {
+ expect(wrapper.text()).not.toContain('group.role')
+ expect(wrapper.text()).not.toContain('group.roles')
+ })
+
+ it('has group type "public"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.public')
+ })
+
+ it('has group action radius "interplanetary"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.interplanetary')
+ })
+
+ it('has group categories "psyche", "body-and-excercise", "spirituality"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.psyche')
+ expect(wrapper.text()).toContain('contribution.category.name.body-and-excercise')
+ expect(wrapper.text()).toContain('contribution.category.name.spirituality')
+ })
+
+ it('has no(!) group goal – because is "null"', () => {
+ expect(wrapper.text()).not.toContain('group.goal')
+ })
+
+ it('has ProfileList with members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ expect(profileList.text()).toContain('group.membersListTitle')
+ expect(profileList.text()).not.toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).toContain('Peter Lustig')
+ expect(profileList.text()).toContain('Jenny Rostock')
+ expect(profileList.text()).toContain('Bob der Baumeister')
+ expect(profileList.text()).toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has no(!) profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(false)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+ })
+ })
+
+ describe('given a closed group – "school-for-citizens"', () => {
+ describe('given a current user', () => {
+ describe('as group owner – "peter-lustig"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': peterLustig,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...schoolForCitizens,
+ myRole: 'owner',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('School For Citizens')
+ })
+
+ it('has AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(true)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(true)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&school-for-citizens')
+ })
+
+ describe('displays group location', () => {
+ it('has group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(true)
+ })
+
+ it('has group location name "Paris"', () => {
+ expect(wrapper.text()).toContain('Paris')
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has members count', () => {
+ expect(wrapper.text()).toContain('group.membersCount')
+ })
+
+ it('has join/leave button disabled(!)', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBe('disabled')
+ })
+
+ it('has group role "owner"', () => {
+ expect(wrapper.text()).toContain('group.role')
+ expect(wrapper.text()).toContain('group.roles.owner')
+ })
+
+ it('has group type "closed"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.closed')
+ })
+
+ it('has group action radius "national"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.national')
+ })
+
+ it('has group categories "children", "science"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.children')
+ expect(wrapper.text()).toContain('contribution.category.name.science')
+ })
+
+ it('has group goal', () => {
+ expect(wrapper.text()).toContain('group.goal')
+ expect(wrapper.text()).toContain('Our children shall receive education for life.')
+ })
+
+ it('has ProfileList with members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ expect(profileList.text()).toContain('group.membersListTitle')
+ expect(profileList.text()).not.toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).toContain('Peter Lustig')
+ expect(profileList.text()).toContain('Jenny Rostock')
+ expect(profileList.text()).toContain('Bob der Baumeister')
+ expect(profileList.text()).toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(true)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+
+ describe('as usual member – "jenny-rostock"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': jennyRostock,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...schoolForCitizens,
+ myRole: 'usual',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('School For Citizens')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&school-for-citizens')
+ })
+
+ describe('displays group location', () => {
+ it('has group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(true)
+ })
+
+ it('has group location name "Paris"', () => {
+ expect(wrapper.text()).toContain('Paris')
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has members count', () => {
+ expect(wrapper.text()).toContain('group.membersCount')
+ })
+
+ it('has join/leave button enabled', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBeFalsy()
+ })
+
+ it('has group role "usual"', () => {
+ expect(wrapper.text()).toContain('group.role')
+ expect(wrapper.text()).toContain('group.roles.usual')
+ })
+
+ it('has group type "closed"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.closed')
+ })
+
+ it('has group action radius "national"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.national')
+ })
+
+ it('has group categories "children", "science"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.children')
+ expect(wrapper.text()).toContain('contribution.category.name.science')
+ })
+
+ it('has group goal', () => {
+ expect(wrapper.text()).toContain('group.goal')
+ expect(wrapper.text()).toContain('Our children shall receive education for life.')
+ })
+
+ it('has ProfileList with members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ expect(profileList.text()).toContain('group.membersListTitle')
+ expect(profileList.text()).not.toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).toContain('Peter Lustig')
+ expect(profileList.text()).toContain('Jenny Rostock')
+ expect(profileList.text()).toContain('Bob der Baumeister')
+ expect(profileList.text()).toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(true)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+
+ describe('as pending member – "bob-der-baumeister"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': bobDerBaumeister,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...schoolForCitizens,
+ myRole: 'pending',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('School For Citizens')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&school-for-citizens')
+ })
+
+ describe('displays group location', () => {
+ it('has group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(true)
+ })
+
+ it('has group location name "Paris"', () => {
+ expect(wrapper.text()).toContain('Paris')
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has no(!) members count', () => {
+ expect(wrapper.text()).not.toContain('group.membersCount')
+ })
+
+ it('has join/leave button enabled', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBeFalsy()
+ })
+
+ it('has group role "pending"', () => {
+ expect(wrapper.text()).toContain('group.role')
+ expect(wrapper.text()).toContain('group.roles.pending')
+ })
+
+ it('has group type "closed"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.closed')
+ })
+
+ it('has group action radius "national"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.national')
+ })
+
+ it('has group categories "children", "science"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.children')
+ expect(wrapper.text()).toContain('contribution.category.name.science')
+ })
+
+ it('has group goal', () => {
+ expect(wrapper.text()).toContain('group.goal')
+ expect(wrapper.text()).toContain('Our children shall receive education for life.')
+ })
+
+ it('has ProfileList without(!) members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ // expect(profileList.text()).not.toContain('group.membersListTitle') // does not work, because is part of 'group.membersListTitleNotAllowedSeeingGroupMembers'
+ expect(profileList.text()).toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).not.toContain('Peter Lustig')
+ expect(profileList.text()).not.toContain('Jenny Rostock')
+ expect(profileList.text()).not.toContain('Bob der Baumeister')
+ expect(profileList.text()).not.toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has no(!) profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(false)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+
+ describe('as none(!) member – "huey"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': huey,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...schoolForCitizens,
+ myRole: null,
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('School For Citizens')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&school-for-citizens')
+ })
+
+ describe('displays group location', () => {
+ it('has group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(true)
+ })
+
+ it('has group location name "Paris"', () => {
+ expect(wrapper.text()).toContain('Paris')
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has no(!) members count', () => {
+ expect(wrapper.text()).not.toContain('group.membersCount')
+ })
+
+ it('has join/leave button enabled', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBeFalsy()
+ })
+
+ it('has no(!) group role', () => {
+ expect(wrapper.text()).not.toContain('group.role')
+ expect(wrapper.text()).not.toContain('group.roles')
+ })
+
+ it('has group type "closed"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.closed')
+ })
+
+ it('has group action radius "national"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.national')
+ })
+
+ it('has group categories "children", "science"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.children')
+ expect(wrapper.text()).toContain('contribution.category.name.science')
+ })
+
+ it('has group goal', () => {
+ expect(wrapper.text()).toContain('group.goal')
+ expect(wrapper.text()).toContain('Our children shall receive education for life.')
+ })
+
+ it('has ProfileList without(!) members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ // expect(profileList.text()).not.toContain('group.membersListTitle') // does not work, because is part of 'group.membersListTitleNotAllowedSeeingGroupMembers'
+ expect(profileList.text()).toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).not.toContain('Peter Lustig')
+ expect(profileList.text()).not.toContain('Jenny Rostock')
+ expect(profileList.text()).not.toContain('Bob der Baumeister')
+ expect(profileList.text()).not.toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has no(!) profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(false)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+ })
+ })
+
+ describe('given a hidden group – "investigative-journalism"', () => {
+ describe('given a current user', () => {
+ describe('as group owner – "peter-lustig"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': peterLustig,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...investigativeJournalism,
+ myRole: 'owner',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('Investigative Journalism')
+ })
+
+ it('has AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(true)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(true)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&investigative-journalism')
+ })
+
+ describe('displays group location', () => {
+ it('has group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(true)
+ })
+
+ it('has group location name "Hamburg"', () => {
+ expect(wrapper.text()).toContain('Hamburg')
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has members count', () => {
+ expect(wrapper.text()).toContain('group.membersCount')
+ })
+
+ it('has join/leave button disabled(!)', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBe('disabled')
+ })
+
+ it('has group role "owner"', () => {
+ expect(wrapper.text()).toContain('group.role')
+ expect(wrapper.text()).toContain('group.roles.owner')
+ })
+
+ it('has group type "hidden"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.hidden')
+ })
+
+ it('has group action radius "global"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.global')
+ })
+
+ it('has group categories "law", "politics", "it-and-media"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.law')
+ expect(wrapper.text()).toContain('contribution.category.name.politics')
+ expect(wrapper.text()).toContain('contribution.category.name.it-and-media')
+ })
+
+ it('has group goal', () => {
+ expect(wrapper.text()).toContain('group.goal')
+ expect(wrapper.text()).toContain(
+ 'Investigative journalists share ideas and insights and can collaborate.',
+ )
+ })
+
+ it('has ProfileList with members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ expect(profileList.text()).toContain('group.membersListTitle')
+ expect(profileList.text()).not.toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).toContain('Peter Lustig')
+ expect(profileList.text()).toContain('Jenny Rostock')
+ expect(profileList.text()).toContain('Bob der Baumeister')
+ expect(profileList.text()).toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(true)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+
+ describe('as usual member – "jenny-rostock"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': jennyRostock,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...investigativeJournalism,
+ myRole: 'usual',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has group name – to verificate the group', () => {
+ expect(wrapper.text()).toContain('Investigative Journalism')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(true)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('&investigative-journalism')
+ })
+
+ describe('displays group location', () => {
+ it('has group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(true)
+ })
+
+ it('has group location name "Hamburg"', () => {
+ expect(wrapper.text()).toContain('Hamburg')
+ })
+ })
+
+ it('has group foundation', () => {
+ expect(wrapper.text()).toContain('group.foundation')
+ })
+
+ it('has members count', () => {
+ expect(wrapper.text()).toContain('group.membersCount')
+ })
+
+ it('has join/leave button enabled', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(true)
+ expect(wrapper.find('.join-leave-button').attributes('disabled')).toBeFalsy()
+ })
+
+ it('has group role "usual"', () => {
+ expect(wrapper.text()).toContain('group.role')
+ expect(wrapper.text()).toContain('group.roles.usual')
+ })
+
+ it('has group type "hidden"', () => {
+ expect(wrapper.text()).toContain('group.type')
+ expect(wrapper.text()).toContain('group.types.hidden')
+ })
+
+ it('has group action radius "global"', () => {
+ expect(wrapper.text()).toContain('group.actionRadius')
+ expect(wrapper.text()).toContain('group.actionRadii.global')
+ })
+
+ it('has group categories "law", "politics", "it-and-media"', () => {
+ expect(wrapper.text()).toContain('group.categories')
+ expect(wrapper.text()).toContain('contribution.category.name.law')
+ expect(wrapper.text()).toContain('contribution.category.name.politics')
+ expect(wrapper.text()).toContain('contribution.category.name.it-and-media')
+ })
+
+ it('has group goal', () => {
+ expect(wrapper.text()).toContain('group.goal')
+ expect(wrapper.text()).toContain(
+ 'Investigative journalists share ideas and insights and can collaborate.',
+ )
+ })
+
+ it('has ProfileList with members', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(true)
+ expect(profileList.text()).toContain('group.membersListTitle')
+ expect(profileList.text()).not.toContain(
+ 'group.membersListTitleNotAllowedSeeingGroupMembers',
+ )
+ expect(profileList.text()).toContain('Peter Lustig')
+ expect(profileList.text()).toContain('Jenny Rostock')
+ expect(profileList.text()).toContain('Bob der Baumeister')
+ expect(profileList.text()).toContain('Huey')
+ })
+
+ it('has description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(true)
+ })
+
+ it('has profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(true)
+ })
+
+ it('has empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(true)
+ })
+ })
+
+ describe('as pending member – "bob-der-baumeister"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': bobDerBaumeister,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...investigativeJournalism,
+ myRole: 'pending',
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has no(!) group name – to verificate the group', () => {
+ expect(wrapper.text()).not.toContain('Investigative Journalism')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has not(!) ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(false)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has no(!) group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(false)
+ expect(wrapper.text()).not.toContain('&investigative-journalism')
+ })
+
+ describe('displays not(!) group location', () => {
+ it('has no(!) group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(false)
+ })
+
+ it('has no(!) group location name "Hamburg"', () => {
+ expect(wrapper.text()).not.toContain('Hamburg')
+ })
+ })
+
+ it('has no(!) group foundation', () => {
+ expect(wrapper.text()).not.toContain('group.foundation')
+ })
+
+ it('has no(!) members count', () => {
+ expect(wrapper.text()).not.toContain('group.membersCount')
+ })
+
+ it('has no(!) join/leave button', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(false)
+ })
+
+ it('has no(!) group role', () => {
+ expect(wrapper.text()).not.toContain('group.role')
+ expect(wrapper.text()).not.toContain('group.roles')
+ })
+
+ it('has no(!) group type', () => {
+ expect(wrapper.text()).not.toContain('group.type')
+ expect(wrapper.text()).not.toContain('group.types')
+ })
+
+ it('has no(!) group action radius', () => {
+ expect(wrapper.text()).not.toContain('group.actionRadius')
+ expect(wrapper.text()).not.toContain('group.actionRadii')
+ })
+
+ it('has no(!) group categories "law", "politics", "it-and-media"', () => {
+ expect(wrapper.text()).not.toContain('group.categories')
+ expect(wrapper.text()).not.toContain('contribution.category.name.law')
+ expect(wrapper.text()).not.toContain('contribution.category.name.politics')
+ expect(wrapper.text()).not.toContain('contribution.category.name.it-and-media')
+ })
+
+ it('has no(!) group goal', () => {
+ expect(wrapper.text()).not.toContain('group.goal')
+ })
+
+ it('has not(!) ProfileList', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(false)
+ })
+
+ it('has not(!) description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(false)
+ })
+
+ it('has no(!) profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(false)
+ })
+
+ it('has no(!) empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(false)
+ })
+ })
+
+ describe('as none(!) member – "huey"', () => {
+ beforeEach(() => {
+ mocks.$store = {
+ getters: {
+ 'auth/user': huey,
+ 'auth/isModerator': () => false,
+ },
+ }
+ wrapper = Wrapper(() => {
+ return {
+ Group: [
+ {
+ ...investigativeJournalism,
+ myRole: null,
+ },
+ ],
+ GroupMembers: [peterLustig, jennyRostock, bobDerBaumeister, huey],
+ }
+ })
+ })
+
+ it('has no(!) group name – to verificate the group', () => {
+ expect(wrapper.text()).not.toContain('Investigative Journalism')
+ })
+
+ it('has not(!) AvatarUploader', () => {
+ expect(wrapper.find('.avatar-uploader').exists()).toBe(false)
+ })
+
+ it('has not(!) ProfileAvatar', () => {
+ expect(wrapper.find('.profile-avatar').exists()).toBe(false)
+ })
+
+ it('has not(!) GroupContentMenu', () => {
+ expect(wrapper.find('.group-content-menu').exists()).toBe(false)
+ })
+
+ it('has no(!) group slug', () => {
+ // expect(wrapper.find('[data-test="ampersand"]').exists()).toBe(false)
+ expect(wrapper.text()).not.toContain('&investigative-journalism')
+ })
+
+ describe('displays not(!) group location', () => {
+ it('has no(!) group location icon "map-marker"', () => {
+ expect(wrapper.find('[data-test="map-marker"]').exists()).toBe(false)
+ })
+
+ it('has no(!) group location name "Hamburg"', () => {
+ expect(wrapper.text()).not.toContain('Hamburg')
+ })
+ })
+
+ it('has no(!) group foundation', () => {
+ expect(wrapper.text()).not.toContain('group.foundation')
+ })
+
+ it('has no(!) members count', () => {
+ expect(wrapper.text()).not.toContain('group.membersCount')
+ })
+
+ it('has no(!) join/leave button', () => {
+ expect(wrapper.find('.join-leave-button').exists()).toBe(false)
+ })
+
+ it('has no(!) group role', () => {
+ expect(wrapper.text()).not.toContain('group.role')
+ expect(wrapper.text()).not.toContain('group.roles')
+ })
+
+ it('has no(!) group type', () => {
+ expect(wrapper.text()).not.toContain('group.type')
+ expect(wrapper.text()).not.toContain('group.types')
+ })
+
+ it('has no(!) group action radius', () => {
+ expect(wrapper.text()).not.toContain('group.actionRadius')
+ expect(wrapper.text()).not.toContain('group.actionRadii')
+ })
+
+ it('has no(!) group categories "law", "politics", "it-and-media"', () => {
+ expect(wrapper.text()).not.toContain('group.categories')
+ expect(wrapper.text()).not.toContain('contribution.category.name.law')
+ expect(wrapper.text()).not.toContain('contribution.category.name.politics')
+ expect(wrapper.text()).not.toContain('contribution.category.name.it-and-media')
+ })
+
+ it('has no(!) group goal', () => {
+ expect(wrapper.text()).not.toContain('group.goal')
+ })
+
+ it('has not(!) ProfileList', () => {
+ const profileList = wrapper.find('.profile-list')
+ expect(profileList.exists()).toBe(false)
+ })
+
+ it('has not(!) description BaseCard', () => {
+ expect(wrapper.find('.group-description').exists()).toBe(false)
+ })
+
+ it('has no(!) profile post add button', () => {
+ expect(wrapper.find('.profile-post-add-button').exists()).toBe(false)
+ })
+
+ it('has no(!) empty post list', () => {
+ expect(wrapper.find('[data-test="icon-empty"]').exists()).toBe(false)
+ })
+ })
+ })
+ })
+ })
+})
diff --git a/webapp/pages/group/_id/_slug.vue b/webapp/pages/group/_id/_slug.vue
new file mode 100644
index 0000000000..d1b410081e
--- /dev/null
+++ b/webapp/pages/group/_id/_slug.vue
@@ -0,0 +1,677 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ groupName }}
+
+
+
+
+ {{ `&${groupSlug}` }}
+
+
+
+
+ {{ group && group.location ? group.location.name : '' }}
+
+
+
+ {{ $t('group.foundation') }} {{ group.createdAt | date('MMMM yyyy') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('group.role') }}
+
+
+
+ {{ group && group.myRole ? $t('group.roles.' + group.myRole) : '' }}
+
+
+
+
+
+ {{ $t('group.type') }}
+
+
+
+ {{ group && group.groupType ? $t('group.types.' + group.groupType) : '' }}
+
+
+
+
+ {{ $t('group.actionRadius') }}
+
+
+
+ {{
+ group && group.actionRadius ? $t('group.actionRadii.' + group.actionRadius) : ''
+ }}
+
+
+
+
+
+
+
+
+
+ {{
+ $t(
+ 'group.categories',
+ {},
+ group && group.categories ? group.categories.length : 0,
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('group.goal') }}
+
+
+
+ {{ group ? group.about : '' }}
+
+
+
+
+
+
+ {{ $t('profile.network.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isDescriptionCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/pages/group/create.vue b/webapp/pages/group/create.vue
new file mode 100644
index 0000000000..b16cf933f9
--- /dev/null
+++ b/webapp/pages/group/create.vue
@@ -0,0 +1,72 @@
+
+
+
+ {{ $t('group.createNewGroup.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/pages/group/edit/_id.vue b/webapp/pages/group/edit/_id.vue
new file mode 100644
index 0000000000..7a0f9d051d
--- /dev/null
+++ b/webapp/pages/group/edit/_id.vue
@@ -0,0 +1,66 @@
+
+
+
+ {{ $t('group.editGroupSettings.title') }}
+
+ {{ $t('group.editGroupSettings.groupName', { name: group.name }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/pages/group/edit/_id/index.vue b/webapp/pages/group/edit/_id/index.vue
new file mode 100644
index 0000000000..e3c934dc50
--- /dev/null
+++ b/webapp/pages/group/edit/_id/index.vue
@@ -0,0 +1,72 @@
+
+
+
+ {{ $t('group.general') }}
+
+
+
+
+
+
+
diff --git a/webapp/pages/group/edit/_id/members.vue b/webapp/pages/group/edit/_id/members.vue
new file mode 100644
index 0000000000..c1e19bcd1e
--- /dev/null
+++ b/webapp/pages/group/edit/_id/members.vue
@@ -0,0 +1,57 @@
+
+
+
+ {{ $t('group.members') }}
+
+
+
+
+
+
+
diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue
index 9fb20d53be..8b06341eb1 100644
--- a/webapp/pages/index.vue
+++ b/webapp/pages/index.vue
@@ -38,7 +38,6 @@
v-tooltip="{
content: $t('contribution.newPost'),
placement: 'left',
- delay: { show: 500 },
}"
class="post-add-button"
icon="plus"
diff --git a/webapp/pages/my-groups.vue b/webapp/pages/my-groups.vue
new file mode 100644
index 0000000000..7302b92ad4
--- /dev/null
+++ b/webapp/pages/my-groups.vue
@@ -0,0 +1,73 @@
+
+
+
+ {{ $t('group.myGroups') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/pages/post/_id.spec.js b/webapp/pages/post/_id.spec.js.old
similarity index 100%
rename from webapp/pages/post/_id.spec.js
rename to webapp/pages/post/_id.spec.js.old
diff --git a/webapp/pages/post/_id.vue b/webapp/pages/post/_id.vue
index 14cc65a4e1..c50ccc3033 100644
--- a/webapp/pages/post/_id.vue
+++ b/webapp/pages/post/_id.vue
@@ -1,16 +1,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
diff --git a/webapp/pages/post/_id/_slug/index.spec.js b/webapp/pages/post/_id/_slug/index.spec.js
index 4737386ef0..2dd4522b28 100644
--- a/webapp/pages/post/_id/_slug/index.spec.js
+++ b/webapp/pages/post/_id/_slug/index.spec.js
@@ -56,6 +56,10 @@ describe('PostSlug', () => {
},
$route: {
hash: '',
+ params: {
+ slug: 'slug',
+ id: 'id',
+ },
},
// If you are mocking the router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$router: {
diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue
index ff5c9a4982..f111a4a895 100644
--- a/webapp/pages/post/_id/_slug/index.vue
+++ b/webapp/pages/post/_id/_slug/index.vue
@@ -1,109 +1,129 @@
-
-
-
-
-
-
-
-
-
-
- {{ post.title }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {{ $t('post.viewPost.title') }}
+
+ {{ $t('post.viewPost.forGroup.title', { name: post.group.name }) }}
+
-
-
-
-
-
-
- {{ $t('settings.blocked-users.explanation.commenting-disabled') }}
-
- {{ $t('settings.blocked-users.explanation.commenting-explanation') }}
-
- {{ $t('site.faq') }}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ post.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('settings.blocked-users.explanation.commenting-disabled') }}
+
+ {{ $t('settings.blocked-users.explanation.commenting-explanation') }}
+
+ {{ $t('site.faq') }}
+
+
+
+
+
+
+
+
+
+
@@ -169,6 +189,31 @@ export default {
}, 50)
},
computed: {
+ routes() {
+ const { slug, id } = this.$route.params
+ return [
+ {
+ name: this.$t('common.post', null, 1),
+ path: `/post/${id}/${slug}`,
+ children: [
+ {
+ name: this.$t('common.comment', null, 2),
+ path: `/post/${id}/${slug}#comments`,
+ },
+ // TODO implement
+ /* {
+ name: this.$t('common.letsTalk'),
+ path: `/post/${id}/${slug}#lets-talk`
+ }, */
+ // TODO implement
+ /* {
+ name: this.$t('common.versus'),
+ path: `/post/${id}/${slug}#versus`
+ } */
+ ],
+ },
+ ]
+ },
menuModalsData() {
return postMenuModalsData(
// "this.post" may not always be defined at the beginning …
@@ -268,6 +313,11 @@ export default {
}