From d57c02c48e2ab857eac297f227f82dc4a5610a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 4 Oct 2021 13:03:04 +0200 Subject: [PATCH 001/374] Refine backend README.md --- backend/README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/README.md b/backend/README.md index 6d837856c2..3601dd2fa0 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 ``` @@ -72,6 +76,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 ``` @@ -79,7 +84,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 ``` @@ -98,12 +103,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 @@ -117,12 +124,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 ``` @@ -140,6 +149,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/ ``` @@ -147,6 +157,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 ``` @@ -156,6 +167,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/ ``` @@ -163,6 +175,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 ``` @@ -180,6 +193,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 ``` @@ -190,6 +204,7 @@ $ docker-compose exec backend yarn run test Run the unit tests: ```bash +# in backend/ while database is running $ yarn run test ``` From bf9dd205ca6a790c12550182f68ec43e99bf8076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 08:26:14 +0200 Subject: [PATCH 002/374] Implement GQL for groups --- backend/src/db/migrate/store.js | 3 +- backend/src/models/Groups.js | 133 ++++++++++ backend/src/models/index.js | 1 + .../schema/types/enum/GroupActionRadius.gql | 6 + backend/src/schema/types/enum/GroupType.gql | 5 + backend/src/schema/types/type/Group.gql | 249 ++++++++++++++++++ backend/src/schema/types/type/User.gql | 52 ++-- 7 files changed, 422 insertions(+), 27 deletions(-) create mode 100644 backend/src/models/Groups.js create mode 100644 backend/src/schema/types/enum/GroupActionRadius.gql create mode 100644 backend/src/schema/types/enum/GroupType.gql create mode 100644 backend/src/schema/types/type/Group.gql diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 377caf0b0e..7a8be0b945 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -62,8 +62,9 @@ class Store { await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices 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("user_fulltext_search",["Group"],["name", "slug", "description"])', // Wolle: check for 'name', 'slug', 'description' + '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/models/Groups.js b/backend/src/models/Groups.js new file mode 100644 index 0000000000..aa6f5767e0 --- /dev/null +++ b/backend/src/models/Groups.js @@ -0,0 +1,133 @@ +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 }, + avatar: { + type: 'relationship', + relationship: 'AVATAR_IMAGE', + target: 'Image', + direction: 'out', + }, + deleted: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + wasSeeded: 'boolean', // Wolle: used or needed? + locationName: { type: 'string', allow: [null] }, + about: { type: 'string', allow: [null, ''] }, // Wolle: null? + description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content + // Wolle: followedBy: { + // type: 'relationship', + // relationship: 'FOLLOWS', + // target: 'User', + // direction: 'in', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: correct this way? + members: { type: 'relationship', + relationship: 'MEMBERS', + target: 'User', + direction: 'out' + }, + // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, + createdAt: { + type: 'string', + isoDate: true, + default: () => new Date().toISOString() + }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + // Wolle: emoted: { + // type: 'relationships', + // relationship: 'EMOTED', + // target: 'Post', + // direction: 'out', + // properties: { + // emotion: { + // type: 'string', + // valid: ['happy', 'cry', 'surprised', 'angry', 'funny'], + // invalid: [null], + // }, + // }, + // eager: true, + // cascade: true, + // }, + // Wolle: blocked: { + // type: 'relationship', + // relationship: 'BLOCKED', + // target: 'User', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: muted: { + // type: 'relationship', + // relationship: 'MUTED', + // target: 'User', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: notifications: { + // type: 'relationship', + // relationship: 'NOTIFIED', + // target: 'User', + // direction: 'in', + // }, + // Wolle inviteCodes: { + // type: 'relationship', + // relationship: 'GENERATED', + // target: 'InviteCode', + // direction: 'out', + // }, + // Wolle: redeemedInviteCode: { + // type: 'relationship', + // relationship: 'REDEEMED', + // target: 'InviteCode', + // direction: 'out', + // }, + // Wolle: shouted: { + // type: 'relationship', + // relationship: 'SHOUTED', + // target: 'Post', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + isIn: { + type: 'relationship', + relationship: 'IS_IN', + target: 'Location', + direction: 'out', + }, + // Wolle: pinned: { + // type: 'relationship', + // relationship: 'PINNED', + // target: 'Post', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: showShoutsPublicly: { + // type: 'boolean', + // default: false, + // }, + // Wolle: sendNotificationEmails: { + // type: 'boolean', + // default: true, + // }, + // Wolle: locale: { + // type: 'string', + // allow: [null], + // }, +} 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/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql new file mode 100644 index 0000000000..afc4211337 --- /dev/null +++ b/backend/src/schema/types/enum/GroupActionRadius.gql @@ -0,0 +1,6 @@ +enum GroupActionRadius { + regional + national + continental + international +} 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..655a251d6e --- /dev/null +++ b/backend/src/schema/types/type/Group.gql @@ -0,0 +1,249 @@ +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 + # Wolle: needed? locale_asc + # locale_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") + + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") + locationName: String + about: String # goal + description: String + groupType: GroupType + actionRadius: GroupActionRadius + + categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") + + # Wolle: needed? + socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + + # Wolle: showShoutsPublicly: Boolean + # Wolle: sendNotificationEmails: Boolean + # Wolle: needed? locale: String + members: [User]! @relation(name: "MEMBERS", direction: "OUT") + membersCount: Int! + @cypher(statement: "MATCH (this)-[:MEMBERS]->(r:User) RETURN COUNT(DISTINCT r)") + + # Wolle: followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") + # Wolle: followedByCount: Int! + # @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)") + + # Wolle: inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT") + # Wolle: redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT") + + # Is the currently logged in user following that user? + # Wolle: followedByCurrentUser: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:FOLLOWS]-(u:User { id: $cypherParams.currentUserId}) + # RETURN COUNT(u) >= 1 + # """ + # ) + + # Wolle: isBlocked: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + # Wolle: blocked: Boolean! + # @cypher( + # statement: """ + # MATCH (this)-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + + # Wolle: isMuted: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:MUTED]-(user:User { id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + + # contributions: [WrittenPost]! + # contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! + # @cypher( + # statement: "MATCH (this)-[w:WROTE]->(p:Post) RETURN p as Post, w.timestamp as timestamp" + # ) + # Wolle: needed? + # contributions: [Post]! @relation(name: "WROTE", direction: "OUT") + # contributionsCount: Int! + # @cypher( + # statement: """ + # MATCH (this)-[:WROTE]->(r:Post) + # WHERE NOT r.deleted = true AND NOT r.disabled = true + # RETURN COUNT(r) + # """ + # ) + + # Wolle: comments: [Comment]! @relation(name: "WROTE", direction: "OUT") + # commentedCount: Int! + # @cypher( + # statement: "MATCH (this)-[:WROTE]->(:Comment)-[:COMMENTS]->(p:Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))" + # ) + + # Wolle: shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") + # shoutedCount: Int! + # @cypher( + # statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)" + # ) + + # Wolle: badges: [Badge]! @relation(name: "REWARDED", direction: "IN") + # badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") + + # Wolle: emotions: [EMOTED] +} + + +input _GroupFilter { + AND: [_GroupFilter!] + OR: [_GroupFilter!] + name_contains: String + about_contains: String + description_contains: String + slug_contains: String + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] + # Wolle: + # friends: _GroupFilter + # friends_not: _GroupFilter + # friends_in: [_GroupFilter!] + # friends_not_in: [_GroupFilter!] + # friends_some: _GroupFilter + # friends_none: _GroupFilter + # friends_single: _GroupFilter + # friends_every: _GroupFilter + # following: _GroupFilter + # following_not: _GroupFilter + # following_in: [_GroupFilter!] + # following_not_in: [_GroupFilter!] + # following_some: _GroupFilter + # following_none: _GroupFilter + # following_single: _GroupFilter + # following_every: _GroupFilter + # followedBy: _GroupFilter + # followedBy_not: _GroupFilter + # followedBy_in: [_GroupFilter!] + # followedBy_not_in: [_GroupFilter!] + # followedBy_some: _GroupFilter + # followedBy_none: _GroupFilter + # followedBy_single: _GroupFilter + # followedBy_every: _GroupFilter + # role_in: [UserGroup!] +} + +type Query { + Group( + id: ID + email: String # admins need to search for a user sometimes + name: String + slug: String + locationName: String + about: String + description: String + createdAt: String + updatedAt: String + first: Int + offset: Int + orderBy: [_GroupOrdering] + filter: _GroupFilter + ): [Group] + + availableGroupTypes: [GroupType]! + + # Wolle: + # availableRoles: [UserGroup]! + # mutedUsers: [User] + # blockedUsers: [User] + # isLoggedIn: Boolean! + # currentUser: User + # findUsers(query: String!,limit: Int = 10, filter: _GroupFilter): [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 + # """ + # ) +} + +# Wolle: enum Deletable { +# Post +# Comment +# } + +type Mutation { + CreateGroup ( + id: ID! + name: String + email: String + slug: String + avatar: ImageInput + locationName: String + about: String + description: String + # Wolle: add group settings + # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + ): Group + + UpdateUser ( + id: ID! + name: String + email: String + slug: String + avatar: ImageInput + locationName: String + about: String + description: String + # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + ): Group + + DeleteGroup(id: ID!): Group + + # Wolle: + # muteUser(id: ID!): User + # unmuteUser(id: ID!): User + # blockUser(id: ID!): User + # unblockUser(id: ID!): User + + # Wolle: switchUserRole(role: UserGroup!, id: ID!): User +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 772dedf6b3..aca08df0eb 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -156,19 +156,19 @@ input _UserFilter { type Query { User( - id: ID - email: String # admins need to search for a user sometimes - name: String - slug: String - role: UserGroup - 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: UserGroup + locationName: String + about: String + createdAt: String + updatedAt: String + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter ): [User] availableRoles: [UserGroup]! @@ -197,19 +197,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 + about: String + termsAndConditionsAgreedVersion: String + termsAndConditionsAgreedAt: String + allowEmbedIframes: Boolean + showShoutsPublicly: Boolean + sendNotificationEmails: Boolean + locale: String ): User DeleteUser(id: ID!, resource: [Deletable]): User From 52bffa426b798378a2eef7ba00c773b92361c182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 09:10:02 +0200 Subject: [PATCH 003/374] Fix linting --- backend/src/models/Groups.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/src/models/Groups.js b/backend/src/models/Groups.js index aa6f5767e0..b26f7779cb 100644 --- a/backend/src/models/Groups.js +++ b/backend/src/models/Groups.js @@ -26,16 +26,12 @@ export default { // }, // }, // Wolle: correct this way? - members: { type: 'relationship', - relationship: 'MEMBERS', - target: 'User', - direction: 'out' - }, + members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, createdAt: { type: 'string', isoDate: true, - default: () => new Date().toISOString() + default: () => new Date().toISOString(), }, updatedAt: { type: 'string', From 9632d0f8524a9868a984a54409cc2e68bd6a7d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 09:18:30 +0200 Subject: [PATCH 004/374] Fix file name from 'Groups.js' to singular 'Group.js' --- backend/src/models/{Groups.js => Group.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/models/{Groups.js => Group.js} (100%) diff --git a/backend/src/models/Groups.js b/backend/src/models/Group.js similarity index 100% rename from backend/src/models/Groups.js rename to backend/src/models/Group.js From f565e5fb6a042286c4f5eeed484011b0f24ca58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 16:20:11 +0200 Subject: [PATCH 005/374] Implement 'CreateGroup' with tests --- backend/.env.template | 2 + backend/src/config/index.js | 1 + backend/src/db/graphql/mutations.ts | 29 + backend/src/db/migrate/store.js | 2 +- backend/src/middleware/excerptMiddleware.js | 5 + .../src/middleware/permissionsMiddleware.js | 1 + backend/src/middleware/sluggifyMiddleware.js | 4 + backend/src/middleware/slugify/uniqueSlug.js | 1 + backend/src/models/Group.js | 11 +- backend/src/schema/resolvers/groups.js | 224 +++++++ backend/src/schema/resolvers/groups.spec.js | 610 ++++++++++++++++++ backend/src/schema/types/type/Group.gql | 19 +- webapp/.env.template | 1 + webapp/config/index.js | 1 + 14 files changed, 899 insertions(+), 12 deletions(-) create mode 100644 backend/src/db/graphql/mutations.ts create mode 100644 backend/src/schema/resolvers/groups.js create mode 100644 backend/src/schema/resolvers/groups.spec.js diff --git a/backend/.env.template b/backend/.env.template index 5858a5d1e3..dd46846a99 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -28,3 +28,5 @@ AWS_BUCKET= EMAIL_DEFAULT_SENDER="devops@ocelot.social" EMAIL_SUPPORT="devops@ocelot.social" + +CATEGORIES_ACTIVE=false diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 6ad8c578b8..7df780cfc2 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -86,6 +86,7 @@ const options = { ORGANIZATION_URL: emails.ORGANIZATION_LINK, PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false, INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true + CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, } // Check if all required configs are present diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts new file mode 100644 index 0000000000..a29cfa1123 --- /dev/null +++ b/backend/src/db/graphql/mutations.ts @@ -0,0 +1,29 @@ +import gql from 'graphql-tag' + +export const createGroupMutation = gql` + mutation ( + $id: ID, + $name: String!, + $slug: String, + $about: String, + $categoryIds: [ID] + ) { + CreateGroup( + id: $id + name: $name + slug: $slug + about: $about + categoryIds: $categoryIds + ) { + id + name + slug + about + disabled + deleted + owner { + name + } + } + } +` diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 7a8be0b945..78960be6b8 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -59,7 +59,7 @@ class Store { const session = driver.session() await createDefaultAdminUser(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("user_fulltext_search",["User"],["name", "slug"])', diff --git a/backend/src/middleware/excerptMiddleware.js b/backend/src/middleware/excerptMiddleware.js index 40a6a6ae40..cfaf7f1b05 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -2,6 +2,11 @@ import trunc from 'trunc-html' export default { Mutation: { + CreateGroup: async (resolve, root, args, context, info) => { + args.descriptionExcerpt = trunc(args.description, 120).html + const result = await resolve(root, args, context, info) + return result + }, CreatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html const result = await resolve(root, args, context, info) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index b10389f501..7e23cfe0ff 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -140,6 +140,7 @@ export default shield( Signup: or(publicRegistration, inviteRegistration, isAdmin), SignupVerification: allow, UpdateUser: onlyYourself, + CreateGroup: isAuthenticated, CreatePost: isAuthenticated, UpdatePost: isAuthor, DeletePost: isAuthor, diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 165235be9d..25c7c21a49 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -26,6 +26,10 @@ 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.title, 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) 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/models/Group.js b/backend/src/models/Group.js index b26f7779cb..651c2983e8 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -15,7 +15,8 @@ export default { wasSeeded: 'boolean', // Wolle: used or needed? locationName: { type: 'string', allow: [null] }, about: { type: 'string', allow: [null, ''] }, // Wolle: null? - description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content + description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content, wie bei Posts "content: { type: 'string', disallow: [null], min: 3 },"? + descriptionExcerpt: { type: 'string', allow: [null] }, // Wolle: followedBy: { // type: 'relationship', // relationship: 'FOLLOWS', @@ -25,8 +26,14 @@ export default { // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, // }, // }, + owner: { + type: 'relationship', + relationship: 'OWNS', + target: 'User', + direction: 'in', + }, // Wolle: correct this way? - members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, + // members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, createdAt: { type: 'string', diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js new file mode 100644 index 0000000000..0ed5d1356b --- /dev/null +++ b/backend/src/schema/resolvers/groups.js @@ -0,0 +1,224 @@ +import { v4 as uuid } from 'uuid' +// Wolle: import { neo4jgraphql } from 'neo4j-graphql-js' +// Wolle: import { isEmpty } from 'lodash' +import { UserInputError } from 'apollo-server' +import CONFIG from '../../config' +// Wolle: import { mergeImage, deleteImage } from './images/images' +import Resolver from './helpers/Resolver' +// Wolle: import { filterForMutedUsers } from './helpers/filterForMutedUsers' + +// Wolle: const maintainPinnedPosts = (params) => { +// const pinnedPostFilter = { pinned: true } +// if (isEmpty(params.filter)) { +// params.filter = { OR: [pinnedPostFilter, {}] } +// } else { +// params.filter = { OR: [pinnedPostFilter, { ...params.filter }] } +// } +// return params +// } + +export default { + // Wolle: Query: { + // Post: async (object, params, context, resolveInfo) => { + // 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 filterForMutedUsers(params, context) + // return neo4jgraphql(object, params, context, resolveInfo) + // }, + // PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { + // const { postId, data } = params + // const session = context.driver.session() + // const readTxResultPromise = session.readTransaction(async (transaction) => { + // const emotionsCountTransactionResponse = await transaction.run( + // ` + // MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() + // RETURN COUNT(DISTINCT emoted) as emotionsCount + // `, + // { postId, data }, + // ) + // return emotionsCountTransactionResponse.records.map( + // (record) => record.get('emotionsCount').low, + // ) + // }) + // try { + // const [emotionsCount] = await readTxResultPromise + // return emotionsCount + // } finally { + // session.close() + // } + // }, + // PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { + // const { postId } = params + // const session = context.driver.session() + // const readTxResultPromise = session.readTransaction(async (transaction) => { + // const emotionsTransactionResponse = await transaction.run( + // ` + // MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) + // RETURN collect(emoted.emotion) as emotion + // `, + // { userId: context.user.id, postId }, + // ) + // return emotionsTransactionResponse.records.map((record) => record.get('emotion')) + // }) + // try { + // const [emotions] = await readTxResultPromise + // return emotions + // } finally { + // session.close() + // } + // }, + // }, + Mutation: { + CreateGroup: async (_parent, params, context, _resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds + params.id = params.id || uuid() + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const categoriesCypher = + CONFIG.CATEGORIES_ACTIVE && categoryIds + ? `WITH group + 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 (group)<-[:OWNS]-(owner) + MERGE (group)<-[:ADMINISTERS]-(owner) + ${categoriesCypher} + RETURN group {.*} + `, + { userId: context.user.id, categoryIds, params }, + ) + const [group] = ownercreateGroupTransactionResponse.records.map((record) => + record.get('group'), + ) + return group + }) + try { + const group = await writeTxResultPromise + return group + } catch (e) { + if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + throw new UserInputError('Group with this slug already exists!') + throw new Error(e) + } finally { + session.close() + } + }, + // UpdatePost: async (_parent, params, context, _resolveInfo) => { + // const { categoryIds } = params + // const { image: imageInput } = params + // delete params.categoryIds + // 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 + // ` + + // if (categoryIds && categoryIds.length) { + // const cypherDeletePreviousRelations = ` + // MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) + // DELETE previousRelations + // RETURN post, category + // ` + + // await session.writeTransaction((transaction) => { + // return transaction.run(cypherDeletePreviousRelations, { params }) + // }) + + // updatePostCypher += ` + // UNWIND $categoryIds AS categoryId + // MATCH (category:Category {id: categoryId}) + // MERGE (post)-[:CATEGORIZED]->(category) + // WITH post + // ` + // } + + // updatePostCypher += `RETURN post {.*}` + // const updatePostVariables = { categoryIds, params } + // try { + // const writeTxResultPromise = session.writeTransaction(async (transaction) => { + // const updatePostTransactionResponse = await transaction.run( + // updatePostCypher, + // updatePostVariables, + // ) + // const [post] = updatePostTransactionResponse.records.map((record) => record.get('post')) + // await mergeImage(post, 'HERO_IMAGE', imageInput, { transaction }) + // return post + // }) + // const post = await writeTxResultPromise + // return post + // } finally { + // session.close() + // } + // }, + + // DeletePost: async (object, args, context, resolveInfo) => { + // const session = context.driver.session() + // const writeTxResultPromise = session.writeTransaction(async (transaction) => { + // const deletePostTransactionResponse = await transaction.run( + // ` + // MATCH (post:Post {id: $postId}) + // OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) + // SET post.deleted = TRUE + // SET post.content = 'UNAVAILABLE' + // SET post.contentExcerpt = 'UNAVAILABLE' + // SET post.title = 'UNAVAILABLE' + // SET comment.deleted = TRUE + // RETURN post {.*} + // `, + // { postId: args.id }, + // ) + // const [post] = deletePostTransactionResponse.records.map((record) => record.get('post')) + // await deleteImage(post, 'HERO_IMAGE', { transaction }) + // return post + // }) + // try { + // const post = await writeTxResultPromise + // return post + // } finally { + // session.close() + // } + }, + Group: { + ...Resolver('Group', { + // Wolle: undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'], + hasMany: { + // Wolle: tags: '-[:TAGGED]->(related:Tag)', + categories: '-[:CATEGORIZED]->(related:Category)', + }, + hasOne: { + owner: '<-[:OWNS]-(related:User)', + // Wolle: image: '-[:HERO_IMAGE]->(related:Image)', + }, + // Wolle: count: { + // contributionsCount: + // '-[:WROTE]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', + // }, + // Wolle: boolean: { + // shoutedByCurrentUser: + // 'MATCH(this)<-[:SHOUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1', + // viewedTeaserByCurrentUser: + // 'MATCH (this)<-[:VIEWED_TEASER]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', + // }, + }), + }, +} diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js new file mode 100644 index 0000000000..aba7181595 --- /dev/null +++ b/backend/src/schema/resolvers/groups.spec.js @@ -0,0 +1,610 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import { createGroupMutation } from '../../db/graphql/mutations' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' + +const driver = getDriver() +const neode = getNeode() + +// Wolle: let query +let mutate +let authenticatedUser +let user + +const categoryIds = ['cat9', 'cat4', 'cat15'] +let variables = {} + +beforeAll(async () => { + await cleanDatabase() + + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + // Wolle: query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() +}) + +beforeEach(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: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }), + neode.create('Category', { + id: 'cat4', + name: 'Environment & Nature', + icon: 'tree', + }), + neode.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + icon: 'shopping-cart', + }), + neode.create('Category', { + id: 'cat27', + name: 'Animal Protection', + icon: 'paw', + }), + ]) + authenticatedUser = null +}) + +// 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('Group', () => { +// describe('can be filtered', () => { +// let followedUser, happyPost, cryPost +// beforeEach(async () => { +// ;[followedUser] = await Promise.all([ +// Factory.build( +// 'user', +// { +// id: 'followed-by-me', +// name: 'Followed User', +// }, +// { +// email: 'followed@example.org', +// password: '1234', +// }, +// ), +// ]) +// ;[happyPost, cryPost] = await Promise.all([ +// Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), +// Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), +// Factory.build( +// 'post', +// { +// id: 'post-by-followed-user', +// }, +// { +// categoryIds: ['cat9'], +// author: followedUser, +// }, +// ), +// ]) +// }) + +// describe('no filter', () => { +// it('returns all posts', async () => { +// const postQueryNoFilters = gql` +// query Post($filter: _PostFilter) { +// Post(filter: $filter) { +// id +// } +// } +// ` +// const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] +// variables = { filter: {} } +// await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ +// data: { +// Post: expect.arrayContaining(expected), +// }, +// }) +// }) +// }) + +// /* it('by categories', async () => { +// const postQueryFilteredByCategories = gql` +// query Post($filter: _PostFilter) { +// Post(filter: $filter) { +// id +// categories { +// id +// } +// } +// } +// ` +// const expected = { +// data: { +// Post: [ +// { +// id: 'post-by-followed-user', +// categories: [{ id: 'cat9' }], +// }, +// ], +// }, +// } +// variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } +// await expect( +// query({ query: postQueryFilteredByCategories, variables }), +// ).resolves.toMatchObject(expected) +// }) */ + +// describe('by emotions', () => { +// const postQueryFilteredByEmotions = gql` +// query Post($filter: _PostFilter) { +// Post(filter: $filter) { +// id +// emotions { +// emotion +// } +// } +// } +// ` + +// it('filters by single emotion', async () => { +// const expected = { +// data: { +// Post: [ +// { +// id: 'happy-post', +// emotions: [{ emotion: 'happy' }], +// }, +// ], +// }, +// } +// await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) +// variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } +// await expect( +// query({ query: postQueryFilteredByEmotions, variables }), +// ).resolves.toMatchObject(expected) +// }) + +// it('filters by multiple emotions', async () => { +// const expected = [ +// { +// id: 'happy-post', +// emotions: [{ emotion: 'happy' }], +// }, +// { +// id: 'cry-post', +// emotions: [{ emotion: 'cry' }], +// }, +// ] +// await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) +// await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) +// variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } +// await expect( +// query({ query: postQueryFilteredByEmotions, variables }), +// ).resolves.toMatchObject({ +// data: { +// Post: expect.arrayContaining(expected), +// }, +// errors: undefined, +// }) +// }) +// }) + +// it('by followed-by', async () => { +// const postQueryFilteredByUsersFollowed = gql` +// query Post($filter: _PostFilter) { +// Post(filter: $filter) { +// id +// author { +// id +// name +// } +// } +// } +// ` + +// await user.relateTo(followedUser, 'following') +// variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } +// await expect( +// query({ query: postQueryFilteredByUsersFollowed, variables }), +// ).resolves.toMatchObject({ +// data: { +// Post: [ +// { +// id: 'post-by-followed-user', +// author: { id: 'followed-by-me', name: 'Followed User' }, +// }, +// ], +// }, +// errors: undefined, +// }) +// }) +// }) +// }) + +describe('CreateGroup', () => { + beforeEach(() => { + variables = { + ...variables, + id: 'g589', + name: 'The Best Group', + slug: 'the-best-group', + about: 'We will change the world!', + categoryIds, + } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ mutation: createGroupMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('creates a group', async () => { + const expected = { + data: { + CreateGroup: { + // Wolle: id: 'g589', + name: 'The Best Group', + slug: 'the-best-group', + about: 'We will change the world!', + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('assigns the authenticated user as owner', async () => { + const expected = { + data: { + CreateGroup: { + name: 'The Best Group', + owner: { + name: 'TestUser', + }, + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('`disabled` and `deleted` default to `false`', async () => { + const expected = { data: { CreateGroup: { disabled: false, deleted: false } } } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) +}) + +// describe('UpdatePost', () => { +// let author, newlyCreatedPost +// const updatePostMutation = gql` +// mutation ($id: ID!, $title: String!, $content: String!, $image: ImageInput) { +// UpdatePost(id: $id, title: $title, content: $content, image: $image) { +// id +// title +// content +// author { +// name +// slug +// } +// createdAt +// updatedAt +// } +// } +// ` +// beforeEach(async () => { +// author = await Factory.build('user', { slug: 'the-author' }) +// newlyCreatedPost = await Factory.build( +// 'post', +// { +// id: 'p9876', +// title: 'Old title', +// content: 'Old content', +// }, +// { +// author, +// categoryIds, +// }, +// ) + +// variables = { +// id: 'p9876', +// title: 'New title', +// content: 'New content', +// } +// }) + +// describe('unauthenticated', () => { +// it('throws authorization error', async () => { +// authenticatedUser = null +// expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ +// errors: [{ message: 'Not Authorised!' }], +// data: { UpdatePost: null }, +// }) +// }) +// }) + +// describe('authenticated but not the author', () => { +// beforeEach(async () => { +// authenticatedUser = await user.toJson() +// }) + +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: updatePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated as author', () => { +// beforeEach(async () => { +// authenticatedUser = await author.toJson() +// }) + +// it('updates a post', async () => { +// const expected = { +// data: { UpdatePost: { id: 'p9876', content: 'New content' } }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// it('updates a post, but maintains non-updated attributes', async () => { +// const expected = { +// data: { +// UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// it('updates the updatedAt attribute', async () => { +// newlyCreatedPost = await newlyCreatedPost.toJson() +// const { +// data: { UpdatePost }, +// } = await mutate({ mutation: updatePostMutation, variables }) +// expect(newlyCreatedPost.updatedAt).toBeTruthy() +// expect(Date.parse(newlyCreatedPost.updatedAt)).toEqual(expect.any(Number)) +// expect(UpdatePost.updatedAt).toBeTruthy() +// expect(Date.parse(UpdatePost.updatedAt)).toEqual(expect.any(Number)) +// expect(newlyCreatedPost.updatedAt).not.toEqual(UpdatePost.updatedAt) +// }) + +// /* describe('no new category ids provided for update', () => { +// it('resolves and keeps current categories', async () => { +// const expected = { +// data: { +// UpdatePost: { +// id: 'p9876', +// categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]), +// }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) */ + +// /* describe('given category ids', () => { +// beforeEach(() => { +// variables = { ...variables, categoryIds: ['cat27'] } +// }) + +// it('updates categories of a post', async () => { +// const expected = { +// data: { +// UpdatePost: { +// id: 'p9876', +// categories: expect.arrayContaining([{ id: 'cat27' }]), +// }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) */ + +// describe('params.image', () => { +// describe('is object', () => { +// beforeEach(() => { +// variables = { ...variables, image: { sensitive: true } } +// }) +// it('updates the image', async () => { +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeTruthy() +// }) +// }) + +// describe('is null', () => { +// beforeEach(() => { +// variables = { ...variables, image: null } +// }) +// it('deletes the image', async () => { +// await expect(neode.all('Image')).resolves.toHaveLength(6) +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.all('Image')).resolves.toHaveLength(5) +// }) +// }) + +// describe('is undefined', () => { +// beforeEach(() => { +// delete variables.image +// }) +// it('keeps the image unchanged', async () => { +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// }) +// }) +// }) +// }) +// }) + +// describe('DeletePost', () => { +// let author +// const deletePostMutation = gql` +// mutation ($id: ID!) { +// DeletePost(id: $id) { +// id +// deleted +// content +// contentExcerpt +// image { +// url +// } +// comments { +// deleted +// content +// contentExcerpt +// } +// } +// } +// ` + +// beforeEach(async () => { +// author = await Factory.build('user') +// await Factory.build( +// 'post', +// { +// id: 'p4711', +// title: 'I will be deleted', +// content: 'To be deleted', +// }, +// { +// image: Factory.build('image', { +// url: 'path/to/some/image', +// }), +// author, +// categoryIds, +// }, +// ) +// variables = { ...variables, id: 'p4711' } +// }) + +// describe('unauthenticated', () => { +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: deletePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated but not the author', () => { +// beforeEach(async () => { +// authenticatedUser = await user.toJson() +// }) + +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: deletePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated as author', () => { +// beforeEach(async () => { +// authenticatedUser = await author.toJson() +// }) + +// it('marks the post as deleted and blacks out attributes', async () => { +// const expected = { +// data: { +// DeletePost: { +// id: 'p4711', +// deleted: true, +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// image: null, +// comments: [], +// }, +// }, +// } +// await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// describe('if there are comments on the post', () => { +// beforeEach(async () => { +// await Factory.build( +// 'comment', +// { +// content: 'to be deleted comment content', +// contentExcerpt: 'to be deleted comment content', +// }, +// { +// postId: 'p4711', +// }, +// ) +// }) + +// it('marks the comments as deleted', async () => { +// const expected = { +// data: { +// DeletePost: { +// id: 'p4711', +// deleted: true, +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// image: null, +// comments: [ +// { +// deleted: true, +// // Should we black out the comment content in the database, too? +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// }, +// ], +// }, +// }, +// } +// await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) +// }) +// }) diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 655a251d6e..2dbe3c8c6b 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -21,7 +21,7 @@ type Group { id: ID! name: String # title slug: String! - + createdAt: String updatedAt: String deleted: Boolean @@ -41,12 +41,14 @@ type Group { # Wolle: needed? socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + owner: User @relation(name: "OWNS", direction: "IN") + # Wolle: showShoutsPublicly: Boolean # Wolle: sendNotificationEmails: Boolean # Wolle: needed? locale: String - members: [User]! @relation(name: "MEMBERS", direction: "OUT") - membersCount: Int! - @cypher(statement: "MATCH (this)-[:MEMBERS]->(r:User) RETURN COUNT(DISTINCT r)") + # members: [User]! @relation(name: "MEMBERS", direction: "OUT") + # membersCount: Int! + # @cypher(statement: "MATCH (this)-[:MEMBERS]->(r:User) RETURN COUNT(DISTINCT r)") # Wolle: followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") # Wolle: followedByCount: Int! @@ -207,14 +209,14 @@ type Query { type Mutation { CreateGroup ( - id: ID! - name: String - email: String + id: ID + name: String! slug: String avatar: ImageInput locationName: String about: String description: String + categoryIds: [ID] # Wolle: add group settings # Wolle: # showShoutsPublicly: Boolean @@ -222,10 +224,9 @@ type Mutation { # locale: String ): Group - UpdateUser ( + UpdateGroup ( id: ID! name: String - email: String slug: String avatar: ImageInput locationName: String diff --git a/webapp/.env.template b/webapp/.env.template index 7373255a91..9776fcea29 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -4,3 +4,4 @@ PUBLIC_REGISTRATION=false INVITE_REGISTRATION=true WEBSOCKETS_URI=ws://localhost:3000/api/graphql GRAPHQL_URI=http://localhost:4000/ +CATEGORIES_ACTIVE=false diff --git a/webapp/config/index.js b/webapp/config/index.js index 00df85baca..db030e9298 100644 --- a/webapp/config/index.js +++ b/webapp/config/index.js @@ -33,6 +33,7 @@ const options = { // Cookies COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly + CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, } const CONFIG = { From 4f1646509b40a9ddc21b30dadd7056b10ffaa91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Tue, 2 Aug 2022 16:25:46 +0200 Subject: [PATCH 006/374] Fix Group name slugification --- backend/src/middleware/sluggifyMiddleware.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 25c7c21a49..2a965c87f0 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -27,7 +27,7 @@ export default { return resolve(root, args, context, info) }, CreateGroup: async (resolve, root, args, context, info) => { - args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Group'))) + args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Group'))) return resolve(root, args, context, info) }, CreatePost: async (resolve, root, args, context, info) => { From da2c7dc6845cd100d8b9d3e0a6b0ef4a7426cbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 07:29:51 +0200 Subject: [PATCH 007/374] Add migration with fulltext indeces and unique keys for groups --- backend/src/db/migrate/store.js | 1 - ...text_indices_and_unique_keys_for_groups.js | 66 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 78960be6b8..938ebef020 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -63,7 +63,6 @@ class Store { return Promise.all( [ 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])', - 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["Group"],["name", "slug", "description"])', // Wolle: check for 'name', 'slug', 'description' '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() + } +} From fc20143a6566df4e81dd437c833f3e606abe31dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 07:51:29 +0200 Subject: [PATCH 008/374] Test groups creation in slugifyMiddleware --- backend/src/db/graphql/mutations.ts | 30 +++ .../src/middleware/slugifyMiddleware.spec.js | 246 ++++++++++++++---- backend/src/models/User.spec.js | 2 +- backend/src/schema/resolvers/groups.spec.js | 4 +- 4 files changed, 222 insertions(+), 60 deletions(-) diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts index a29cfa1123..5fc554ee2b 100644 --- a/backend/src/db/graphql/mutations.ts +++ b/backend/src/db/graphql/mutations.ts @@ -27,3 +27,33 @@ export const createGroupMutation = gql` } } ` + +export const createPostMutation = gql` + mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { + slug + } + } +` + +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 + ) { + slug + } + } +` diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 7c6f18ab19..fbf03c6755 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -1,8 +1,9 @@ -import Factory, { cleanDatabase } from '../db/factories' -import { gql } from '../helpers/jest' +import gql from 'graphql-tag' import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../db/factories' +import { createPostMutation, createGroupMutation, signupVerificationMutation } from '../db/graphql/mutations' let mutate let authenticatedUser @@ -57,15 +58,128 @@ afterEach(async () => { }) describe('slugifyMiddleware', () => { - describe('CreatePost', () => { + describe('CreateGroup', () => { 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 - } + + beforeEach(() => { + variables = { + ...variables, + name: 'The Best Group', + about: 'Some about', + categoryIds, } - ` + }) + + describe('if slug not exists', () => { + it('generates a slug based on name', async () => { + await expect( + mutate({ + mutation: createGroupMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + slug: 'the-best-group', + }, + }, + }) + }) + + 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', + }, + }, + }) + }) + }) + + 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 () => { + variables = { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + } + await expect( + mutate({ + mutation: createGroupMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + slug: 'pre-existing-group-1', + }, + }, + }) + }) + + describe('but if the client specifies a slug', () => { + it('rejects CreateGroup', async (done) => { + variables = { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + slug: 'pre-existing-group', + } + try { + await expect( + mutate({ mutation: createGroupMutation, variables }), + ).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', () => { + const categoryIds = ['cat9'] beforeEach(() => { variables = { @@ -76,18 +190,38 @@ 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', + }, }, - }, + }) + }) + + 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', + }, + }, + }) }) }) @@ -160,7 +294,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 `) } }) @@ -169,28 +303,6 @@ 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 - } - } - ` - beforeEach(() => { variables = { ...variables, @@ -211,21 +323,41 @@ 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', + }, }, - }, + }) }) - }) + 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', + }, + }, + }) + }) + }) + describe('if slug exists', () => { beforeEach(async () => { await Factory.build('user', { @@ -237,7 +369,7 @@ describe('slugifyMiddleware', () => { it('chooses another slug', async () => { await expect( mutate({ - mutation, + mutation: signupVerificationMutation, variables, }), ).resolves.toMatchObject({ @@ -260,7 +392,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/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/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index aba7181595..8d8c7c3d8c 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -250,7 +250,7 @@ describe('CreateGroup', () => { ...variables, id: 'g589', name: 'The Best Group', - slug: 'the-best-group', + slug: 'the-group', about: 'We will change the world!', categoryIds, } @@ -274,7 +274,7 @@ describe('CreateGroup', () => { CreateGroup: { // Wolle: id: 'g589', name: 'The Best Group', - slug: 'the-best-group', + slug: 'the-group', about: 'We will change the world!', }, }, From ea0223b6f2a9f19c74307d28612e0cd16f192628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 07:59:00 +0200 Subject: [PATCH 009/374] Rename 'UserGroup' to 'UserRole' --- backend/src/schema/types/type/Group.gql | 34 ++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 2dbe3c8c6b..310df9dbc3 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -160,7 +160,7 @@ input _GroupFilter { # followedBy_none: _GroupFilter # followedBy_single: _GroupFilter # followedBy_every: _GroupFilter - # role_in: [UserGroup!] + # role_in: [UserRole!] } type Query { @@ -183,7 +183,7 @@ type Query { availableGroupTypes: [GroupType]! # Wolle: - # availableRoles: [UserGroup]! + # availableRoles: [UserRole]! # mutedUsers: [User] # blockedUsers: [User] # isLoggedIn: Boolean! @@ -208,7 +208,7 @@ type Query { # } type Mutation { - CreateGroup ( + CreateGroup( id: ID name: String! slug: String @@ -217,14 +217,14 @@ type Mutation { about: String description: String categoryIds: [ID] - # Wolle: add group settings - # Wolle: - # showShoutsPublicly: Boolean - # sendNotificationEmails: Boolean - # locale: String - ): Group - - UpdateGroup ( + ): # Wolle: add group settings + # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + Group + + UpdateGroup( id: ID! name: String slug: String @@ -232,11 +232,11 @@ type Mutation { locationName: String about: String description: String - # Wolle: - # showShoutsPublicly: Boolean - # sendNotificationEmails: Boolean - # locale: String - ): Group + ): # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + Group DeleteGroup(id: ID!): Group @@ -246,5 +246,5 @@ type Mutation { # blockUser(id: ID!): User # unblockUser(id: ID!): User - # Wolle: switchUserRole(role: UserGroup!, id: ID!): User + # Wolle: switchUserRole(role: UserRole!, id: ID!): User } From 2c402ba25bd20e71e7997d712bd85423b6bc359f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 08:22:53 +0200 Subject: [PATCH 010/374] Fix linting --- backend/src/middleware/slugifyMiddleware.spec.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index fbf03c6755..44701970d7 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -1,9 +1,12 @@ -import gql from 'graphql-tag' import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../db/factories' -import { createPostMutation, createGroupMutation, signupVerificationMutation } from '../db/graphql/mutations' +import { + createPostMutation, + createGroupMutation, + signupVerificationMutation, +} from '../db/graphql/mutations' let mutate let authenticatedUser @@ -357,7 +360,7 @@ describe('slugifyMiddleware', () => { }) }) }) - + describe('if slug exists', () => { beforeEach(async () => { await Factory.build('user', { From cf31f3c1d6ed25cd516aaf8f810a7fb1350c324d Mon Sep 17 00:00:00 2001 From: ogerly Date: Wed, 3 Aug 2022 10:26:08 +0200 Subject: [PATCH 011/374] preparation for creating a new group --- webapp/components/GroupForm/GroupForm.vue | 272 ++++++++++++++++++ webapp/components/GroupTeaser/GroupTeaser.vue | 24 ++ webapp/locales/de.json | 3 + webapp/locales/en.json | 3 + webapp/pages/group/create.vue | 18 ++ webapp/pages/my-groups.vue | 16 ++ 6 files changed, 336 insertions(+) create mode 100644 webapp/components/GroupForm/GroupForm.vue create mode 100644 webapp/components/GroupTeaser/GroupTeaser.vue create mode 100644 webapp/pages/group/create.vue create mode 100644 webapp/pages/my-groups.vue diff --git a/webapp/components/GroupForm/GroupForm.vue b/webapp/components/GroupForm/GroupForm.vue new file mode 100644 index 0000000000..76d659b961 --- /dev/null +++ b/webapp/components/GroupForm/GroupForm.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/webapp/components/GroupTeaser/GroupTeaser.vue b/webapp/components/GroupTeaser/GroupTeaser.vue new file mode 100644 index 0000000000..e9b3ba670c --- /dev/null +++ b/webapp/components/GroupTeaser/GroupTeaser.vue @@ -0,0 +1,24 @@ + diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 297daa511b..1628978839 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -366,6 +366,9 @@ "follow": "Folgen", "following": "Folge Ich" }, + "group": { + "newGroup":"Erstelle eine neue Gruppe" + }, "hashtags-filter": { "clearSearch": "Suche löschen", "hashtag-search": "Suche nach #{hashtag}", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 8499b0290c..4043eefd85 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -366,6 +366,9 @@ "follow": "Follow", "following": "Following" }, + "group": { + "newGroup":"Create a new Group" + }, "hashtags-filter": { "clearSearch": "Clear search", "hashtag-search": "Searching for #{hashtag}", diff --git a/webapp/pages/group/create.vue b/webapp/pages/group/create.vue new file mode 100644 index 0000000000..6b52167f2a --- /dev/null +++ b/webapp/pages/group/create.vue @@ -0,0 +1,18 @@ + + + diff --git a/webapp/pages/my-groups.vue b/webapp/pages/my-groups.vue new file mode 100644 index 0000000000..386bb1e51b --- /dev/null +++ b/webapp/pages/my-groups.vue @@ -0,0 +1,16 @@ + + From 45558f06dd4ef948ff785aa0a065677eac5f7829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 11:49:46 +0200 Subject: [PATCH 012/374] Add comment about faking the 'gql' tag in the backend --- backend/src/helpers/jest.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/helpers/jest.js b/backend/src/helpers/jest.js index 201d68c141..14317642b5 100644 --- a/backend/src/helpers/jest.js +++ b/backend/src/helpers/jest.js @@ -1,3 +1,6 @@ +// TODO: can be replaced with, which is no a fake: +// import gql from 'graphql-tag' + //* This is a fake ES2015 template string, just to benefit of syntax // highlighting of `gql` template strings in certain editors. export function gql(strings) { From 520598c89770044534a1c64f67be593a76b3b861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 11:50:56 +0200 Subject: [PATCH 013/374] Implement 'description', 'groupType', and 'actionRadius' in 'CreateGroup' --- backend/src/db/graphql/mutations.ts | 9 ++++ .../src/middleware/slugifyMiddleware.spec.js | 8 +++ backend/src/models/Group.js | 54 +++++++++++-------- backend/src/schema/resolvers/groups.spec.js | 4 +- backend/src/schema/types/type/Group.gql | 23 ++++---- 5 files changed, 64 insertions(+), 34 deletions(-) diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts index 5fc554ee2b..c49856f2a1 100644 --- a/backend/src/db/graphql/mutations.ts +++ b/backend/src/db/graphql/mutations.ts @@ -6,6 +6,9 @@ export const createGroupMutation = gql` $name: String!, $slug: String, $about: String, + $description: String!, + $groupType: GroupType!, + $actionRadius: GroupActionRadius!, $categoryIds: [ID] ) { CreateGroup( @@ -13,12 +16,18 @@ export const createGroupMutation = gql` name: $name slug: $slug about: $about + description: $description + groupType: $groupType + actionRadius: $actionRadius categoryIds: $categoryIds ) { id name slug about + description + groupType + actionRadius disabled deleted owner { diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 44701970d7..af6ff25b0f 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -69,6 +69,9 @@ describe('slugifyMiddleware', () => { ...variables, name: 'The Best Group', about: 'Some about', + description: 'Some description', + groupType: 'closed', + actionRadius: 'national', categoryIds, } }) @@ -83,7 +86,12 @@ describe('slugifyMiddleware', () => { ).resolves.toMatchObject({ data: { CreateGroup: { + name: 'The Best Group', slug: 'the-best-group', + about: 'Some about', + description: 'Some description', + groupType: 'closed', + actionRadius: 'national', }, }, }) diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js index 651c2983e8..53b02fbec5 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -4,19 +4,44 @@ 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', }, - deleted: { type: 'boolean', default: false }, - disabled: { type: 'boolean', default: false }, - wasSeeded: 'boolean', // Wolle: used or needed? - locationName: { type: 'string', allow: [null] }, - about: { type: 'string', allow: [null, ''] }, // Wolle: null? - description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content, wie bei Posts "content: { type: 'string', disallow: [null], min: 3 },"? + + 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' }, + + locationName: { type: 'string', allow: [null] }, + + wasSeeded: 'boolean', // Wolle: used or needed? + owner: { + type: 'relationship', + relationship: 'OWNS', + target: 'User', + direction: 'in', + }, // Wolle: followedBy: { // type: 'relationship', // relationship: 'FOLLOWS', @@ -26,26 +51,9 @@ export default { // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, // }, // }, - owner: { - type: 'relationship', - relationship: 'OWNS', - target: 'User', - direction: 'in', - }, // Wolle: correct this way? // members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, - createdAt: { - type: 'string', - isoDate: true, - default: () => new Date().toISOString(), - }, - updatedAt: { - type: 'string', - isoDate: true, - required: true, - default: () => new Date().toISOString(), - }, // Wolle: emoted: { // type: 'relationships', // relationship: 'EMOTED', diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 8d8c7c3d8c..4932c2c5e7 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -252,6 +252,9 @@ describe('CreateGroup', () => { name: 'The Best Group', slug: 'the-group', about: 'We will change the world!', + description: 'Some description', + groupType: 'public', + actionRadius: 'regional', categoryIds, } }) @@ -272,7 +275,6 @@ describe('CreateGroup', () => { const expected = { data: { CreateGroup: { - // Wolle: id: 'g589', name: 'The Best Group', slug: 'the-group', about: 'We will change the world!', diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 310df9dbc3..ee71f3e1f1 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -19,27 +19,28 @@ enum _GroupOrdering { type Group { id: ID! - name: String # title + name: String! # title slug: String! - createdAt: String - updatedAt: String + createdAt: String! + updatedAt: String! deleted: Boolean disabled: Boolean avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT") + about: String # goal + description: String! + groupType: GroupType! + actionRadius: GroupActionRadius! + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String - about: String # goal - description: String - groupType: GroupType - actionRadius: GroupActionRadius categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") # Wolle: needed? - socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + # socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") owner: User @relation(name: "OWNS", direction: "IN") @@ -213,10 +214,12 @@ type Mutation { name: String! slug: String avatar: ImageInput - locationName: String about: String - description: String + description: String! + groupType: GroupType! + actionRadius: GroupActionRadius! categoryIds: [ID] + locationName: String ): # Wolle: add group settings # Wolle: # showShoutsPublicly: Boolean From cc44ee8ebcf6157172d92d2267d1e4607e7df73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 13:12:50 +0200 Subject: [PATCH 014/374] Refactor relations ':OWNS' and ':ADMINISTERS' to ':MEMBER_OF' with properties --- backend/src/db/graphql/mutations.ts | 7 +- backend/src/models/Group.js | 14 +- backend/src/schema/resolvers/groups.js | 128 ++++++++---------- backend/src/schema/resolvers/groups.spec.js | 7 +- .../src/schema/types/enum/GroupMemberRole.gql | 6 + backend/src/schema/types/type/Group.gql | 15 +- backend/src/schema/types/type/MEMBER_OF.gql | 5 + 7 files changed, 94 insertions(+), 88 deletions(-) create mode 100644 backend/src/schema/types/enum/GroupMemberRole.gql create mode 100644 backend/src/schema/types/type/MEMBER_OF.gql diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts index c49856f2a1..4f07e0f1e7 100644 --- a/backend/src/db/graphql/mutations.ts +++ b/backend/src/db/graphql/mutations.ts @@ -30,9 +30,10 @@ export const createGroupMutation = gql` actionRadius disabled deleted - owner { - name - } + myRole + # Wolle: owner { + # name + # } } } ` diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js index 53b02fbec5..0cec02bf8d 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -33,15 +33,17 @@ export default { groupType: { type: 'string', default: 'public' }, actionRadius: { type: 'string', default: 'regional' }, + myRole: { type: 'string', default: 'pending' }, + locationName: { type: 'string', allow: [null] }, wasSeeded: 'boolean', // Wolle: used or needed? - owner: { - type: 'relationship', - relationship: 'OWNS', - target: 'User', - direction: 'in', - }, + // Wolle: owner: { + // type: 'relationship', + // relationship: 'OWNS', + // target: 'User', + // direction: 'in', + // }, // Wolle: followedBy: { // type: 'relationship', // relationship: 'FOLLOWS', diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 0ed5d1356b..b202c60372 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -18,63 +18,47 @@ import Resolver from './helpers/Resolver' // } export default { - // Wolle: Query: { - // Post: async (object, params, context, resolveInfo) => { - // 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 filterForMutedUsers(params, context) - // return neo4jgraphql(object, params, context, resolveInfo) - // }, - // PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { - // const { postId, data } = params - // const session = context.driver.session() - // const readTxResultPromise = session.readTransaction(async (transaction) => { - // const emotionsCountTransactionResponse = await transaction.run( - // ` - // MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() - // RETURN COUNT(DISTINCT emoted) as emotionsCount - // `, - // { postId, data }, - // ) - // return emotionsCountTransactionResponse.records.map( - // (record) => record.get('emotionsCount').low, - // ) - // }) - // try { - // const [emotionsCount] = await readTxResultPromise - // return emotionsCount - // } finally { - // session.close() - // } - // }, - // PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { - // const { postId } = params - // const session = context.driver.session() - // const readTxResultPromise = session.readTransaction(async (transaction) => { - // const emotionsTransactionResponse = await transaction.run( - // ` - // MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) - // RETURN collect(emoted.emotion) as emotion - // `, - // { userId: context.user.id, postId }, - // ) - // return emotionsTransactionResponse.records.map((record) => record.get('emotion')) - // }) - // try { - // const [emotions] = await readTxResultPromise - // return emotions - // } finally { - // session.close() - // } - // }, - // }, + Query: { + // Wolle: Post: async (object, params, context, resolveInfo) => { + // params = await filterForMutedUsers(params, context) + // // params = await maintainPinnedPosts(params) + // return neo4jgraphql(object, params, context, resolveInfo) + // }, + // Group: async (object, params, context, resolveInfo) => { + // // const { email } = params + // const session = context.driver.session() + // const readTxResultPromise = session.readTransaction(async (txc) => { + // const result = await txc.run( + // ` + // MATCH (user:User {id: $userId})-[:MEMBER_OF]->(group:Group) + // RETURN properties(group) AS inviteCodes + // `, + // { + // userId: context.user.id, + // }, + // ) + // return result.records.map((record) => record.get('inviteCodes')) + // }) + // if (email) { + // try { + // session = context.driver.session() + // const readTxResult = await session.readTransaction((txc) => { + // const result = txc.run( + // ` + // MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $args.email}) + // RETURN user`, + // { args }, + // ) + // return result + // }) + // return readTxResult.records.map((r) => r.get('user').properties) + // } finally { + // session.close() + // } + // } + // return neo4jgraphql(object, args, context, resolveInfo) + // }, + }, Mutation: { CreateGroup: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params @@ -84,12 +68,14 @@ export default { const writeTxResultPromise = session.writeTransaction(async (transaction) => { const categoriesCypher = CONFIG.CATEGORIES_ACTIVE && categoryIds - ? `WITH group - UNWIND $categoryIds AS categoryId - MATCH (category:Category {id: categoryId}) - MERGE (group)-[:CATEGORIZED]->(category)` + ? ` + WITH group, membership + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (group)-[:CATEGORIZED]->(category) + ` : '' - const ownercreateGroupTransactionResponse = await transaction.run( + const ownerCreateGroupTransactionResponse = await transaction.run( ` CREATE (group:Group) SET group += $params @@ -97,14 +83,16 @@ export default { SET group.updatedAt = toString(datetime()) WITH group MATCH (owner:User {id: $userId}) - MERGE (group)<-[:OWNS]-(owner) - MERGE (group)<-[:ADMINISTERS]-(owner) + MERGE (owner)-[membership:MEMBER_OF]->(group) + SET membership.createdAt = toString(datetime()) + SET membership.updatedAt = toString(datetime()) + SET membership.role = 'owner' ${categoriesCypher} - RETURN group {.*} + RETURN group {.*, myRole: membership.role} `, { userId: context.user.id, categoryIds, params }, ) - const [group] = ownercreateGroupTransactionResponse.records.map((record) => + const [group] = ownerCreateGroupTransactionResponse.records.map((record) => record.get('group'), ) return group @@ -205,10 +193,10 @@ export default { // Wolle: tags: '-[:TAGGED]->(related:Tag)', categories: '-[:CATEGORIZED]->(related:Category)', }, - hasOne: { - owner: '<-[:OWNS]-(related:User)', - // Wolle: image: '-[:HERO_IMAGE]->(related:Image)', - }, + // hasOne: { + // owner: '<-[:OWNS]-(related:User)', + // // Wolle: image: '-[:HERO_IMAGE]->(related:Image)', + // }, // Wolle: count: { // contributionsCount: // '-[:WROTE]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 4932c2c5e7..17fc4b1da7 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -292,9 +292,10 @@ describe('CreateGroup', () => { data: { CreateGroup: { name: 'The Best Group', - owner: { - name: 'TestUser', - }, + myRole: 'owner', + // Wolle: owner: { + // name: 'TestUser', + // }, }, }, errors: undefined, 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/type/Group.gql b/backend/src/schema/types/type/Group.gql index ee71f3e1f1..72ac9b57ae 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -39,10 +39,12 @@ type Group { categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") + myRole: GroupMemberRole # if 'null' then the current user is no member + # Wolle: needed? # socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") - owner: User @relation(name: "OWNS", direction: "IN") + # Wolle: owner: User @relation(name: "OWNS", direction: "IN") # Wolle: showShoutsPublicly: Boolean # Wolle: sendNotificationEmails: Boolean @@ -129,9 +131,12 @@ input _GroupFilter { AND: [_GroupFilter!] OR: [_GroupFilter!] name_contains: String + slug_contains: String about_contains: String description_contains: String - slug_contains: String + groupType_in: [GroupType!] + actionRadius_in: [GroupActionRadius!] + myRole_in: [GroupMemberRole!] id: ID id_not: ID id_in: [ID!] @@ -161,20 +166,18 @@ input _GroupFilter { # followedBy_none: _GroupFilter # followedBy_single: _GroupFilter # followedBy_every: _GroupFilter - # role_in: [UserRole!] } type Query { Group( id: ID - email: String # admins need to search for a user sometimes name: String slug: String + createdAt: String + updatedAt: String locationName: String about: String description: String - createdAt: String - updatedAt: String first: Int offset: Int orderBy: [_GroupOrdering] 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! +} From 94411648fd23feb74564f978c8e6988829de196f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 13:29:49 +0200 Subject: [PATCH 015/374] Implement 'Group' query, first step --- .../src/middleware/permissionsMiddleware.js | 1 + backend/src/schema/resolvers/groups.js | 64 ++++++++----------- 2 files changed, 28 insertions(+), 37 deletions(-) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 7e23cfe0ff..99dcfc0cde 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -114,6 +114,7 @@ export default shield( reports: isModerator, statistics: allow, currentUser: allow, + Group: isAuthenticated, Post: allow, profilePagePosts: allow, Comment: allow, diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index b202c60372..9a55c9f4df 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -24,40 +24,30 @@ export default { // // params = await maintainPinnedPosts(params) // return neo4jgraphql(object, params, context, resolveInfo) // }, - // Group: async (object, params, context, resolveInfo) => { - // // const { email } = params - // const session = context.driver.session() - // const readTxResultPromise = session.readTransaction(async (txc) => { - // const result = await txc.run( - // ` - // MATCH (user:User {id: $userId})-[:MEMBER_OF]->(group:Group) - // RETURN properties(group) AS inviteCodes - // `, - // { - // userId: context.user.id, - // }, - // ) - // return result.records.map((record) => record.get('inviteCodes')) - // }) - // if (email) { - // try { - // session = context.driver.session() - // const readTxResult = await session.readTransaction((txc) => { - // const result = txc.run( - // ` - // MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $args.email}) - // RETURN user`, - // { args }, - // ) - // return result - // }) - // return readTxResult.records.map((r) => r.get('user').properties) - // } finally { - // session.close() - // } - // } - // return neo4jgraphql(object, args, context, resolveInfo) - // }, + Group: async (_object, _params, context, _resolveInfo) => { + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + const result = await txc.run( + ` + MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group) + RETURN group {.*, myRole: membership.role} + `, + { + userId: context.user.id, + }, + ) + const group = result.records.map((record) => record.get('group')) + return group + }) + try { + const group = await readTxResultPromise + return group + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, }, Mutation: { CreateGroup: async (_parent, params, context, _resolveInfo) => { @@ -100,10 +90,10 @@ export default { try { const group = await writeTxResultPromise return group - } catch (e) { - if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + } catch (error) { + if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') throw new UserInputError('Group with this slug already exists!') - throw new Error(e) + throw new Error(error) } finally { session.close() } From 867b78dfa3983f92978ff7ec5284830a6fc34d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 3 Aug 2022 15:16:23 +0200 Subject: [PATCH 016/374] Implement 'Group' query, second step --- backend/src/db/graphql/authentications.ts | 29 ++ backend/src/db/graphql/groups.ts | 95 +++++ backend/src/db/graphql/mutations.ts | 69 ---- backend/src/db/graphql/posts.ts | 15 + .../src/middleware/slugifyMiddleware.spec.js | 8 +- backend/src/schema/resolvers/groups.spec.js | 325 +++++++++--------- backend/src/schema/types/type/Group.gql | 2 +- 7 files changed, 302 insertions(+), 241 deletions(-) create mode 100644 backend/src/db/graphql/authentications.ts create mode 100644 backend/src/db/graphql/groups.ts delete mode 100644 backend/src/db/graphql/mutations.ts create mode 100644 backend/src/db/graphql/posts.ts diff --git a/backend/src/db/graphql/authentications.ts b/backend/src/db/graphql/authentications.ts new file mode 100644 index 0000000000..f059706508 --- /dev/null +++ b/backend/src/db/graphql/authentications.ts @@ -0,0 +1,29 @@ +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 + ) { + slug + } + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/db/graphql/groups.ts b/backend/src/db/graphql/groups.ts new file mode 100644 index 0000000000..80b5996586 --- /dev/null +++ b/backend/src/db/graphql/groups.ts @@ -0,0 +1,95 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createGroupMutation = gql` + mutation ( + $id: ID, + $name: String!, + $slug: String, + $about: String, + $description: String!, + $groupType: GroupType!, + $actionRadius: GroupActionRadius!, + $categoryIds: [ID] + ) { + CreateGroup( + id: $id + name: $name + slug: $slug + about: $about + description: $description + groupType: $groupType + actionRadius: $actionRadius + categoryIds: $categoryIds + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + # Wolle: owner { + # name + # } + } + } +` + +// ------ queries + +export const groupQuery = gql` + query ( + $id: ID, + $name: String, + $slug: String, + $createdAt: String + $updatedAt: String + $about: String, + $description: String, + # $groupType: GroupType!, + # $actionRadius: GroupActionRadius!, + $categoryIds: [ID] + $locationName: String + $first: Int + $offset: Int + $orderBy: [_GroupOrdering] + $filter: _GroupFilter + ) { + Group( + id: $id + name: $name + slug: $slug + createdAt: $createdAt + updatedAt: $updatedAt + about: $about + description: $description + # groupType: $groupType + # actionRadius: $actionRadius + categoryIds: $categoryIds + locationName: $locationName + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + # Wolle: owner { + # name + # } + } + } +` diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts deleted file mode 100644 index 4f07e0f1e7..0000000000 --- a/backend/src/db/graphql/mutations.ts +++ /dev/null @@ -1,69 +0,0 @@ -import gql from 'graphql-tag' - -export const createGroupMutation = gql` - mutation ( - $id: ID, - $name: String!, - $slug: String, - $about: String, - $description: String!, - $groupType: GroupType!, - $actionRadius: GroupActionRadius!, - $categoryIds: [ID] - ) { - CreateGroup( - id: $id - name: $name - slug: $slug - about: $about - description: $description - groupType: $groupType - actionRadius: $actionRadius - categoryIds: $categoryIds - ) { - id - name - slug - about - description - groupType - actionRadius - disabled - deleted - myRole - # Wolle: owner { - # name - # } - } - } -` - -export const createPostMutation = gql` - mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { - CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { - slug - } - } -` - -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 - ) { - slug - } - } -` diff --git a/backend/src/db/graphql/posts.ts b/backend/src/db/graphql/posts.ts new file mode 100644 index 0000000000..3277af8207 --- /dev/null +++ b/backend/src/db/graphql/posts.ts @@ -0,0 +1,15 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createPostMutation = gql` + mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { + slug + } + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index af6ff25b0f..3c18e70b0b 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -2,11 +2,9 @@ import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../db/factories' -import { - createPostMutation, - createGroupMutation, - signupVerificationMutation, -} from '../db/graphql/mutations' +import { createGroupMutation } from '../db/graphql/groups' +import { createPostMutation } from '../db/graphql/posts' +import { signupVerificationMutation } from '../db/graphql/authentications' let mutate let authenticatedUser diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 17fc4b1da7..8860f87f26 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -1,6 +1,6 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' -import { createGroupMutation } from '../../db/graphql/mutations' +import { createGroupMutation } from '../../db/graphql/groups' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' @@ -78,171 +78,164 @@ afterEach(async () => { await cleanDatabase() }) -// describe('Group', () => { -// describe('can be filtered', () => { -// let followedUser, happyPost, cryPost -// beforeEach(async () => { -// ;[followedUser] = await Promise.all([ -// Factory.build( -// 'user', -// { -// id: 'followed-by-me', -// name: 'Followed User', -// }, -// { -// email: 'followed@example.org', -// password: '1234', -// }, -// ), -// ]) -// ;[happyPost, cryPost] = await Promise.all([ -// Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), -// Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), -// Factory.build( -// 'post', -// { -// id: 'post-by-followed-user', -// }, -// { -// categoryIds: ['cat9'], -// author: followedUser, -// }, -// ), -// ]) -// }) - -// describe('no filter', () => { -// it('returns all posts', async () => { -// const postQueryNoFilters = gql` -// query Post($filter: _PostFilter) { -// Post(filter: $filter) { -// id -// } -// } -// ` -// const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] -// variables = { filter: {} } -// await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ -// data: { -// Post: expect.arrayContaining(expected), -// }, -// }) -// }) -// }) - -// /* it('by categories', async () => { -// const postQueryFilteredByCategories = gql` -// query Post($filter: _PostFilter) { -// Post(filter: $filter) { -// id -// categories { -// id -// } -// } -// } -// ` -// const expected = { -// data: { -// Post: [ -// { -// id: 'post-by-followed-user', -// categories: [{ id: 'cat9' }], -// }, -// ], -// }, -// } -// variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } -// await expect( -// query({ query: postQueryFilteredByCategories, variables }), -// ).resolves.toMatchObject(expected) -// }) */ - -// describe('by emotions', () => { -// const postQueryFilteredByEmotions = gql` -// query Post($filter: _PostFilter) { -// Post(filter: $filter) { -// id -// emotions { -// emotion -// } -// } -// } -// ` - -// it('filters by single emotion', async () => { -// const expected = { -// data: { -// Post: [ -// { -// id: 'happy-post', -// emotions: [{ emotion: 'happy' }], -// }, -// ], -// }, -// } -// await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) -// variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } -// await expect( -// query({ query: postQueryFilteredByEmotions, variables }), -// ).resolves.toMatchObject(expected) -// }) - -// it('filters by multiple emotions', async () => { -// const expected = [ -// { -// id: 'happy-post', -// emotions: [{ emotion: 'happy' }], -// }, -// { -// id: 'cry-post', -// emotions: [{ emotion: 'cry' }], -// }, -// ] -// await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) -// await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) -// variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } -// await expect( -// query({ query: postQueryFilteredByEmotions, variables }), -// ).resolves.toMatchObject({ -// data: { -// Post: expect.arrayContaining(expected), -// }, -// errors: undefined, -// }) -// }) -// }) - -// it('by followed-by', async () => { -// const postQueryFilteredByUsersFollowed = gql` -// query Post($filter: _PostFilter) { -// Post(filter: $filter) { -// id -// author { -// id -// name -// } -// } -// } -// ` - -// await user.relateTo(followedUser, 'following') -// variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } -// await expect( -// query({ query: postQueryFilteredByUsersFollowed, variables }), -// ).resolves.toMatchObject({ -// data: { -// Post: [ -// { -// id: 'post-by-followed-user', -// author: { id: 'followed-by-me', name: 'Followed User' }, -// }, -// ], -// }, -// errors: undefined, -// }) -// }) -// }) -// }) +describe('Group', () => { + // describe('can be filtered', () => { + // let followedUser, happyPost, cryPost + // beforeEach(async () => { + // ;[followedUser] = await Promise.all([ + // Factory.build( + // 'user', + // { + // id: 'followed-by-me', + // name: 'Followed User', + // }, + // { + // email: 'followed@example.org', + // password: '1234', + // }, + // ), + // ]) + // ;[happyPost, cryPost] = await Promise.all([ + // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), + // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), + // Factory.build( + // 'post', + // { + // id: 'post-by-followed-user', + // }, + // { + // categoryIds: ['cat9'], + // author: followedUser, + // }, + // ), + // ]) + // }) + // describe('no filter', () => { + // it('returns all posts', async () => { + // const postQueryNoFilters = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // } + // } + // ` + // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] + // variables = { filter: {} } + // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // }) + // }) + // }) + // /* it('by categories', async () => { + // const postQueryFilteredByCategories = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // categories { + // id + // } + // } + // } + // ` + // const expected = { + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // categories: [{ id: 'cat9' }], + // }, + // ], + // }, + // } + // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } + // await expect( + // query({ query: postQueryFilteredByCategories, variables }), + // ).resolves.toMatchObject(expected) + // }) */ + // describe('by emotions', () => { + // const postQueryFilteredByEmotions = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // emotions { + // emotion + // } + // } + // } + // ` + // it('filters by single emotion', async () => { + // const expected = { + // data: { + // Post: [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // ], + // }, + // } + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject(expected) + // }) + // it('filters by multiple emotions', async () => { + // const expected = [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // { + // id: 'cry-post', + // emotions: [{ emotion: 'cry' }], + // }, + // ] + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // errors: undefined, + // }) + // }) + // }) + // it('by followed-by', async () => { + // const postQueryFilteredByUsersFollowed = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // author { + // id + // name + // } + // } + // } + // ` + // await user.relateTo(followedUser, 'following') + // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } + // await expect( + // query({ query: postQueryFilteredByUsersFollowed, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // author: { id: 'followed-by-me', name: 'Followed User' }, + // }, + // ], + // }, + // errors: undefined, + // }) + // }) + // }) +}) describe('CreateGroup', () => { beforeEach(() => { diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 72ac9b57ae..cd15689ec7 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -175,9 +175,9 @@ type Query { slug: String createdAt: String updatedAt: String - locationName: String about: String description: String + locationName: String first: Int offset: Int orderBy: [_GroupOrdering] From fcca0f378d92726f5dc63e0fd0e6672edd210d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Thu, 4 Aug 2022 09:24:04 +0200 Subject: [PATCH 017/374] Implement 'Group' query, next step --- ...{authentications.ts => authentications.js} | 0 .../src/db/graphql/{groups.ts => groups.js} | 10 +- backend/src/db/graphql/{posts.ts => posts.js} | 0 backend/src/schema/resolvers/groups.js | 34 +- backend/src/schema/resolvers/groups.spec.js | 432 +++++++++++------- backend/src/schema/types/type/Group.gql | 1 + 6 files changed, 307 insertions(+), 170 deletions(-) rename backend/src/db/graphql/{authentications.ts => authentications.js} (100%) rename backend/src/db/graphql/{groups.ts => groups.js} (89%) rename backend/src/db/graphql/{posts.ts => posts.js} (100%) diff --git a/backend/src/db/graphql/authentications.ts b/backend/src/db/graphql/authentications.js similarity index 100% rename from backend/src/db/graphql/authentications.ts rename to backend/src/db/graphql/authentications.js diff --git a/backend/src/db/graphql/groups.ts b/backend/src/db/graphql/groups.js similarity index 89% rename from backend/src/db/graphql/groups.ts rename to backend/src/db/graphql/groups.js index 80b5996586..e8da8e90bd 100644 --- a/backend/src/db/graphql/groups.ts +++ b/backend/src/db/graphql/groups.js @@ -46,6 +46,7 @@ export const createGroupMutation = gql` export const groupQuery = gql` query ( + $isMember: Boolean $id: ID, $name: String, $slug: String, @@ -55,7 +56,7 @@ export const groupQuery = gql` $description: String, # $groupType: GroupType!, # $actionRadius: GroupActionRadius!, - $categoryIds: [ID] + # $categoryIds: [ID] $locationName: String $first: Int $offset: Int @@ -63,6 +64,7 @@ export const groupQuery = gql` $filter: _GroupFilter ) { Group( + isMember: $isMember id: $id name: $name slug: $slug @@ -72,8 +74,12 @@ export const groupQuery = gql` description: $description # groupType: $groupType # actionRadius: $actionRadius - categoryIds: $categoryIds + # categoryIds: $categoryIds locationName: $locationName + first: $first + offset: $offset + orderBy: $orderBy + filter: $filter ) { id name diff --git a/backend/src/db/graphql/posts.ts b/backend/src/db/graphql/posts.js similarity index 100% rename from backend/src/db/graphql/posts.ts rename to backend/src/db/graphql/posts.js diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 9a55c9f4df..be07fecc6e 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -24,18 +24,34 @@ export default { // // params = await maintainPinnedPosts(params) // return neo4jgraphql(object, params, context, resolveInfo) // }, - Group: async (_object, _params, context, _resolveInfo) => { + Group: async (_object, params, context, _resolveInfo) => { + const { isMember } = params const session = context.driver.session() const readTxResultPromise = session.readTransaction(async (txc) => { - const result = await txc.run( - ` - MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group) + let groupCypher + if (isMember === true) { + groupCypher = ` + MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group) RETURN group {.*, myRole: membership.role} - `, - { - userId: context.user.id, - }, - ) + ` + } else { + if (isMember === false) { + groupCypher = ` + MATCH (group:Group) + WHERE NOT (:User {id: $userId})-[:MEMBER_OF]->(group) + RETURN group {.*, myRole: NULL} + ` + } else { + groupCypher = ` + MATCH (group:Group) + OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group) + RETURN group {.*, myRole: membership.role} + ` + } + } + const result = await txc.run(groupCypher, { + userId: context.user.id, + }) const group = result.records.map((record) => record.get('group')) return group }) diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 8860f87f26..58e5f37bef 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -1,13 +1,13 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' -import { createGroupMutation } from '../../db/graphql/groups' +import { createGroupMutation, groupQuery } from '../../db/graphql/groups' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' const driver = getDriver() const neode = getNeode() -// Wolle: let query +let query let mutate let authenticatedUser let user @@ -27,7 +27,7 @@ beforeAll(async () => { } }, }) - // Wolle: query = createTestClient(server).query + query = createTestClient(server).query mutate = createTestClient(server).mutate }) @@ -79,162 +79,276 @@ afterEach(async () => { }) describe('Group', () => { - // describe('can be filtered', () => { - // let followedUser, happyPost, cryPost - // beforeEach(async () => { - // ;[followedUser] = await Promise.all([ - // Factory.build( - // 'user', - // { - // id: 'followed-by-me', - // name: 'Followed User', - // }, - // { - // email: 'followed@example.org', - // password: '1234', - // }, - // ), - // ]) - // ;[happyPost, cryPost] = await Promise.all([ - // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), - // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), - // Factory.build( - // 'post', - // { - // id: 'post-by-followed-user', - // }, - // { - // categoryIds: ['cat9'], - // author: followedUser, - // }, - // ), - // ]) - // }) - // describe('no filter', () => { - // it('returns all posts', async () => { - // const postQueryNoFilters = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // } - // } - // ` - // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] - // variables = { filter: {} } - // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ - // data: { - // Post: expect.arrayContaining(expected), - // }, - // }) - // }) - // }) - // /* it('by categories', async () => { - // const postQueryFilteredByCategories = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // categories { - // id - // } - // } - // } - // ` - // const expected = { - // data: { - // Post: [ - // { - // id: 'post-by-followed-user', - // categories: [{ id: 'cat9' }], - // }, - // ], - // }, - // } - // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } - // await expect( - // query({ query: postQueryFilteredByCategories, variables }), - // ).resolves.toMatchObject(expected) - // }) */ - // describe('by emotions', () => { - // const postQueryFilteredByEmotions = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // emotions { - // emotion - // } - // } - // } - // ` - // it('filters by single emotion', async () => { - // const expected = { - // data: { - // Post: [ - // { - // id: 'happy-post', - // emotions: [{ emotion: 'happy' }], - // }, - // ], - // }, - // } - // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) - // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } - // await expect( - // query({ query: postQueryFilteredByEmotions, variables }), - // ).resolves.toMatchObject(expected) - // }) - // it('filters by multiple emotions', async () => { - // const expected = [ - // { - // id: 'happy-post', - // emotions: [{ emotion: 'happy' }], - // }, - // { - // id: 'cry-post', - // emotions: [{ emotion: 'cry' }], - // }, - // ] - // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) - // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) - // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } - // await expect( - // query({ query: postQueryFilteredByEmotions, variables }), - // ).resolves.toMatchObject({ - // data: { - // Post: expect.arrayContaining(expected), - // }, - // errors: undefined, - // }) - // }) - // }) - // it('by followed-by', async () => { - // const postQueryFilteredByUsersFollowed = gql` - // query Post($filter: _PostFilter) { - // Post(filter: $filter) { - // id - // author { - // id - // name - // } - // } - // } - // ` - // await user.relateTo(followedUser, 'following') - // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } - // await expect( - // query({ query: postQueryFilteredByUsersFollowed, variables }), - // ).resolves.toMatchObject({ - // data: { - // Post: [ - // { - // id: 'post-by-followed-user', - // author: { id: 'followed-by-me', name: 'Followed User' }, - // }, - // ], - // }, - // errors: undefined, - // }) - // }) - // }) + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await query({ query: groupQuery, variables: {} }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + let otherUser + + beforeEach(async () => { + otherUser = await Factory.build( + 'user', + { + id: 'other-user', + name: 'Other TestUser', + }, + { + email: 'test2@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!?', + groupType: 'closed', + actionRadius: 'international', + 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', + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('query can fetch', () => { + it('groups where user is member (or owner in this case)', async () => { + const expected = { + data: { + Group: [ + { + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }, + ], + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: true } }), + ).resolves.toMatchObject(expected) + }) + + it('groups where user is not(!) member', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: false } }), + ).resolves.toMatchObject(expected) + }) + + it('all groups', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) + }) + }) + + // Wolle: describe('can be filtered', () => { + // let followedUser, happyPost, cryPost + // beforeEach(async () => { + // ;[followedUser] = await Promise.all([ + // Factory.build( + // 'user', + // { + // id: 'followed-by-me', + // name: 'Followed User', + // }, + // { + // email: 'followed@example.org', + // password: '1234', + // }, + // ), + // ]) + // ;[happyPost, cryPost] = await Promise.all([ + // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), + // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), + // Factory.build( + // 'post', + // { + // id: 'post-by-followed-user', + // }, + // { + // categoryIds: ['cat9'], + // author: followedUser, + // }, + // ), + // ]) + // }) + // describe('no filter', () => { + // it('returns all posts', async () => { + // const postQueryNoFilters = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // } + // } + // ` + // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] + // variables = { filter: {} } + // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // }) + // }) + // }) + // /* it('by categories', async () => { + // const postQueryFilteredByCategories = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // categories { + // id + // } + // } + // } + // ` + // const expected = { + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // categories: [{ id: 'cat9' }], + // }, + // ], + // }, + // } + // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } + // await expect( + // query({ query: postQueryFilteredByCategories, variables }), + // ).resolves.toMatchObject(expected) + // }) */ + // describe('by emotions', () => { + // const postQueryFilteredByEmotions = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // emotions { + // emotion + // } + // } + // } + // ` + // it('filters by single emotion', async () => { + // const expected = { + // data: { + // Post: [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // ], + // }, + // } + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject(expected) + // }) + // it('filters by multiple emotions', async () => { + // const expected = [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // { + // id: 'cry-post', + // emotions: [{ emotion: 'cry' }], + // }, + // ] + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // errors: undefined, + // }) + // }) + // }) + // it('by followed-by', async () => { + // const postQueryFilteredByUsersFollowed = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // author { + // id + // name + // } + // } + // } + // ` + // await user.relateTo(followedUser, 'following') + // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } + // await expect( + // query({ query: postQueryFilteredByUsersFollowed, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // author: { id: 'followed-by-me', name: 'Followed User' }, + // }, + // ], + // }, + // errors: undefined, + // }) + // }) + // }) + }) }) describe('CreateGroup', () => { diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index cd15689ec7..b8e00f0eef 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -170,6 +170,7 @@ input _GroupFilter { type Query { Group( + isMember: Boolean # if 'undefined' or 'null' then all groups id: ID name: String slug: String From f1831661e58c1547485b16d49036e8ef5a14ac9b Mon Sep 17 00:00:00 2001 From: ogerly Date: Sun, 7 Aug 2022 14:12:17 +0200 Subject: [PATCH 018/374] add my-groups page and setting link --- webapp/components/GroupForm/GroupForm.vue | 208 +++++------------- webapp/components/GroupTeaser/GroupTeaser.vue | 40 ++-- webapp/locales/de.json | 3 + webapp/locales/en.json | 3 + webapp/pages/group/create.vue | 9 +- webapp/pages/settings.vue | 4 + 6 files changed, 88 insertions(+), 179 deletions(-) diff --git a/webapp/components/GroupForm/GroupForm.vue b/webapp/components/GroupForm/GroupForm.vue index 76d659b961..e730a3c138 100644 --- a/webapp/components/GroupForm/GroupForm.vue +++ b/webapp/components/GroupForm/GroupForm.vue @@ -3,10 +3,7 @@ - - diff --git a/webapp/pages/group/create.vue b/webapp/pages/group/create.vue index b77096859d..9463083f1c 100644 --- a/webapp/pages/group/create.vue +++ b/webapp/pages/group/create.vue @@ -1,11 +1,13 @@ + diff --git a/webapp/components/GroupTeaser/GroupTeaser.vue b/webapp/components/GroupTeaser/GroupTeaser.vue index a199106a55..22df641e97 100644 --- a/webapp/components/GroupTeaser/GroupTeaser.vue +++ b/webapp/components/GroupTeaser/GroupTeaser.vue @@ -1,7 +1,5 @@ + + diff --git a/webapp/pages/my-groups.vue b/webapp/pages/my-groups.vue index 386bb1e51b..a861403ed1 100644 --- a/webapp/pages/my-groups.vue +++ b/webapp/pages/my-groups.vue @@ -2,15 +2,45 @@
my groups
+
From 7682aa7e45a289df8018eba72442ff197f5234ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 11:34:51 +0200 Subject: [PATCH 028/374] Fix description length for slugify tests --- backend/src/middleware/slugifyMiddleware.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 3c18e70b0b..59fa72ba72 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -12,6 +12,8 @@ let variables const driver = getDriver() const neode = getNeode() +const descriptionAddition100 = + ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' beforeAll(async () => { await cleanDatabase() @@ -67,7 +69,7 @@ describe('slugifyMiddleware', () => { ...variables, name: 'The Best Group', about: 'Some about', - description: 'Some description', + description: 'Some description' + descriptionAddition100, groupType: 'closed', actionRadius: 'national', categoryIds, @@ -87,7 +89,7 @@ describe('slugifyMiddleware', () => { name: 'The Best Group', slug: 'the-best-group', about: 'Some about', - description: 'Some description', + description: 'Some description' + descriptionAddition100, groupType: 'closed', actionRadius: 'national', }, From 82401b1488dd6aee9282b6b9f810f480f25edf2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 12:52:20 +0200 Subject: [PATCH 029/374] Update backend/src/schema/resolvers/groups.js Co-authored-by: Moriz Wahl --- backend/src/schema/resolvers/groups.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index e6a8c3a181..f6d482421e 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -36,8 +36,7 @@ export default { const result = await txc.run(groupCypher, { userId: context.user.id, }) - const group = result.records.map((record) => record.get('group')) - return group + return result.records.map((record) => record.get('group')) }) try { const group = await readTxResultPromise From f150ea3d7ce24286127d2ddf8fa08ca977c5ded7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 12:52:31 +0200 Subject: [PATCH 030/374] Update backend/src/schema/resolvers/groups.js Co-authored-by: Moriz Wahl --- backend/src/schema/resolvers/groups.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index f6d482421e..dadbcd2a1e 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -39,8 +39,7 @@ export default { return result.records.map((record) => record.get('group')) }) try { - const group = await readTxResultPromise - return group + return await readTxResultPromise } catch (error) { throw new Error(error) } finally { From 5e741ead8d3f96d5d4eacf88a7e7e3f70a361fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 13:15:43 +0200 Subject: [PATCH 031/374] Overtake Moriz suggestions --- backend/src/models/Group.js | 2 - backend/src/schema/resolvers/groups.js | 6 +- backend/src/schema/resolvers/groups.spec.js | 126 +++++++++--------- .../schema/types/enum/GroupActionRadius.gql | 3 +- 4 files changed, 71 insertions(+), 66 deletions(-) diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js index 25149e9c39..a75ad518f9 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -37,8 +37,6 @@ export default { locationName: { type: 'string', allow: [null] }, - wasSeeded: 'boolean', // Wolle: used or needed? - isIn: { type: 'relationship', relationship: 'IS_IN', diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index dadbcd2a1e..d1af985138 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -52,17 +52,17 @@ export default { const { categoryIds } = params delete params.categoryIds if (!categoryIds || categoryIds.length < CATEGORIES_MIN) { - throw new UserInputError('To Less Categories!') + throw new UserInputError('Too view categories!') } if (categoryIds && categoryIds.length > CATEGORIES_MAX) { - throw new UserInputError('To Many Categories!') + 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('To Short Description!') + throw new UserInputError('Description too short!') } params.id = params.id || uuid() const session = context.driver.session() diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index bae530c612..5354f5ebeb 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -120,7 +120,7 @@ describe('Group', () => { about: 'We will change nothing!', description: 'We love it like it is!?' + descriptionAddition100, groupType: 'closed', - actionRadius: 'international', + actionRadius: 'global', categoryIds, }, }) @@ -139,62 +139,68 @@ describe('Group', () => { }) }) - describe('can find', () => { - it('all', async () => { - const expected = { - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }), - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, - } - await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) + describe('query groups', () => { + describe('without any filters', () => { + it('finds all groups', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) + }) }) - it('where user is member (or owner in this case)', async () => { - const expected = { - data: { - Group: [ - { - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }, - ], - }, - errors: undefined, - } - await expect( - query({ query: groupQuery, variables: { isMember: true } }), - ).resolves.toMatchObject(expected) + describe('isMember = true', () => { + it('finds only groups where user is member', async () => { + const expected = { + data: { + Group: [ + { + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }, + ], + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: true } }), + ).resolves.toMatchObject(expected) + }) }) - it('where user is not(!) member', async () => { - const expected = { - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, - } - await expect( - query({ query: groupQuery, variables: { isMember: false } }), - ).resolves.toMatchObject(expected) + describe('isMember = false', () => { + it('finds only groups where user is not(!) member', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: false } }), + ).resolves.toMatchObject(expected) + }) }) }) }) @@ -258,7 +264,7 @@ describe('CreateGroup', () => { ) }) - it('"disabled" and "deleted" default to "false"', async () => { + it('has "disabled" and "deleted" default to "false"', async () => { const expected = { data: { CreateGroup: { disabled: false, deleted: false } } } await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( expected, @@ -268,7 +274,7 @@ describe('CreateGroup', () => { describe('description', () => { describe('length without HTML', () => { describe('less then 100 chars', () => { - it('throws error: "To Less Categories!"', async () => { + it('throws error: "Too view categories!"', async () => { const { errors } = await mutate({ mutation: createGroupMutation, variables: { @@ -278,7 +284,7 @@ describe('CreateGroup', () => { '0123456789', }, }) - expect(errors[0]).toHaveProperty('message', 'To Short Description!') + expect(errors[0]).toHaveProperty('message', 'Description too short!') }) }) }) @@ -286,22 +292,22 @@ describe('CreateGroup', () => { describe('categories', () => { describe('not even one', () => { - it('throws error: "To Less Categories!"', async () => { + it('throws error: "Too view categories!"', async () => { const { errors } = await mutate({ mutation: createGroupMutation, variables: { ...variables, categoryIds: null }, }) - expect(errors[0]).toHaveProperty('message', 'To Less Categories!') + expect(errors[0]).toHaveProperty('message', 'Too view categories!') }) }) describe('four', () => { - it('throws error: "To Many Categories!"', async () => { + 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', 'To Many Categories!') + expect(errors[0]).toHaveProperty('message', 'Too many categories!') }) }) }) diff --git a/backend/src/schema/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql index afc4211337..221ed7f877 100644 --- a/backend/src/schema/types/enum/GroupActionRadius.gql +++ b/backend/src/schema/types/enum/GroupActionRadius.gql @@ -2,5 +2,6 @@ enum GroupActionRadius { regional national continental - international + global + interplanetary } From b0d28f8649bba912a11263f206d6d368b579b648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Wed, 10 Aug 2022 13:19:42 +0200 Subject: [PATCH 032/374] Rename 'descriptionAddition100' to 'descriptionAdditional100' --- backend/src/middleware/slugifyMiddleware.spec.js | 6 +++--- backend/src/schema/resolvers/groups.spec.js | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 59fa72ba72..9605aada90 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -12,7 +12,7 @@ let variables const driver = getDriver() const neode = getNeode() -const descriptionAddition100 = +const descriptionAdditional100 = ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' beforeAll(async () => { @@ -69,7 +69,7 @@ describe('slugifyMiddleware', () => { ...variables, name: 'The Best Group', about: 'Some about', - description: 'Some description' + descriptionAddition100, + description: 'Some description' + descriptionAdditional100, groupType: 'closed', actionRadius: 'national', categoryIds, @@ -89,7 +89,7 @@ describe('slugifyMiddleware', () => { name: 'The Best Group', slug: 'the-best-group', about: 'Some about', - description: 'Some description' + descriptionAddition100, + description: 'Some description' + descriptionAdditional100, groupType: 'closed', actionRadius: 'national', }, diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 5354f5ebeb..b3327d44a1 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -13,7 +13,7 @@ let authenticatedUser let user const categoryIds = ['cat9', 'cat4', 'cat15'] -const descriptionAddition100 = +const descriptionAdditional100 = ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' let variables = {} @@ -118,7 +118,7 @@ describe('Group', () => { id: 'others-group', name: 'Uninteresting Group', about: 'We will change nothing!', - description: 'We love it like it is!?' + descriptionAddition100, + description: 'We love it like it is!?' + descriptionAdditional100, groupType: 'closed', actionRadius: 'global', categoryIds, @@ -131,7 +131,7 @@ describe('Group', () => { id: 'my-group', name: 'The Best Group', about: 'We will change the world!', - description: 'Some description' + descriptionAddition100, + description: 'Some description' + descriptionAdditional100, groupType: 'public', actionRadius: 'regional', categoryIds, @@ -214,7 +214,7 @@ describe('CreateGroup', () => { name: 'The Best Group', slug: 'the-group', about: 'We will change the world!', - description: 'Some description' + descriptionAddition100, + description: 'Some description' + descriptionAdditional100, groupType: 'public', actionRadius: 'regional', categoryIds, From addfc39c4c3b1a6132b4e1b82061a01ed651fb48 Mon Sep 17 00:00:00 2001 From: ogerly Date: Thu, 11 Aug 2022 11:10:52 +0200 Subject: [PATCH 033/374] add groupe site id und slug --- webapp/components/GroupForm/GroupForm.vue | 60 +++++++-------- webapp/components/GroupList/GroupCard.vue | 50 ++++++++++++ webapp/components/GroupList/GroupList.vue | 73 +++++++++++++----- webapp/components/GroupMember/GroupMember.vue | 67 ++++++++++++++++ webapp/components/GroupTeaser/GroupTeaser.vue | 6 +- webapp/pages/group/_id.vue | 9 +++ webapp/pages/group/_id/_slug.vue | 6 ++ webapp/pages/group/create.vue | 7 +- webapp/pages/my-groups.vue | 76 ++++++++++++------- 9 files changed, 270 insertions(+), 84 deletions(-) create mode 100644 webapp/components/GroupList/GroupCard.vue create mode 100644 webapp/components/GroupMember/GroupMember.vue create mode 100644 webapp/pages/group/_id.vue create mode 100644 webapp/pages/group/_id/_slug.vue diff --git a/webapp/components/GroupForm/GroupForm.vue b/webapp/components/GroupForm/GroupForm.vue index 1bd4455d7d..703adc45c7 100644 --- a/webapp/components/GroupForm/GroupForm.vue +++ b/webapp/components/GroupForm/GroupForm.vue @@ -1,32 +1,31 @@ @@ -44,19 +43,20 @@ export default { name: '', status: '', description: '', + disable: false, } }, - + methods: { - submit() { - console.log('handleSubmit') - }, - handleSubmit() { - console.log('handleSubmit') - }, - reset() { - console.log('handleSubmit') + submit() { + console.log('handleSubmit') + }, + handleSubmit() { + console.log('handleSubmit') + }, + reset() { + console.log('handleSubmit') + }, }, - }, } diff --git a/webapp/components/GroupList/GroupCard.vue b/webapp/components/GroupList/GroupCard.vue new file mode 100644 index 0000000000..4ae9f807fd --- /dev/null +++ b/webapp/components/GroupList/GroupCard.vue @@ -0,0 +1,50 @@ + + diff --git a/webapp/components/GroupList/GroupList.vue b/webapp/components/GroupList/GroupList.vue index bfaceed21a..69df8acb72 100644 --- a/webapp/components/GroupList/GroupList.vue +++ b/webapp/components/GroupList/GroupList.vue @@ -1,27 +1,58 @@ diff --git a/webapp/components/GroupMember/GroupMember.vue b/webapp/components/GroupMember/GroupMember.vue new file mode 100644 index 0000000000..f83b00685e --- /dev/null +++ b/webapp/components/GroupMember/GroupMember.vue @@ -0,0 +1,67 @@ + + diff --git a/webapp/components/GroupTeaser/GroupTeaser.vue b/webapp/components/GroupTeaser/GroupTeaser.vue index 22df641e97..33a50dca74 100644 --- a/webapp/components/GroupTeaser/GroupTeaser.vue +++ b/webapp/components/GroupTeaser/GroupTeaser.vue @@ -21,9 +21,7 @@ - diff --git a/webapp/pages/group/_id.vue b/webapp/pages/group/_id.vue new file mode 100644 index 0000000000..7ce25e827c --- /dev/null +++ b/webapp/pages/group/_id.vue @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/webapp/pages/group/_id/_slug.vue b/webapp/pages/group/_id/_slug.vue new file mode 100644 index 0000000000..36f2ccb2be --- /dev/null +++ b/webapp/pages/group/_id/_slug.vue @@ -0,0 +1,6 @@ + diff --git a/webapp/pages/group/create.vue b/webapp/pages/group/create.vue index 9463083f1c..e82fadd6ed 100644 --- a/webapp/pages/group/create.vue +++ b/webapp/pages/group/create.vue @@ -1,19 +1,24 @@ - diff --git a/webapp/pages/my-groups.vue b/webapp/pages/my-groups.vue index a861403ed1..81843f5d28 100644 --- a/webapp/pages/my-groups.vue +++ b/webapp/pages/my-groups.vue @@ -2,45 +2,65 @@
my groups
- + +
+
+
From 46d26a0ef8ab16b32662e53f763e25fc676b6037 Mon Sep 17 00:00:00 2001 From: ogerly Date: Sun, 14 Aug 2022 11:45:33 +0200 Subject: [PATCH 034/374] add graphql groups, add createGroup mutation --- webapp/components/GroupForm/GroupForm.vue | 41 +++++---- webapp/graphql/groups.js | 107 ++++++++++++++++++++++ webapp/pages/group/create.vue | 40 +++++++- 3 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 webapp/graphql/groups.js diff --git a/webapp/components/GroupForm/GroupForm.vue b/webapp/components/GroupForm/GroupForm.vue index 703adc45c7..2cbc674647 100644 --- a/webapp/components/GroupForm/GroupForm.vue +++ b/webapp/components/GroupForm/GroupForm.vue @@ -2,25 +2,25 @@
- + - + -
{{ name }}
-
{{ status }}
-
{{ description }}
+
{{ form.name }}
+
{{ form.status }}
+
{{ form.description }}
Reset form - Save group + Save group
@@ -38,24 +38,33 @@ export default { components: { CategoriesSelect, }, + props:{ + value: { + type: Object, + default: () => ({}), + required: true, + } + }, data() { return { - name: '', - status: '', - description: '', - disable: false, + form: { + name: '', + status: '', + description: '', + disable: false, + } + } }, methods: { submit() { - console.log('handleSubmit') - }, - handleSubmit() { - console.log('handleSubmit') + console.log('submit', this.form) + this.$emit('createGroup', this.form) + }, reset() { - console.log('handleSubmit') + console.log('reset') }, }, } diff --git a/webapp/graphql/groups.js b/webapp/graphql/groups.js new file mode 100644 index 0000000000..c41f06e4d9 --- /dev/null +++ b/webapp/graphql/groups.js @@ -0,0 +1,107 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createGroupMutation = gql` + mutation ( + $id: ID + $name: String! + $slug: String + $about: String + $description: String! + $groupType: GroupType! + $actionRadius: GroupActionRadius! + $categoryIds: [ID] + ) { + CreateGroup( + id: $id + name: $name + slug: $slug + about: $about + description: $description + groupType: $groupType + actionRadius: $actionRadius + categoryIds: $categoryIds + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + # Wolle: owner { + # name + # } + } + } +` + +// ------ queries + +export const groupQuery = gql` + query ( + $isMember: Boolean + $id: ID + $name: String + $slug: String + $createdAt: String + $updatedAt: String + $about: String + $description: String + # $groupType: GroupType!, + # $actionRadius: GroupActionRadius!, + # $categoryIds: [ID] + $locationName: String + $first: Int + $offset: Int + $orderBy: [_GroupOrdering] + $filter: _GroupFilter + ) { + Group( + isMember: $isMember + id: $id + name: $name + slug: $slug + createdAt: $createdAt + updatedAt: $updatedAt + about: $about + description: $description + # groupType: $groupType + # actionRadius: $actionRadius + # categoryIds: $categoryIds + locationName: $locationName + first: $first + offset: $offset + orderBy: $orderBy + filter: $filter + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + categories { + id + slug + name + icon + } + # Wolle: owner { + # name + # } + } + } +` diff --git a/webapp/pages/group/create.vue b/webapp/pages/group/create.vue index e82fadd6ed..c76be5360f 100644 --- a/webapp/pages/group/create.vue +++ b/webapp/pages/group/create.vue @@ -2,7 +2,7 @@
- +   @@ -14,11 +14,49 @@ From c2a769f325c98665f97ebb1366a7e85ddec70f51 Mon Sep 17 00:00:00 2001 From: ogerly Date: Sun, 14 Aug 2022 12:49:15 +0200 Subject: [PATCH 035/374] add CategoriesSelect in GroupForm.vue --- webapp/components/GroupForm/GroupForm.vue | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/webapp/components/GroupForm/GroupForm.vue b/webapp/components/GroupForm/GroupForm.vue index 2cbc674647..8e29bf300a 100644 --- a/webapp/components/GroupForm/GroupForm.vue +++ b/webapp/components/GroupForm/GroupForm.vue @@ -14,9 +14,13 @@ +
{{ form.name }}
{{ form.status }}
{{ form.description }}
+
{{ form.categoryIds }}
Reset form @@ -39,19 +43,18 @@ export default { CategoriesSelect, }, props:{ - value: { - type: Object, - default: () => ({}), - required: true, - } + model: { type: String, required: true }, + value: { type: String, default: '' }, }, data() { return { + categoriesActive: this.$env.CATEGORIES_ACTIVE, form: { name: '', status: '', description: '', disable: false, + categoryIds: [], } } From dd876c52fab328eabccb1379923f2dbf6ba9891d Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 15 Aug 2022 10:52:31 +0200 Subject: [PATCH 036/374] increas max-old-space-size for jest, handle some asyncs, test validation for caregories only if categories are active --- backend/package.json | 2 +- backend/src/middleware/excerptMiddleware.js | 15 ++++-------- .../src/middleware/slugifyMiddleware.spec.js | 24 +++++++++---------- backend/src/schema/resolvers/groups.js | 6 ++--- 4 files changed, 21 insertions(+), 26 deletions(-) diff --git a/backend/package.json b/backend/package.json index 9651cbb95c..9aa7f539fb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,7 @@ "dev": "nodemon --exec babel-node src/ -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/ -e js,gql", "lint": "eslint src --config .eslintrc.js", - "test": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --runInBand --coverage", + "test": "cross-env NODE_ENV=test NODE_OPTIONS=--max-old-space-size=8192 jest --forceExit --detectOpenHandles --runInBand --coverage", "db:clean": "babel-node src/db/clean.js", "db:reset": "yarn run db:clean", "db:seed": "babel-node src/db/seed.js", diff --git a/backend/src/middleware/excerptMiddleware.js b/backend/src/middleware/excerptMiddleware.js index cfaf7f1b05..ca061609a0 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -4,28 +4,23 @@ export default { Mutation: { CreateGroup: async (resolve, root, args, context, info) => { args.descriptionExcerpt = trunc(args.description, 120).html - const result = await resolve(root, args, context, info) - return result + 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/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 9605aada90..3fea526eef 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -6,7 +6,6 @@ import { createGroupMutation } from '../db/graphql/groups' import { createPostMutation } from '../db/graphql/posts' import { signupVerificationMutation } from '../db/graphql/authentications' -let mutate let authenticatedUser let variables @@ -15,19 +14,20 @@ 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 () => { diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index d1af985138..5737f5505f 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -51,10 +51,10 @@ export default { CreateGroup: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params delete params.categoryIds - if (!categoryIds || categoryIds.length < CATEGORIES_MIN) { + if (CONFIG.CATEGORIES_ACTIVE && (!categoryIds || categoryIds.length < CATEGORIES_MIN)) { throw new UserInputError('Too view categories!') } - if (categoryIds && categoryIds.length > CATEGORIES_MAX) { + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > CATEGORIES_MAX) { throw new UserInputError('Too many categories!') } if ( @@ -94,7 +94,7 @@ export default { `, { userId: context.user.id, categoryIds, params }, ) - const [group] = ownerCreateGroupTransactionResponse.records.map((record) => + const [group] = await ownerCreateGroupTransactionResponse.records.map((record) => record.get('group'), ) return group From beacad4a17b33ececa908b6fe655626f1487f949 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 15 Aug 2022 11:07:45 +0200 Subject: [PATCH 037/374] set CONFIG in specs --- backend/src/schema/resolvers/groups.spec.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index b3327d44a1..707558a063 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -3,6 +3,7 @@ import Factory, { cleanDatabase } from '../../db/factories' import { createGroupMutation, 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() @@ -291,6 +292,10 @@ describe('CreateGroup', () => { }) describe('categories', () => { + beforeEach(() => { + CONFIG.CATEGORIES_ACTIVE = true + }) + describe('not even one', () => { it('throws error: "Too view categories!"', async () => { const { errors } = await mutate({ From 916dfbb46ed08cb7e786b9b16b723ca78c0ccf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 15 Aug 2022 14:00:36 +0200 Subject: [PATCH 038/374] Move or use GQL mutations in seeding to or from separate files --- backend/src/db/graphql/comments.js | 15 +++++++++++++++ backend/src/db/graphql/posts.js | 5 +++-- backend/src/db/seed.js | 18 +++--------------- 3 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 backend/src/db/graphql/comments.js 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/posts.js b/backend/src/db/graphql/posts.js index 3277af8207..237446d41b 100644 --- a/backend/src/db/graphql/posts.js +++ b/backend/src/db/graphql/posts.js @@ -3,8 +3,9 @@ import gql from 'graphql-tag' // ------ mutations export const createPostMutation = gql` - mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { - CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { + mutation ($id: ID, $title: String!, $slug: String, $content: String!, $categoryIds: [ID]!) { + CreatePost(id: $id, title: $title, slug: $slug, content: $content, categoryIds: $categoryIds) { + id slug } } diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index 46c5870e0e..0a0926f06e 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -5,7 +5,9 @@ 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 } from './graphql/groups' +import { createPostMutation } from './graphql/posts' +import { createCommentMutation } from './graphql/comments' if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) { throw new Error(`You cannot seed the database in a non-staging and real production environment!`) @@ -558,13 +560,6 @@ 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({ @@ -615,13 +610,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, From 0f8abe770a1856629efefcbeacb6e6c2eb376fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 15 Aug 2022 15:01:26 +0200 Subject: [PATCH 039/374] Move GQL mutation 'loginMutation' in 'user_management.spec.js' into a separate file --- backend/src/db/graphql/userManagement.js | 13 +++++++++++++ .../src/schema/resolvers/user_management.spec.js | 7 +------ 2 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 backend/src/db/graphql/userManagement.js 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/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index 2dcb148555..15b39e80dd 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' @@ -177,12 +178,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) } From bbda8e6dd06bb39725d5c731041c36dd825c5d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Hu=C3=9F?= Date: Mon, 15 Aug 2022 15:22:49 +0200 Subject: [PATCH 040/374] Seed some groups --- backend/src/db/seed.js | 54 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index 0a0926f06e..64ee3c1dd7 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -5,7 +5,7 @@ import createServer from '../server' import faker from '@faker-js/faker' import Factory from '../db/factories' import { getNeode, getDriver } from '../db/neo4j' -// import { createGroupMutation } from './graphql/groups' +import { createGroupMutation } from './graphql/groups' import { createPostMutation } from './graphql/posts' import { createCommentMutation } from './graphql/comments' @@ -383,6 +383,58 @@ 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: ['cat3', 'cat13', '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: ['cat3', 'cat13', 'cat16'], + }, + }), + ]) + + 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:

  • Use the exercises (consciously) for your personal development.

`, + groupType: 'public', + actionRadius: 'interplanetary', + categoryIds: ['cat3', 'cat13', 'cat16'], + }, + }), + ]) + + // Create Posts + const [p0, p1, p3, p4, p5, p6, p9, p10, p11, p13, p14, p15] = await Promise.all([ Factory.build( 'post', From 936ecf247728456ddc42bb5d3959c79ba03e4bca Mon Sep 17 00:00:00 2001 From: ogerly Date: Mon, 15 Aug 2022 15:35:33 +0200 Subject: [PATCH 041/374] add mutation createGroup and query GroupList --- .../CategoriesSelect/CategoriesSelect.vue | 1 + webapp/components/GroupForm/GroupForm.vue | 91 ++++++++++++----- webapp/components/GroupList/GroupCard.vue | 28 +++--- webapp/components/GroupList/GroupList.vue | 5 +- webapp/components/GroupMember/GroupMember.vue | 98 +++++++++---------- webapp/graphql/groups.js | 16 +-- webapp/pages/group/_id.vue | 17 ++-- webapp/pages/group/_id/_slug.vue | 6 +- webapp/pages/group/create.vue | 43 ++++---- webapp/pages/my-groups.vue | 21 +++- 10 files changed, 183 insertions(+), 143 deletions(-) diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.vue b/webapp/components/CategoriesSelect/CategoriesSelect.vue index b7d71de2d9..1fb95a8db8 100644 --- a/webapp/components/CategoriesSelect/CategoriesSelect.vue +++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue @@ -46,6 +46,7 @@ export default { }, methods: { toggleCategory(id) { + console.log('toggleCategory', id) this.selectedCategoryIds = xor(this.selectedCategoryIds, [id]) if (this.$parentForm) { this.$parentForm.update(this.model, this.selectedCategoryIds) diff --git a/webapp/components/GroupForm/GroupForm.vue b/webapp/components/GroupForm/GroupForm.vue index 8e29bf300a..d52fea0c4b 100644 --- a/webapp/components/GroupForm/GroupForm.vue +++ b/webapp/components/GroupForm/GroupForm.vue @@ -1,30 +1,53 @@