Merge branch 'master' of github.com:Ocelot-Social-Community/Ocelot-Social into 5059-epic-groups

# Conflicts:
#	backend/src/schema/types/type/User.gql
This commit is contained in:
Wolfgang Huß 2022-09-08 13:29:29 +02:00
commit 89920f387a
11 changed files with 397 additions and 26 deletions

View File

@ -64,6 +64,7 @@ your `.env` configuration file.
Your backend is up and running at [http://localhost:4000/](http://localhost:4000/) Your backend is up and running at [http://localhost:4000/](http://localhost:4000/)
This will start the GraphQL service \(by default on localhost:4000\) where you This will start the GraphQL service \(by default on localhost:4000\) where you
can issue GraphQL requests or access GraphQL Playground in the browser. can issue GraphQL requests or access GraphQL Playground in the browser.
More details about our GraphQL playground and how to use it with ocelot.social can be found [here](./src/graphql/GraphQL-Playground.md).
![GraphQL Playground](../.gitbook/assets/graphql-playground.png) ![GraphQL Playground](../.gitbook/assets/graphql-playground.png)

View File

@ -0,0 +1,108 @@
# GraphQL Playground
To use GraphQL Playground, we need to know some basics:
## How To Login?
First, we need to have a user from ocelot.social to log in as.
The user can be created by seeding the Neo4j database from the backend or by multiple GraphQL mutations.
### Seed The Neo4j Database
In your browser you can reach the GraphQL Playground under `http://localhost:4000/`, if the database and the backend are running, see [backend](../../README.md).
There you will also find instructions on how to seed the database.
### Use GraphQL Mutations To Create A User
TODO: Describe how to create a user using GraphQL mutations!
### Login Via GraphQL
You can register a user by sending the query:
```gql
mutation {
login(email: "user@example.org", password: "1234")
}
```
Or use `"moderator@example.org"` or `"admin@example.org"` for the roll you need.
If all goes well, you will receive a QGL response like:
```json
{
"data": {
"login": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InUzIiwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTY2MjAyMzMwNSwiZXhwIjoxNzI1MTM4NTA1LCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.atBS-SOeS784HPeFl_5s8sRWehEAU1BkgcOZFD8d4bU"
}
}
```
You can use this response to set an HTTP header when you click `HTTP HEADERS` in the footer.
Just set it with the login token you received in response:
```json
{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InUzIiwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTY2MjAyMzMwNSwiZXhwIjoxNzI1MTM4NTA1LCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.atBS-SOeS784HPeFl_5s8sRWehEAU1BkgcOZFD8d4bU"
}
```
This token is used for all other queries and mutations you send to the backend.
## Query And Mutate
When you are logged in and open a new playground tab by clicking "+", you can create a new group by sending the following mutation:
```gql
mutation {
CreateGroup(
# id: ""
name: "My Group"
# slug: ""
about: "We will save the world"
description: "<p class=\"\"><em>English:</em></p><p class=\"\">This group is hidden.</p><h3>What is our group for?</h3><p>This group was created to allow investigative journalists to share and collaborate.</p><h3>How does it work?</h3><p>Here you can internally share posts and comments about them.</p><p><br></p><p><em>Deutsch:</em></p><p class=\"\">Diese Gruppe ist verborgen.</p><h3>Wofür ist unsere Gruppe?</h3><p class=\"\">Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.</p><h3>Wie funktioniert das?</h3><p class=\"\">Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.</p>"
groupType: hidden
actionRadius: interplanetary
categoryIds: ["cat12"]
) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
groupType
actionRadius
myRole
}
}
```
You will receive the answer:
```json
{
"data": {
"CreateGroup": {
"id": "2e3bbadb-804b-4ebc-a673-2d7c7f05e827",
"name": "My Group",
"slug": "my-group",
"createdAt": "2022-09-01T09:44:47.969Z",
"updatedAt": "2022-09-01T09:44:47.969Z",
"disabled": false,
"deleted": false,
"about": "We will save the world",
"description": "<p class=\"\"><em>English:</em></p><p class=\"\">This group is hidden.</p><h3>What is our group for?</h3><p>This group was created to allow investigative journalists to share and collaborate.</p><h3>How does it work?</h3><p>Here you can internally share posts and comments about them.</p><p><br></p><p><em>Deutsch:</em></p><p class=\"\">Diese Gruppe ist verborgen.</p><h3>Wofür ist unsere Gruppe?</h3><p class=\"\">Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.</p><h3>Wie funktioniert das?</h3><p class=\"\">Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.</p>",
"groupType": "hidden",
"actionRadius": "interplanetary",
"myRole": "owner"
}
}
}
```
If you look into the Neo4j database with your browser and search the groups, you will now also find your new group.
For more details about our Neo4j database read [here](../../../neo4j/README.md).

View File

@ -323,6 +323,7 @@ export default shield(
GenerateInviteCode: isAuthenticated, GenerateInviteCode: isAuthenticated,
switchUserRole: isAdmin, switchUserRole: isAdmin,
markTeaserAsViewed: allow, markTeaserAsViewed: allow,
saveCategorySettings: isAuthenticated,
}, },
User: { User: {
email: or(isMyOwn, isAdmin), email: or(isMyOwn, isAdmin),

View File

@ -269,6 +269,45 @@ export default {
session.close() 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: { User: {
email: async (parent, params, context, resolveInfo) => { email: async (parent, params, context, resolveInfo) => {

View File

@ -3,6 +3,7 @@ import { gql } from '../../helpers/jest'
import { getNeode, getDriver } from '../../db/neo4j' import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server' import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import { categories } from '../../constants/categories'
const categoryIds = ['cat9'] const categoryIds = ['cat9']
let user let user
@ -56,6 +57,12 @@ const switchUserRoleMutation = gql`
} }
` `
const saveCategorySettings = gql`
mutation ($activeCategories: [String]) {
saveCategorySettings(activeCategories: $activeCategories)
}
`
beforeAll(async () => { beforeAll(async () => {
await cleanDatabase() 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' },
]),
},
],
},
}),
)
})
})
})
})
})

View File

@ -115,6 +115,14 @@ type User {
emotions: [EMOTED] emotions: [EMOTED]
activeCategories: [Category] @cypher(
statement: """
MATCH (category:Category)
WHERE NOT ((this)-[:NOT_INTERESTED_IN]->(category))
RETURN category
"""
)
myRoleInGroup: GroupMemberRole myRoleInGroup: GroupMemberRole
} }
@ -222,4 +230,6 @@ type Mutation {
unblockUser(id: ID!): User unblockUser(id: ID!): User
switchUserRole(role: UserRole!, id: ID!): User switchUserRole(role: UserRole!, id: ID!): User
saveCategorySettings(activeCategories: [String]): Boolean
} }

View File

@ -0,0 +1,58 @@
<template>
<dropdown ref="category-menu" placement="top-start" :offset="8" class="category-menu">
<base-button
slot="default"
:filled="filterActive"
:ghost="!filterActive"
slot-scope="{ toggleMenu }"
@click.prevent="toggleMenu()"
>
<ds-text uppercase>{{ $t('admin.categories.name') }}</ds-text>
</base-button>
<template slot="popover">
<div class="category-menu-options">
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
<categories-filter v-if="categoriesActive" />
</div>
</template>
</dropdown>
</template>
<script>
import Dropdown from '~/components/Dropdown'
import { mapGetters } from 'vuex'
import CategoriesFilter from './CategoriesFilter'
export default {
name: 'CategoriesMenu',
components: {
Dropdown,
CategoriesFilter,
},
props: {
placement: { type: String },
offset: { type: [String, Number] },
},
data() {
return {
categoriesActive: this.$env.CATEGORIES_ACTIVE,
}
},
computed: {
...mapGetters({
filterActive: 'posts/isActive',
}),
},
}
</script>
<style lang="scss">
.category-menu-options {
max-width: $size-max-width-filter-menu;
padding: $space-small $space-x-small;
> .title {
font-size: $font-size-large;
}
}
</style>

View File

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

View File

@ -15,6 +15,15 @@
> >
<base-button icon="bars" @click="toggleMobileMenuView" circle /> <base-button icon="bars" @click="toggleMobileMenuView" circle />
</ds-flex-item> </ds-flex-item>
<ds-flex-item
v-if="categoriesActive && isLoggedIn"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="flex-grow: 0; flex-basis: auto"
>
<client-only>
<categories-menu></categories-menu>
</client-only>
</ds-flex-item>
<ds-flex-item <ds-flex-item
:width="{ base: '45%', sm: '45%', md: '45%', lg: '50%' }" :width="{ base: '45%', sm: '45%', md: '45%', lg: '50%' }"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }" :class="{ 'hide-mobile-menu': !toggleMobileMenu }"
@ -90,6 +99,7 @@ import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
import PageFooter from '~/components/PageFooter/PageFooter' import PageFooter from '~/components/PageFooter/PageFooter'
import AvatarMenu from '~/components/AvatarMenu/AvatarMenu' import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
import InviteButton from '~/components/InviteButton/InviteButton' import InviteButton from '~/components/InviteButton/InviteButton'
import CategoriesMenu from '~/components/FilterMenu/CategoriesMenu.vue'
export default { export default {
components: { components: {
@ -102,6 +112,7 @@ export default {
FilterMenu, FilterMenu,
PageFooter, PageFooter,
InviteButton, InviteButton,
CategoriesMenu,
}, },
mixins: [seo], mixins: [seo],
data() { data() {
@ -109,6 +120,7 @@ export default {
mobileSearchVisible: false, mobileSearchVisible: false,
toggleMobileMenu: false, toggleMobileMenu: false,
inviteRegistration: this.$env.INVITE_REGISTRATION === true, // for 'false' in .env INVITE_REGISTRATION is of type undefined and not(!) boolean false, because of internal handling, inviteRegistration: this.$env.INVITE_REGISTRATION === true, // for 'false' in .env INVITE_REGISTRATION is of type undefined and not(!) boolean false, because of internal handling,
categoriesActive: this.$env.CATEGORIES_ACTIVE,
} }
}, },
computed: { computed: {

View File

@ -11,7 +11,7 @@
"admin": { "admin": {
"categories": { "categories": {
"categoryName": "Name", "categoryName": "Name",
"name": "Kategorien", "name": "Themen",
"postCount": "Beiträge" "postCount": "Beiträge"
}, },
"dashboard": { "dashboard": {
@ -98,7 +98,7 @@
} }
}, },
"common": { "common": {
"category": "Kategorie ::: Kategorien", "category": "Thema ::: Themen",
"comment": "Kommentar ::: Kommentare", "comment": "Kommentar ::: Kommentare",
"letsTalk": "Miteinander reden", "letsTalk": "Miteinander reden",
"loading": "wird geladen", "loading": "wird geladen",
@ -113,7 +113,7 @@
"takeAction": "Aktiv werden", "takeAction": "Aktiv werden",
"user": "Benutzer ::: Benutzer", "user": "Benutzer ::: Benutzer",
"validations": { "validations": {
"categories": "es müssen eine bis drei Kategorien ausgewählt werden", "categories": "es müssen eine bis drei Themen ausgewählt werden",
"email": "muss eine gültige E-Mail-Adresse sein", "email": "muss eine gültige E-Mail-Adresse sein",
"url": "muss eine gültige URL sein" "url": "muss eine gültige URL sein"
}, },
@ -214,7 +214,7 @@
"amount-shouts": "{amount} recommendations", "amount-shouts": "{amount} recommendations",
"amount-views": "{amount} views", "amount-views": "{amount} views",
"categories": { "categories": {
"infoSelectedNoOfMaxCategories": "{chosen} von {max} Kategorien ausgewählt" "infoSelectedNoOfMaxCategories": "{chosen} von {max} Themen ausgewählt"
}, },
"category": { "category": {
"name": { "name": {
@ -349,7 +349,7 @@
}, },
"filter-menu": { "filter-menu": {
"all": "Alle", "all": "Alle",
"categories": "Themenkategorien", "categories": "Themen",
"emotions": "Emotionen", "emotions": "Emotionen",
"filter-by": "Filtern nach ...", "filter-by": "Filtern nach ...",
"following": "Benutzern, denen ich folge", "following": "Benutzern, denen ich folge",
@ -471,7 +471,7 @@
"noDecision": "Keine Entscheidung!", "noDecision": "Keine Entscheidung!",
"numberOfUsers": "{count} Nutzern", "numberOfUsers": "{count} Nutzern",
"previousDecision": "Vorherige Entscheidung:", "previousDecision": "Vorherige Entscheidung:",
"reasonCategory": "Kategorie", "reasonCategory": "Thema",
"reasonDescription": "Beschreibung", "reasonDescription": "Beschreibung",
"reportedOn": "Datum", "reportedOn": "Datum",
"status": "Aktueller Status", "status": "Aktueller Status",
@ -593,8 +593,8 @@
}, },
"reason": { "reason": {
"category": { "category": {
"invalid": "Bitte wähle eine gültige Kategorie aus", "invalid": "Bitte wähle ein gültiges Thema aus",
"label": "Wähle eine Kategorie:", "label": "Wähle ein Thema:",
"options": { "options": {
"advert_products_services_commercial": "Bewerben von Produkten und Dienstleistungen mit kommerzieller Absicht.", "advert_products_services_commercial": "Bewerben von Produkten und Dienstleistungen mit kommerzieller Absicht.",
"criminal_behavior_violation_german_law": "Strafbares Verhalten bzw. Verstoß gegen deutsches Recht.", "criminal_behavior_violation_german_law": "Strafbares Verhalten bzw. Verstoß gegen deutsches Recht.",
@ -605,7 +605,7 @@
"other": "Andere …", "other": "Andere …",
"pornographic_content_links": "Das Senden oder Verlinken eindeutig pornografischen Materials." "pornographic_content_links": "Das Senden oder Verlinken eindeutig pornografischen Materials."
}, },
"placeholder": "Kategorie …" "placeholder": "Thema …"
}, },
"description": { "description": {
"label": "Bitte erkläre: Warum möchtest Du dies melden?", "label": "Bitte erkläre: Warum möchtest Du dies melden?",

View File

@ -11,7 +11,7 @@
"admin": { "admin": {
"categories": { "categories": {
"categoryName": "Name", "categoryName": "Name",
"name": "Categories", "name": "Topics",
"postCount": "Posts" "postCount": "Posts"
}, },
"dashboard": { "dashboard": {
@ -98,7 +98,7 @@
} }
}, },
"common": { "common": {
"category": "Category ::: Categories", "category": "Topic ::: Topics",
"comment": "Comment ::: Comments", "comment": "Comment ::: Comments",
"letsTalk": "Let`s Talk", "letsTalk": "Let`s Talk",
"loading": "loading", "loading": "loading",
@ -113,7 +113,7 @@
"takeAction": "Take Action", "takeAction": "Take Action",
"user": "User ::: Users", "user": "User ::: Users",
"validations": { "validations": {
"categories": "at least one and at most three categories must be selected", "categories": "at least one and at most three topics must be selected",
"email": "must be a valid e-mail address", "email": "must be a valid e-mail address",
"url": "must be a valid URL" "url": "must be a valid URL"
}, },
@ -214,7 +214,7 @@
"amount-shouts": "{amount} recommendations", "amount-shouts": "{amount} recommendations",
"amount-views": "{amount} views", "amount-views": "{amount} views",
"categories": { "categories": {
"infoSelectedNoOfMaxCategories": "{chosen} of {max} categories selected" "infoSelectedNoOfMaxCategories": "{chosen} of {max} topics selected"
}, },
"category": { "category": {
"name": { "name": {
@ -349,7 +349,7 @@
}, },
"filter-menu": { "filter-menu": {
"all": "All", "all": "All",
"categories": "Categories of Content", "categories": "Topics",
"emotions": "Emotions", "emotions": "Emotions",
"filter-by": "Filter by ...", "filter-by": "Filter by ...",
"following": "Users I follow", "following": "Users I follow",
@ -471,7 +471,7 @@
"noDecision": "No decision!", "noDecision": "No decision!",
"numberOfUsers": "{count} users", "numberOfUsers": "{count} users",
"previousDecision": "Previous decision:", "previousDecision": "Previous decision:",
"reasonCategory": "Category", "reasonCategory": "Topic",
"reasonDescription": "Description", "reasonDescription": "Description",
"reportedOn": "Date", "reportedOn": "Date",
"status": "Current status", "status": "Current status",
@ -593,8 +593,8 @@
}, },
"reason": { "reason": {
"category": { "category": {
"invalid": "Please select a valid category", "invalid": "Please select a valid topic",
"label": "Select a category:", "label": "Select a topic:",
"options": { "options": {
"advert_products_services_commercial": "Advertising products and services with commercial intent.", "advert_products_services_commercial": "Advertising products and services with commercial intent.",
"criminal_behavior_violation_german_law": "Criminal behavior or violation of German law.", "criminal_behavior_violation_german_law": "Criminal behavior or violation of German law.",
@ -605,7 +605,7 @@
"other": "Other …", "other": "Other …",
"pornographic_content_links": "Posting or linking of clearly pornographic material." "pornographic_content_links": "Posting or linking of clearly pornographic material."
}, },
"placeholder": "Category …" "placeholder": "Topic …"
}, },
"description": { "description": {
"label": "Please explain: Why you like to report this?", "label": "Please explain: Why you like to report this?",