diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js
index f6f675008..e588e1b62 100644
--- a/backend/src/middleware/permissionsMiddleware.js
+++ b/backend/src/middleware/permissionsMiddleware.js
@@ -323,6 +323,7 @@ export default shield(
GenerateInviteCode: isAuthenticated,
switchUserRole: isAdmin,
markTeaserAsViewed: allow,
+ saveCategorySettings: isAuthenticated,
},
User: {
email: or(isMyOwn, isAdmin),
diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js
index 9aac2ab6a..239a299dd 100644
--- a/backend/src/schema/resolvers/groups.js
+++ b/backend/src/schema/resolvers/groups.js
@@ -18,19 +18,25 @@ export default {
if (isMember === true) {
groupCypher = `
MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupIdCypher})
+ WITH group, membership
+ WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role}
`
} else {
if (isMember === false) {
groupCypher = `
MATCH (group:Group${groupIdCypher})
- WHERE NOT (:User {id: $userId})-[:MEMBER_OF]->(group)
+ WHERE (NOT (:User {id: $userId})-[:MEMBER_OF]->(group))
+ WITH group
+ WHERE group.groupType IN ['public', 'closed']
RETURN group {.*, myRole: NULL}
`
} else {
groupCypher = `
MATCH (group:Group${groupIdCypher})
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
+ WITH group, membership
+ WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role}
`
}
diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js
index 2ca40b3e7..e9b38cc2b 100644
--- a/backend/src/schema/resolvers/groups.spec.js
+++ b/backend/src/schema/resolvers/groups.spec.js
@@ -267,6 +267,7 @@ describe('in mode', () => {
describe('authenticated', () => {
let otherUser
+ let ownerOfHiddenGroupUser
beforeAll(async () => {
otherUser = await Factory.build(
@@ -276,7 +277,18 @@ describe('in mode', () => {
name: 'Other TestUser',
},
{
- email: 'test2@example.org',
+ email: 'other-user@example.org',
+ password: '1234',
+ },
+ )
+ ownerOfHiddenGroupUser = await Factory.build(
+ 'user',
+ {
+ id: 'owner-of-hidden-group',
+ name: 'Owner Of Hidden Group',
+ },
+ {
+ email: 'owner-of-hidden-group@example.org',
password: '1234',
},
)
@@ -293,6 +305,59 @@ describe('in mode', () => {
categoryIds,
},
})
+ authenticatedUser = await ownerOfHiddenGroupUser.toJson()
+ await mutate({
+ mutation: createGroupMutation,
+ variables: {
+ id: 'hidden-group',
+ name: 'Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: createGroupMutation,
+ variables: {
+ id: 'second-hidden-group',
+ name: 'Second Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation,
+ variables: {
+ groupId: 'second-hidden-group',
+ userId: 'current-user',
+ roleInGroup: 'pending',
+ },
+ })
+ await mutate({
+ mutation: createGroupMutation,
+ variables: {
+ id: 'third-hidden-group',
+ name: 'Third Investigative Journalism Group',
+ about: 'We will change all.',
+ description: 'We research …' + descriptionAdditional100,
+ groupType: 'hidden',
+ actionRadius: 'global',
+ categoryIds,
+ },
+ })
+ await mutate({
+ mutation: changeGroupMemberRoleMutation,
+ variables: {
+ groupId: 'third-hidden-group',
+ userId: 'current-user',
+ roleInGroup: 'usual',
+ },
+ })
authenticatedUser = await user.toJson()
await mutate({
mutation: createGroupMutation,
@@ -309,117 +374,175 @@ describe('in mode', () => {
})
describe('query groups', () => {
- describe('without any filters', () => {
- it('finds all groups', async () => {
- await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({
- 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,
- })
- })
-
- describe('categories', () => {
- beforeEach(() => {
- CONFIG.CATEGORIES_ACTIVE = true
- })
-
- it('has set categories', async () => {
- await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({
+ describe('in general finds only listed groups – no hidden groups where user is none or pending member', () => {
+ describe('without any filters', () => {
+ it('finds all listed groups', async () => {
+ const result = await query({ query: groupQuery, variables: {} })
+ expect(result).toMatchObject({
data: {
Group: expect.arrayContaining([
expect.objectContaining({
id: 'my-group',
slug: 'the-best-group',
- categories: expect.arrayContaining([
- expect.objectContaining({ id: 'cat4' }),
- expect.objectContaining({ id: 'cat9' }),
- expect.objectContaining({ id: 'cat15' }),
- ]),
myRole: 'owner',
}),
expect.objectContaining({
id: 'others-group',
slug: 'uninteresting-group',
- categories: expect.arrayContaining([
- expect.objectContaining({ id: 'cat4' }),
- expect.objectContaining({ id: 'cat9' }),
- expect.objectContaining({ id: 'cat15' }),
- ]),
+ myRole: null,
+ }),
+ expect.objectContaining({
+ id: 'third-hidden-group',
+ slug: 'third-investigative-journalism-group',
+ myRole: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(3)
+ })
+
+ describe('categories', () => {
+ beforeEach(() => {
+ CONFIG.CATEGORIES_ACTIVE = true
+ })
+
+ it('has set categories', async () => {
+ await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'my-group',
+ slug: 'the-best-group',
+ categories: expect.arrayContaining([
+ expect.objectContaining({ id: 'cat4' }),
+ expect.objectContaining({ id: 'cat9' }),
+ expect.objectContaining({ id: 'cat15' }),
+ ]),
+ myRole: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'others-group',
+ slug: 'uninteresting-group',
+ categories: expect.arrayContaining([
+ expect.objectContaining({ id: 'cat4' }),
+ expect.objectContaining({ id: 'cat9' }),
+ expect.objectContaining({ id: 'cat15' }),
+ ]),
+ myRole: null,
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ })
+ })
+ })
+
+ describe('with given id', () => {
+ describe("id = 'my-group'", () => {
+ it('finds only the listed group with this id', async () => {
+ const result = await query({ query: groupQuery, variables: { id: 'my-group' } })
+ expect(result).toMatchObject({
+ data: {
+ Group: [
+ expect.objectContaining({
+ id: 'my-group',
+ slug: 'the-best-group',
+ myRole: 'owner',
+ }),
+ ],
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(1)
+ })
+ })
+
+ describe("id = 'third-hidden-group'", () => {
+ it("finds only the hidden group where I'm 'usual' member", async () => {
+ const result = await query({
+ query: groupQuery,
+ variables: { id: 'third-hidden-group' },
+ })
+ expect(result).toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'third-hidden-group',
+ slug: 'third-investigative-journalism-group',
+ myRole: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(1)
+ })
+ })
+
+ describe("id = 'second-hidden-group'", () => {
+ it("finds no hidden group where I'm 'pending' member", async () => {
+ const result = await query({
+ query: groupQuery,
+ variables: { id: 'second-hidden-group' },
+ })
+ expect(result.data.Group.length).toBe(0)
+ })
+ })
+
+ describe("id = 'hidden-group'", () => {
+ it("finds no hidden group where I'm not(!) a member at all", async () => {
+ const result = await query({
+ query: groupQuery,
+ variables: { id: 'hidden-group' },
+ })
+ expect(result.data.Group.length).toBe(0)
+ })
+ })
+ })
+
+ describe('isMember = true', () => {
+ it('finds only listed groups where user is member', async () => {
+ const result = await query({ query: groupQuery, variables: { isMember: true } })
+ expect(result).toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'my-group',
+ slug: 'the-best-group',
+ myRole: 'owner',
+ }),
+ expect.objectContaining({
+ id: 'third-hidden-group',
+ slug: 'third-investigative-journalism-group',
+ myRole: 'usual',
+ }),
+ ]),
+ },
+ errors: undefined,
+ })
+ expect(result.data.Group.length).toBe(2)
+ })
+ })
+
+ describe('isMember = false', () => {
+ it('finds only listed groups where user is not(!) member', async () => {
+ const result = await query({ query: groupQuery, variables: { isMember: false } })
+ expect(result).toMatchObject({
+ data: {
+ Group: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'others-group',
+ slug: 'uninteresting-group',
myRole: null,
}),
]),
},
errors: undefined,
})
- })
- })
- })
-
- describe("id = 'my-group'", () => {
- it('finds only the group with this id', async () => {
- await expect(
- query({ query: groupQuery, variables: { id: 'my-group' } }),
- ).resolves.toMatchObject({
- data: {
- Group: [
- expect.objectContaining({
- id: 'my-group',
- slug: 'the-best-group',
- myRole: 'owner',
- }),
- ],
- },
- errors: undefined,
- })
- })
- })
-
- describe('isMember = true', () => {
- it('finds only groups where user is member', async () => {
- await expect(
- query({ query: groupQuery, variables: { isMember: true } }),
- ).resolves.toMatchObject({
- data: {
- Group: [
- {
- id: 'my-group',
- slug: 'the-best-group',
- myRole: 'owner',
- },
- ],
- },
- errors: undefined,
- })
- })
- })
-
- describe('isMember = false', () => {
- it('finds only groups where user is not(!) member', async () => {
- await expect(
- query({ query: groupQuery, variables: { isMember: false } }),
- ).resolves.toMatchObject({
- data: {
- Group: expect.arrayContaining([
- expect.objectContaining({
- id: 'others-group',
- slug: 'uninteresting-group',
- myRole: null,
- }),
- ]),
- },
- errors: undefined,
+ expect(result.data.Group.length).toBe(1)
})
})
})
diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js
index 5dc78c5e1..23a39a2a1 100644
--- a/backend/src/schema/resolvers/users.js
+++ b/backend/src/schema/resolvers/users.js
@@ -269,6 +269,45 @@ export default {
session.close()
}
},
+ saveCategorySettings: async (object, args, context, resolveInfo) => {
+ const { activeCategories } = args
+ const {
+ user: { id },
+ } = context
+
+ const session = context.driver.session()
+ await session.writeTransaction((transaction) => {
+ return transaction.run(
+ `
+ MATCH (user:User { id: $id })-[previousCategories:NOT_INTERESTED_IN]->(category:Category)
+ DELETE previousCategories
+ RETURN user, category
+ `,
+ { id },
+ )
+ })
+ const writeTxResultPromise = session.writeTransaction(async (transaction) => {
+ const saveCategorySettingsResponse = await transaction.run(
+ `
+ MATCH (category:Category) WHERE NOT category.id IN $activeCategories
+ MATCH (user:User { id: $id })
+ MERGE (user)-[r:NOT_INTERESTED_IN]->(category)
+ RETURN user, r, category
+ `,
+ { id, activeCategories },
+ )
+ const [user] = await saveCategorySettingsResponse.records.map((record) =>
+ record.get('user'),
+ )
+ return user
+ })
+ try {
+ await writeTxResultPromise
+ return true
+ } finally {
+ session.close()
+ }
+ },
},
User: {
email: async (parent, params, context, resolveInfo) => {
diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js
index 920ef52ea..7116d2201 100644
--- a/backend/src/schema/resolvers/users.spec.js
+++ b/backend/src/schema/resolvers/users.spec.js
@@ -3,6 +3,7 @@ import { gql } from '../../helpers/jest'
import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing'
+import { categories } from '../../constants/categories'
const categoryIds = ['cat9']
let user
@@ -56,6 +57,12 @@ const switchUserRoleMutation = gql`
}
`
+const saveCategorySettings = gql`
+ mutation ($activeCategories: [String]) {
+ saveCategorySettings(activeCategories: $activeCategories)
+ }
+`
+
beforeAll(async () => {
await cleanDatabase()
@@ -544,3 +551,146 @@ describe('switch user role', () => {
})
})
})
+
+describe('save category settings', () => {
+ beforeEach(async () => {
+ await Promise.all(
+ categories.map(({ icon, name }, index) => {
+ Factory.build('category', {
+ id: `cat${index + 1}`,
+ slug: name,
+ name,
+ icon,
+ })
+ }),
+ )
+ })
+
+ beforeEach(async () => {
+ user = await Factory.build('user', {
+ id: 'user',
+ role: 'user',
+ })
+ variables = {
+ activeCategories: ['cat1', 'cat3', 'cat5'],
+ }
+ })
+
+ describe('not authenticated', () => {
+ beforeEach(async () => {
+ authenticatedUser = undefined
+ })
+
+ it('throws an error', async () => {
+ await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual(
+ expect.objectContaining({
+ errors: [
+ expect.objectContaining({
+ message: 'Not Authorized!',
+ }),
+ ],
+ }),
+ )
+ })
+ })
+
+ describe('authenticated', () => {
+ beforeEach(async () => {
+ authenticatedUser = await user.toJson()
+ })
+
+ const userQuery = gql`
+ query ($id: ID) {
+ User(id: $id) {
+ activeCategories {
+ id
+ }
+ }
+ }
+ `
+
+ describe('no categories saved', () => {
+ it('returns true for active categories mutation', async () => {
+ await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual(
+ expect.objectContaining({
+ data: { saveCategorySettings: true },
+ }),
+ )
+ })
+
+ describe('query for user', () => {
+ beforeEach(async () => {
+ await mutate({ mutation: saveCategorySettings, variables })
+ })
+
+ it('returns the active categories when user is queried', async () => {
+ await expect(
+ query({ query: userQuery, variables: { id: authenticatedUser.id } }),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ data: {
+ User: [
+ {
+ activeCategories: expect.arrayContaining([
+ { id: 'cat1' },
+ { id: 'cat3' },
+ { id: 'cat5' },
+ ]),
+ },
+ ],
+ },
+ }),
+ )
+ })
+ })
+ })
+
+ describe('categories already saved', () => {
+ beforeEach(async () => {
+ variables = {
+ activeCategories: ['cat1', 'cat3', 'cat5'],
+ }
+ await mutate({ mutation: saveCategorySettings, variables })
+ variables = {
+ activeCategories: ['cat10', 'cat11', 'cat12', 'cat8', 'cat9'],
+ }
+ })
+
+ it('returns true', async () => {
+ await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual(
+ expect.objectContaining({
+ data: { saveCategorySettings: true },
+ }),
+ )
+ })
+
+ describe('query for user', () => {
+ beforeEach(async () => {
+ await mutate({ mutation: saveCategorySettings, variables })
+ })
+
+ it('returns the new active categories when user is queried', async () => {
+ await expect(
+ query({ query: userQuery, variables: { id: authenticatedUser.id } }),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ data: {
+ User: [
+ {
+ activeCategories: expect.arrayContaining([
+ { id: 'cat10' },
+ { id: 'cat11' },
+ { id: 'cat12' },
+ { id: 'cat8' },
+ { id: 'cat9' },
+ ]),
+ },
+ ],
+ },
+ }),
+ )
+ })
+ })
+ })
+ })
+})
diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql
index 4219cd00e..3d71aac78 100644
--- a/backend/src/schema/types/type/User.gql
+++ b/backend/src/schema/types/type/User.gql
@@ -115,6 +115,14 @@ type User {
emotions: [EMOTED]
+ activeCategories: [Category] @cypher(
+ statement: """
+ MATCH (category:Category)
+ WHERE NOT ((this)-[:NOT_INTERESTED_IN]->(category))
+ RETURN category
+ """
+ )
+
myRoleInGroup: GroupMemberRole
}
@@ -222,4 +230,6 @@ type Mutation {
unblockUser(id: ID!): User
switchUserRole(role: UserRole!, id: ID!): User
+
+ saveCategorySettings(activeCategories: [String]): Boolean
}
diff --git a/webapp/components/FilterMenu/CategoriesMenu.vue b/webapp/components/FilterMenu/CategoriesMenu.vue
new file mode 100644
index 000000000..37bd19c84
--- /dev/null
+++ b/webapp/components/FilterMenu/CategoriesMenu.vue
@@ -0,0 +1,58 @@
+
+