Merge branch 'master' into deployment-reseed

This commit is contained in:
Ulf Gebhardt 2023-03-14 21:22:46 +01:00 committed by GitHub
commit ee8d9233a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1292 additions and 330 deletions

View File

@ -46,6 +46,11 @@ jobs:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
ref: ${{ github.event.client_payload.ref }}
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v2
with:

View File

@ -293,7 +293,7 @@ jobs:
echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
- run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v1
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ github.token }}
event-type: trigger-build-success

View File

@ -329,19 +329,16 @@ jobs:
- name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
- name: cypress | Fullstack tests
id: e2e-tests
run: |
yarn install
yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} )
##########################################################################
# UPLOAD SCREENSHOTS & VIDEO #############################################
# UPLOAD SCREENSHOTS - IF TESTS FAIL #####################################
##########################################################################
- name: Upload Artifact
- name: Full stack tests | if any test failed, upload screenshots
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v3
with:
name: cypress-screenshots
path: cypress/screenshots/
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: cypress-videos
path: cypress/videos/

View File

@ -197,7 +197,8 @@ Prepare database once before you start by running the following command in a sec
```bash
# in main folder while docker-compose is up
$ docker-compose exec backend yarn run db:migrate init
$ docker compose exec backend yarn run db:migrate init
$ docker compose exec backend yarn run db:migrate up
```
Then clear and seed database by running the following command as well in the second terminal:

View File

@ -81,8 +81,7 @@ More details about our GraphQL playground and how to use it with ocelot.social c
### Database Indexes and Constraints
Database indexes and constraints need to be created when the database and the
backend is running:
Database indexes and constraints need to be created and upgraded when the database and the backend are running:
{% tabs %}
{% tab title="Docker" %}
@ -98,6 +97,11 @@ $ docker compose exec backend yarn prod:migrate init
$ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
```
```bash
# in main folder with docker compose running
$ docker exec backend yarn run db:migrate up
```
{% endtab %}
{% tab title="Without Docker" %}
@ -107,6 +111,11 @@ $ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
yarn run db:migrate init
```
```bash
# in backend/ with database running (In docker or local)
yarn run db:migrate up
```
{% endtab %}
{% endtabs %}
@ -134,6 +143,8 @@ $ docker exec backend yarn run db:reset
$ docker-compose down -v
# if container is not running, run this command to set up your database indexes and constraints
$ docker exec backend yarn run db:migrate init
# And then upgrade the indexes and const
$ docker exec backend yarn run db:migrate up
```
{% endtab %}

View File

@ -150,6 +150,19 @@ export const changeGroupMemberRoleMutation = () => {
`
}
export const removeUserFromGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
RemoveUserFromGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
}
}
`
}
// ------ queries
export const groupQuery = () => {

View File

@ -0,0 +1,65 @@
import gql from 'graphql-tag'
// ------ mutations
export const markAsReadMutation = () => {
return gql`
mutation ($id: ID!) {
markAsRead(id: $id) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
}
export const markAllAsReadMutation = () => {
return gql`
mutation {
markAllAsRead {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
}
// ------ queries
export const notificationQuery = () => {
return gql`
query ($read: Boolean, $orderBy: NotificationOrdering) {
notifications(read: $read, orderBy: $orderBy) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
}

View File

@ -253,6 +253,42 @@ const isMemberOfGroup = rule({
}
})
const canRemoveUserFromGroup = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { groupId, userId } = args
const currentUserId = user.id
if (currentUserId === userId) return false
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (User {id: $currentUserId})-[currentUserMembership:MEMBER_OF]->(group:Group {id: $groupId})
OPTIONAL MATCH (group)<-[userMembership:MEMBER_OF]-(user:User { id: $userId })
RETURN currentUserMembership.role AS currentUserRole, userMembership.role AS userRole
`,
{ currentUserId, groupId, userId },
)
return {
currentUserRole: transactionResponse.records.map((record) =>
record.get('currentUserRole'),
)[0],
userRole: transactionResponse.records.map((record) => record.get('userRole'))[0],
}
})
try {
const { currentUserRole, userRole } = await readTxPromise
return (
currentUserRole && ['owner'].includes(currentUserRole) && userRole && userRole !== 'owner'
)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const canCommentPost = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
@ -382,6 +418,7 @@ export default shield(
JoinGroup: isAllowedToJoinGroup,
LeaveGroup: isAllowedToLeaveGroup,
ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole,
RemoveUserFromGroup: canRemoveUserFromGroup,
CreatePost: and(isAuthenticated, isMemberOfGroup),
UpdatePost: isAuthor,
DeletePost: isAuthor,
@ -412,6 +449,7 @@ export default shield(
blockUser: isAuthenticated,
unblockUser: isAuthenticated,
markAsRead: isAuthenticated,
markAllAsRead: isAuthenticated,
AddEmailAddress: isAuthenticated,
VerifyEmailAddress: isAuthenticated,
pinPost: isAdmin,

View File

@ -16,6 +16,7 @@ export default {
Group: async (_object, params, context, _resolveInfo) => {
const { isMember, id, slug, first, offset } = params
let pagination = ''
const orderBy = 'ORDER BY group.createdAt DESC'
if (first !== undefined && offset !== undefined) pagination = `SKIP ${offset} LIMIT ${first}`
const matchParams = { id, slug }
removeUndefinedNullValuesFromObject(matchParams)
@ -29,6 +30,7 @@ export default {
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}
${orderBy}
${pagination}
`
} else {
@ -39,6 +41,7 @@ export default {
WITH group
WHERE group.groupType IN ['public', 'closed']
RETURN group {.*, myRole: NULL}
${orderBy}
${pagination}
`
} else {
@ -48,6 +51,7 @@ export default {
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}
${orderBy}
${pagination}
`
}
@ -295,25 +299,8 @@ export default {
LeaveGroup: async (_parent, params, context, _resolveInfo) => {
const { groupId, userId } = params
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const leaveGroupCypher = `
MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
DELETE membership
WITH member, group
OPTIONAL MATCH (p:Post)-[:IN]->(group)
WHERE NOT group.groupType = 'public'
WITH member, group, collect(p) AS posts
FOREACH (post IN posts |
MERGE (member)-[:CANNOT_SEE]->(post))
RETURN member {.*, myRoleInGroup: NULL}
`
const transactionResponse = await transaction.run(leaveGroupCypher, { groupId, userId })
const [member] = await transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
return await writeTxResultPromise
return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId)
} catch (error) {
throw new Error(error)
} finally {
@ -368,6 +355,17 @@ export default {
session.close()
}
},
RemoveUserFromGroup: async (_parent, params, context, _resolveInfo) => {
const { groupId, userId } = params
const session = context.driver.session()
try {
return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
},
Group: {
...Resolver('Group', {
@ -383,3 +381,27 @@ export default {
}),
},
}
const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId) => {
return session.writeTransaction(async (transaction) => {
const removeUserFromGroupCypher = `
MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
DELETE membership
WITH user, group
OPTIONAL MATCH (author:User)-[:WROTE]->(p:Post)-[:IN]->(group)
WHERE NOT group.groupType = 'public'
AND NOT author.id = $userId
WITH user, collect(p) AS posts
FOREACH (post IN posts |
MERGE (user)-[:CANNOT_SEE]->(post))
RETURN user {.*, myRoleInGroup: NULL}
`
const transactionResponse = await transaction.run(removeUserFromGroupCypher, {
groupId,
userId,
})
const [user] = await transactionResponse.records.map((record) => record.get('user'))
return user
})
}

View File

@ -6,6 +6,7 @@ import {
joinGroupMutation,
leaveGroupMutation,
changeGroupMemberRoleMutation,
removeUserFromGroupMutation,
groupMembersQuery,
groupQuery,
} from '../../graphql/groups'
@ -196,7 +197,6 @@ const seedComplexScenarioAndClearAuthentication = async () => {
},
})
// hidden-group
authenticatedUser = await adminMemberUser.toJson()
await mutate({
mutation: createGroupMutation(),
variables: {
@ -214,32 +214,17 @@ const seedComplexScenarioAndClearAuthentication = async () => {
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'hidden-group',
userId: 'admin-member-user',
roleInGroup: 'usual',
},
})
await mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'hidden-group',
userId: 'second-owner-member-user',
userId: 'usual-member-user',
roleInGroup: 'usual',
},
})
await mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'hidden-group',
userId: 'admin-member-user',
roleInGroup: 'usual',
},
})
await mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'hidden-group',
userId: 'second-owner-member-user',
roleInGroup: 'usual',
roleInGroup: 'admin',
},
})
@ -2982,4 +2967,192 @@ describe('in mode', () => {
})
})
})
describe('RemoveUserFromGroup', () => {
beforeAll(async () => {
await seedComplexScenarioAndClearAuthentication()
})
afterAll(async () => {
await cleanDatabase()
})
describe('unauthenticated', () => {
it('throws an error', async () => {
await expect(
mutate({
mutation: removeUserFromGroupMutation(),
variables: {
groupId: 'hidden-group',
userId: 'usual-member-user',
},
}),
).resolves.toMatchObject({
errors: expect.arrayContaining([
expect.objectContaining({
message: 'Not Authorized!',
}),
]),
})
})
})
describe('authenticated', () => {
describe('as usual member', () => {
it('throws an error', async () => {
authenticatedUser = await usualMemberUser.toJson()
await expect(
mutate({
mutation: removeUserFromGroupMutation(),
variables: {
groupId: 'hidden-group',
userId: 'admin-member-user',
},
}),
).resolves.toMatchObject({
errors: expect.arrayContaining([
expect.objectContaining({
message: 'Not Authorized!',
}),
]),
})
})
})
describe('as owner', () => {
beforeEach(async () => {
authenticatedUser = await ownerMemberUser.toJson()
})
it('removes the user from the group', async () => {
await expect(
mutate({
mutation: removeUserFromGroupMutation(),
variables: {
groupId: 'hidden-group',
userId: 'usual-member-user',
},
}),
).resolves.toMatchObject({
data: {
RemoveUserFromGroup: expect.objectContaining({
id: 'usual-member-user',
myRoleInGroup: null,
}),
},
errors: undefined,
})
})
it('cannot remove self', async () => {
await expect(
mutate({
mutation: removeUserFromGroupMutation(),
variables: {
groupId: 'hidden-group',
userId: 'owner-member-user',
},
}),
).resolves.toMatchObject({
errors: expect.arrayContaining([
expect.objectContaining({
message: 'Not Authorized!',
}),
]),
})
})
})
describe('as admin', () => {
beforeEach(async () => {
authenticatedUser = await adminMemberUser.toJson()
await mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'hidden-group',
userId: 'usual-member-user',
roleInGroup: 'usual',
},
})
})
it('throws an error', async () => {
authenticatedUser = await usualMemberUser.toJson()
await expect(
mutate({
mutation: removeUserFromGroupMutation(),
variables: {
groupId: 'hidden-group',
userId: 'admin-member-user',
},
}),
).resolves.toMatchObject({
errors: expect.arrayContaining([
expect.objectContaining({
message: 'Not Authorized!',
}),
]),
})
})
/*
it('removes the user from the group', async () => {
await expect(
mutate({
mutation: removeUserFromGroupMutation(),
variables: {
groupId: 'hidden-group',
userId: 'usual-member-user',
},
}),
).resolves.toMatchObject({
data: {
RemoveUserFromGroup: expect.objectContaining({
id: 'usual-member-user',
myRoleInGroup: null,
}),
},
errors: undefined,
})
})
it('cannot remove self', async () => {
await expect(
mutate({
mutation: removeUserFromGroupMutation(),
variables: {
groupId: 'hidden-group',
userId: 'admin-member-user',
},
}),
).resolves.toMatchObject({
errors: expect.arrayContaining([
expect.objectContaining({
message: 'Not Authorized!',
}),
]),
})
})
it('cannot remove owner', async () => {
await expect(
mutate({
mutation: removeUserFromGroupMutation(),
variables: {
groupId: 'hidden-group',
userId: 'owner-member-user',
},
}),
).resolves.toMatchObject({
errors: expect.arrayContaining([
expect.objectContaining({
message: 'Not Authorized!',
}),
]),
})
})
*/
})
})
})
})

View File

@ -99,6 +99,35 @@ export default {
session.close()
}
},
markAllAsRead: async (parent, args, context, resolveInfo) => {
const { user: currentUser } = context
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const markAllNotificationAsReadTransactionResponse = await transaction.run(
`
MATCH (resource)-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
SET notification.read = TRUE
WITH user, notification, resource,
[(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors,
[(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts
WITH resource, user, notification, authors, posts,
resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource
RETURN notification {.*, from: finalResource, to: properties(user)}
`,
{ id: currentUser.id },
)
log(markAllNotificationAsReadTransactionResponse)
return markAllNotificationAsReadTransactionResponse.records.map((record) =>
record.get('notification'),
)
})
try {
const notifications = await writeTxResultPromise
return notifications
} finally {
session.close()
}
},
},
NOTIFIED: {
id: async (parent) => {

View File

@ -3,6 +3,11 @@ import gql from 'graphql-tag'
import { getDriver } from '../../db/neo4j'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../.././server'
import {
markAsReadMutation,
markAllAsReadMutation,
notificationQuery,
} from '../../graphql/notifications'
const driver = getDriver()
let authenticatedUser
@ -146,26 +151,9 @@ describe('given some notifications', () => {
})
describe('notifications', () => {
const notificationQuery = gql`
query ($read: Boolean, $orderBy: NotificationOrdering) {
notifications(read: $read, orderBy: $orderBy) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const { errors } = await query({ query: notificationQuery })
const { errors } = await query({ query: notificationQuery() })
expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -212,7 +200,7 @@ describe('given some notifications', () => {
},
]
await expect(query({ query: notificationQuery, variables })).resolves.toMatchObject({
await expect(query({ query: notificationQuery(), variables })).resolves.toMatchObject({
data: {
notifications: expect.arrayContaining(expected),
},
@ -246,7 +234,7 @@ describe('given some notifications', () => {
},
})
const response = await query({
query: notificationQuery,
query: notificationQuery(),
variables: { ...variables, read: false },
})
await expect(response).toMatchObject(expected)
@ -275,14 +263,14 @@ describe('given some notifications', () => {
it('reduces notifications list', async () => {
await expect(
query({ query: notificationQuery, variables: { ...variables, read: false } }),
query({ query: notificationQuery(), variables: { ...variables, read: false } }),
).resolves.toMatchObject({
data: { notifications: [expect.any(Object), expect.any(Object)] },
errors: undefined,
})
await deletePostAction()
await expect(
query({ query: notificationQuery, variables: { ...variables, read: false } }),
query({ query: notificationQuery(), variables: { ...variables, read: false } }),
).resolves.toMatchObject({ data: { notifications: [] }, errors: undefined })
})
})
@ -291,27 +279,10 @@ describe('given some notifications', () => {
})
describe('markAsRead', () => {
const markAsReadMutation = gql`
mutation ($id: ID!) {
markAsRead(id: $id) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const result = await mutate({
mutation: markAsReadMutation,
mutation: markAsReadMutation(),
variables: { ...variables, id: 'p1' },
})
expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!')
@ -332,7 +303,7 @@ describe('given some notifications', () => {
})
it('returns null', async () => {
const response = await mutate({ mutation: markAsReadMutation, variables })
const response = await mutate({ mutation: markAsReadMutation(), variables })
expect(response.data.markAsRead).toEqual(null)
expect(response.errors).toBeUndefined()
})
@ -348,7 +319,7 @@ describe('given some notifications', () => {
})
it('updates `read` attribute and returns NOTIFIED relationship', async () => {
const { data } = await mutate({ mutation: markAsReadMutation, variables })
const { data } = await mutate({ mutation: markAsReadMutation(), variables })
expect(data).toEqual({
markAsRead: {
from: {
@ -369,7 +340,7 @@ describe('given some notifications', () => {
}
})
it('returns null', async () => {
const response = await mutate({ mutation: markAsReadMutation, variables })
const response = await mutate({ mutation: markAsReadMutation(), variables })
expect(response.data.markAsRead).toEqual(null)
expect(response.errors).toBeUndefined()
})
@ -385,7 +356,7 @@ describe('given some notifications', () => {
})
it('updates `read` attribute and returns NOTIFIED relationship', async () => {
const { data } = await mutate({ mutation: markAsReadMutation, variables })
const { data } = await mutate({ mutation: markAsReadMutation(), variables })
expect(data).toEqual({
markAsRead: {
from: {
@ -401,4 +372,46 @@ describe('given some notifications', () => {
})
})
})
describe('markAllAsRead', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const result = await mutate({
mutation: markAllAsReadMutation(),
})
expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!')
})
})
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await user.toJson()
})
describe('not being notified at all', () => {
beforeEach(async () => {
variables = {
...variables,
}
})
it('returns all as read', async () => {
const response = await mutate({ mutation: markAllAsReadMutation(), variables })
expect(response.data.markAllAsRead).toEqual([
{
createdAt: '2019-08-30T19:33:48.651Z',
from: { __typename: 'Comment', content: 'You have been mentioned in a comment' },
read: true,
},
{
createdAt: '2019-08-31T17:33:48.651Z',
from: { __typename: 'Post', content: 'You have been mentioned in a post' },
read: true,
},
])
expect(response.errors).toBeUndefined()
})
})
})
})
})

View File

@ -1524,9 +1524,9 @@ describe('Posts in Groups', () => {
})
})
it('does not show the posts of the closed group anymore', async () => {
it('stil shows the posts of the closed group', async () => {
const result = await query({ query: filterPosts(), variables: {} })
expect(result.data.Post).toHaveLength(3)
expect(result.data.Post).toHaveLength(4)
expect(result).toMatchObject({
data: {
Post: expect.arrayContaining([
@ -1540,6 +1540,11 @@ describe('Posts in Groups', () => {
title: 'A post without a group',
content: 'I am a user who does not belong to a group yet.',
},
{
id: 'post-to-closed-group',
title: 'A post to a closed group',
content: 'I am posting into a closed group as a member of the group',
},
{
id: 'post-to-hidden-group',
title: 'A post to a hidden group',
@ -1564,9 +1569,9 @@ describe('Posts in Groups', () => {
})
})
it('does only show the public posts', async () => {
it('still shows the post of the hidden group', async () => {
const result = await query({ query: filterPosts(), variables: {} })
expect(result.data.Post).toHaveLength(2)
expect(result.data.Post).toHaveLength(4)
expect(result).toMatchObject({
data: {
Post: expect.arrayContaining([
@ -1580,6 +1585,16 @@ describe('Posts in Groups', () => {
title: 'A post without a group',
content: 'I am a user who does not belong to a group yet.',
},
{
id: 'post-to-closed-group',
title: 'A post to a closed group',
content: 'I am posting into a closed group as a member of the group',
},
{
id: 'post-to-hidden-group',
title: 'A post to a hidden group',
content: 'I am posting into a hidden group as a member of the group',
},
]),
},
errors: undefined,
@ -1603,9 +1618,9 @@ describe('Posts in Groups', () => {
authenticatedUser = await allGroupsUser.toJson()
})
it('does not show the posts of the closed group', async () => {
it('shows the posts of the closed group', async () => {
const result = await query({ query: filterPosts(), variables: {} })
expect(result.data.Post).toHaveLength(3)
expect(result.data.Post).toHaveLength(4)
expect(result).toMatchObject({
data: {
Post: expect.arrayContaining([
@ -1624,6 +1639,11 @@ describe('Posts in Groups', () => {
title: 'A post to a closed group',
content: 'I am posting into a closed group as a member of the group',
},
{
id: 'post-to-hidden-group',
title: 'A post to a hidden group',
content: 'I am posting into a hidden group as a member of the group',
},
]),
},
errors: undefined,

View File

@ -132,4 +132,9 @@ type Mutation {
userId: ID!
roleInGroup: GroupMemberRole!
): User
RemoveUserFromGroup(
groupId: ID!
userId: ID!
): User
}

View File

@ -29,6 +29,7 @@ type Query {
type Mutation {
markAsRead(id: ID!): NOTIFIED
markAllAsRead: [NOTIFIED]
}
type Subscription {

View File

@ -4,6 +4,7 @@
"ignoreTestFiles": "*.js",
"chromeWebSecurity": false,
"baseUrl": "http://localhost:3000",
"video":false,
"retries": {
"runMode": 2,
"openMode": 0

View File

@ -0,0 +1,33 @@
# Docker
## Apple M1 Platform
***Attention:** For using Docker commands in Apple M1 environments!*
```bash
# set env variable for your shell
$ export DOCKER_DEFAULT_PLATFORM=linux/amd64
```
For even more informations, see [Docker More Closely](#docker-more-closely)
### Docker Compose Override File For Apple M1 Platform
For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform:
```bash
# in main folder
# for production
$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up
# for production testing Docker images from DockerHub
$ docker compose -f docker-compose.ocelotsocial-branded.yml -f docker-compose.apple-m1.override.yml up
# only once: init admin user and create indexes and constraints in Neo4j database
$ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
```
## Docker More Closely In Main Code
To get more informations about the Apple M1 platform and to analyze the Docker builds etc. you find our documentation in our main code, [here](https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/DOCKER_MORE_CLOSELY.md).

View File

@ -44,7 +44,7 @@ for development, spin up a
[hosted Neo4j Sandbox instance](https://neo4j.com/download/), run Neo4j in one
of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/),
[spin up Neo4j in a Docker container](https://neo4j.com/developer/docker/),
on Archlinux you can install [neo4j-community from AUR](https://aur.archlinux.org/packages/neo4j-community/)
on Arch linux you can install [neo4j-community from AUR](https://aur.archlinux.org/packages/neo4j-community/)
or on Debian-based systems install [Neo4j from the Debian Repository](http://debian.neo4j.org/).
Just be sure to update the Neo4j connection string and credentials accordingly
in `backend/.env`.
@ -55,7 +55,7 @@ Start Neo4J and confirm the database is running at [http://localhost:7474](http:
Here we describe some rarely used Cypher commands for Neo4j that are needed from time to time:
### Index And Contraint Commands
### Index And Constraint Commands
If indexes or constraints are missing or not set correctly, the browser search will not work or the database seed for development will not work.

View File

@ -30,6 +30,7 @@ export default {
}
}
.filterActive {
background-color: $color-success-active;
color: $color-primary-inverse;
background-color: $color-primary-active;
}
</style>

View File

@ -3,7 +3,7 @@
<a href="#" slot="default" slot-scope="{ toggleMenu }" @click.prevent="toggleMenu()">
<ds-text bold size="large">{{ $t('admin.categories.name') }}</ds-text>
</a>
<template slot="popover">
<template #popover>
<div class="category-menu-options">
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
<categories-filter v-if="categoriesActive" />

View File

@ -10,7 +10,7 @@
>
<base-icon class="dropdown-arrow" name="angle-down" />
</base-button>
<template slot="popover">
<template #popover>
<filter-menu-component />
</template>
</dropdown>

View File

@ -0,0 +1,57 @@
<template>
<span>
<base-button
class="my-filter-button my-filter-button-selected"
right
@click="clickButton"
filled
>
{{ title }}
</base-button>
<base-button
class="filter-remove"
@click="clickRemove"
icon="close"
:title="titleRemove"
size="small"
circle
filled
/>
</span>
</template>
<script>
export default {
name: 'HeaderButton',
props: {
title: {
type: String,
required: true,
},
clickButton: {
type: Function,
required: true,
},
titleRemove: {
type: String,
required: true,
},
clickRemove: {
type: Function,
required: true,
},
},
}
</script>
<style lang="scss">
.my-filter-button-selected {
padding-right: 30px;
margin-top: 4px;
}
.base-button.filter-remove {
position: relative;
margin-left: -31px;
top: -5px;
margin-right: 8px;
}
</style>

View File

@ -1,26 +1,65 @@
import { mount } from '@vue/test-utils'
import GroupMember from './GroupMember.vue'
import { changeGroupMemberRoleMutation, removeUserFromGroupMutation } from '~/graphql/groups.js'
const localVue = global.localVue
const propsData = {
groupId: '',
groupMembers: [],
groupId: 'group-id',
groupMembers: [
{
slug: 'owner',
id: 'owner',
myRoleInGroup: 'owner',
},
{
slug: 'user',
id: 'user',
myRoleInGroup: 'usual',
},
],
}
const stubs = {
'nuxt-link': true,
}
const apolloMock = jest
.fn()
.mockRejectedValueOnce({ message: 'Oh no!' })
.mockResolvedValue({
data: {
ChangeGroupMemberRole: {
slug: 'user',
id: 'user',
myRoleInGroup: 'admin',
},
},
})
const toastErrorMock = jest.fn()
const toastSuccessMock = jest.fn()
describe('GroupMember', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
$t: jest.fn((t) => t),
$apollo: {
mutate: apolloMock,
},
$toast: {
error: toastErrorMock,
success: toastSuccessMock,
},
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupMember, { propsData, mocks, localVue })
return mount(GroupMember, { propsData, mocks, localVue, stubs })
}
beforeEach(() => {
@ -30,5 +69,120 @@ describe('GroupMember', () => {
it('renders', () => {
expect(wrapper.findAll('.group-member')).toHaveLength(1)
})
it('has two users in table', () => {
expect(wrapper.find('tbody').findAll('tr')).toHaveLength(2)
})
it('has no modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false)
})
describe('change user role', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper
.find('tbody')
.findAll('tr')
.at(1)
.find('select')
.findAll('option')
.at(2)
.setSelected()
wrapper.find('tbody').findAll('tr').at(1).find('select').trigger('change')
})
describe('with server error', () => {
it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('Oh no!')
})
})
describe('with server success', () => {
it('calls the API', () => {
expect(apolloMock).toBeCalledWith({
mutation: changeGroupMemberRoleMutation(),
variables: { groupId: 'group-id', userId: 'user', roleInGroup: 'admin' },
})
})
it('toasts a success message', () => {
expect(toastSuccessMock).toBeCalledWith('group.changeMemberRole')
})
})
})
describe('click remove user', () => {
beforeAll(() => {
apolloMock.mockRejectedValueOnce({ message: 'Oh no!!' }).mockResolvedValue({
data: {
RemoveUserFromGroup: {
slug: 'user',
id: 'user',
myRoleInGroup: null,
},
},
})
})
beforeEach(() => {
wrapper = Wrapper()
wrapper.find('tbody').findAll('tr').at(1).find('button').trigger('click')
})
it('opens the modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').isVisible()).toBe(true)
})
describe('click on cancel', () => {
beforeEach(() => {
wrapper.find('div.ds-modal-wrapper').find('button.ds-button-ghost').trigger('click')
})
it('closes the modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false)
})
})
describe('click on confirm with server error', () => {
beforeEach(() => {
wrapper.find('div.ds-modal-wrapper').find('button.ds-button-primary').trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('Oh no!!')
})
it('closes the modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false)
})
})
describe('click on confirm with success', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper.find('div.ds-modal-wrapper').find('button.ds-button-primary').trigger('click')
})
it('calls the API', () => {
expect(apolloMock).toBeCalledWith({
mutation: removeUserFromGroupMutation(),
variables: { groupId: 'group-id', userId: 'user' },
})
})
it('emits load group members', () => {
expect(wrapper.emitted('loadGroupMembers')).toBeTruthy()
})
it('toasts a success message', () => {
expect(toastSuccessMock).toBeCalledWith('group.memberRemoved')
})
it('closes the modal', () => {
expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false)
})
})
})
})
})

View File

@ -53,30 +53,33 @@
</ds-chip>
</template>
<template #edit="scope">
<ds-button v-if="scope.row.myRoleInGroup !== 'owner'" size="small" primary disabled>
<!-- TODO: implement removal of group members -->
<!-- :disabled="scope.row.myRoleInGroup === 'owner'"
-->
<base-button
v-if="scope.row.myRoleInGroup !== 'owner'"
size="small"
primary
@click="
isOpen = true
userId = scope.row.id
"
>
{{ $t('group.removeMemberButton') }}
</ds-button>
</base-button>
</template>
</ds-table>
<!-- TODO: implement removal of group members -->
<!-- TODO: change to ocelot.social modal -->
<!-- <ds-modal
v-if="isOpen"
v-model="isOpen"
:title="`${$t('group.removeMember')}`"
force
extended
:confirm-label="$t('group.removeMember')"
:cancel-label="$t('actions.cancel')"
@confirm="deleteMember(memberId)"
/> -->
<ds-modal
v-if="isOpen"
v-model="isOpen"
:title="`${$t('group.removeMember')}`"
force
extended
:confirm-label="$t('group.removeMember')"
:cancel-label="$t('actions.cancel')"
@confirm="removeUser()"
/>
</div>
</template>
<script>
import { changeGroupMemberRoleMutation } from '~/graphql/groups.js'
import { changeGroupMemberRoleMutation, removeUserFromGroupMutation } from '~/graphql/groups.js'
export default {
name: 'GroupMember',
@ -96,6 +99,8 @@ export default {
query: '',
searchProcess: null,
user: {},
isOpen: false,
userId: null,
}
},
computed: {
@ -139,6 +144,25 @@ export default {
this.$toast.error(error.message)
}
},
removeUser() {
this.$apollo
.mutate({
mutation: removeUserFromGroupMutation(),
variables: { groupId: this.groupId, userId: this.userId },
})
.then(({ data }) => {
this.$emit('loadGroupMembers')
this.$toast.success(
this.$t('group.memberRemoved', { name: data.RemoveUserFromGroup.slug }),
)
})
.catch((error) => {
this.$toast.error(error.message)
})
.finally(() => {
this.userId = null
})
},
},
}
</script>

View File

@ -1,7 +1,16 @@
<template>
<dropdown class="invite-button" offset="8" :placement="placement">
<template #default="{ toggleMenu }">
<base-button icon="user-plus" circle ghost @click.prevent="toggleMenu" />
<base-button
icon="user-plus"
circle
ghost
v-tooltip="{
content: $t('invite-codes.button.tooltip'),
placement: 'bottom-start',
}"
@click.prevent="toggleMenu"
/>
</template>
<template #popover>
<div class="invite-button-menu-popover">
@ -15,10 +24,7 @@
ghost
@click="copyInviteLink"
>
<ds-text bold>
{{ $t('invite-codes.copy-code') }}
{{ inviteCode.code }}
</ds-text>
<ds-text bold>{{ $t('invite-codes.copy-code') }}</ds-text>
</base-button>
</base-card>
</div>
@ -108,6 +114,6 @@ export default {
}
.invite-code {
left: 50%;
margin-left: 25%;
}
</style>

View File

@ -30,7 +30,7 @@ export default {
/* dirty fix to override broken styleguide inline-styles */
.ds-grid {
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)) !important;
gap: 16px !important;
gap: 32px 16px !important;
grid-auto-rows: 20px;
}

View File

@ -12,15 +12,24 @@
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
</base-button>
</template>
<template slot="popover">
<template #popover>
<div class="notifications-menu-popover">
<notification-list :notifications="notifications" @markAsRead="markAsRead" />
</div>
<div class="notifications-link-container">
<nuxt-link :to="{ name: 'notifications' }">
{{ $t('notifications.pageLink') }}
</nuxt-link>
</div>
<ds-flex class="notifications-link-container">
<ds-flex-item :width="{ base: 'auto' }" centered>
<nuxt-link :to="{ name: 'notifications' }">
<ds-button ghost primary>
{{ $t('notifications.pageLink') }}
</ds-button>
</nuxt-link>
</ds-flex-item>
<ds-flex-item :width="{ base: 'auto' }" centered>
<ds-button ghost primary @click="markAllAsRead" data-test="markAllAsRead-button">
{{ $t('notifications.markAllAsRead') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</template>
</dropdown>
</template>
@ -28,7 +37,12 @@
<script>
import { mapGetters } from 'vuex'
import unionBy from 'lodash/unionBy'
import { notificationQuery, markAsReadMutation, notificationAdded } from '~/graphql/User'
import {
notificationQuery,
markAsReadMutation,
notificationAdded,
markAllAsReadMutation,
} from '~/graphql/User'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Dropdown from '~/components/Dropdown'
import NotificationList from '../NotificationList/NotificationList'
@ -56,8 +70,21 @@ export default {
mutation: markAsReadMutation(this.$i18n),
variables,
})
} catch (err) {
this.$toast.error(err.message)
} catch (error) {
this.$toast.error(error.message)
}
},
async markAllAsRead() {
if (!this.hasNotifications) {
return
}
try {
await this.$apollo.mutate({
mutation: markAllAsReadMutation(this.$i18n),
})
} catch (error) {
this.$toast.error(error.message)
}
},
},
@ -71,6 +98,9 @@ export default {
}, 0)
return result
},
hasNotifications() {
return this.notifications.length
},
},
apollo: {
notifications: {
@ -118,7 +148,7 @@ export default {
}
.notifications-link-container {
background-color: $background-color-softer-active;
text-align: center;
justify-content: center;
position: fixed;
bottom: 0;
left: 0;

View File

@ -59,8 +59,8 @@ describe('NotificationsTable.vue', () => {
wrapper = Wrapper()
})
it('renders a table', () => {
expect(wrapper.find('.ds-table').exists()).toBe(true)
it('renders a grid table', () => {
expect(wrapper.find('.notification-grid').exists()).toBe(true)
})
describe('renders 4 columns', () => {
@ -84,7 +84,7 @@ describe('NotificationsTable.vue', () => {
describe('Post', () => {
let firstRowNotification
beforeEach(() => {
firstRowNotification = wrapper.findAll('tbody tr').at(0)
firstRowNotification = wrapper.findAll('.notification-grid-row').at(0)
})
it('renders the author', () => {
@ -117,7 +117,7 @@ describe('NotificationsTable.vue', () => {
describe('Comment', () => {
let secondRowNotification
beforeEach(() => {
secondRowNotification = wrapper.findAll('tbody tr').at(1)
secondRowNotification = wrapper.findAll('.notification-grid-row').at(1)
})
it('renders the author', () => {

View File

@ -1,62 +1,108 @@
<template>
<ds-table v-if="notifications && notifications.length" :data="notifications" :fields="fields">
<template #icon="scope">
<base-icon
v-if="scope.row.from.post"
name="comment"
v-tooltip="{ content: $t('notifications.comment'), placement: 'right' }"
/>
<base-icon
v-else
name="bookmark"
v-tooltip="{ content: $t('notifications.post'), placement: 'right' }"
/>
</template>
<template #user="scope">
<ds-space margin-bottom="base">
<client-only>
<user-teaser
:user="scope.row.from.author"
:date-time="scope.row.from.createdAt"
:class="{ 'notification-status': scope.row.read }"
/>
</client-only>
</ds-space>
<ds-text :class="{ 'notification-status': scope.row.read, reason: true }">
{{ $t(`notifications.reason.${scope.row.reason}`) }}
</ds-text>
</template>
<template #post="scope">
<nuxt-link
class="notification-mention-post"
:class="{ 'notification-status': scope.row.read }"
:to="{
name: 'post-id-slug',
params: params(scope.row.from),
hash: hashParam(scope.row.from),
}"
@click.native="markNotificationAsRead(scope.row.from.id)"
<div class="notification-grid" v-if="notifications && notifications.length">
<ds-grid>
<ds-grid-item v-if="!isMobile" column-span="fullWidth">
<ds-grid class="header-grid">
<ds-grid-item v-for="field in fields" :key="field.label" class="ds-table-head-col">
{{ field.label }}
</ds-grid-item>
</ds-grid>
</ds-grid-item>
<ds-grid-item
v-for="notification in notifications"
:key="notification.id"
column-span="fullWidth"
class="notification-grid-row"
>
<b>{{ scope.row.from.title || scope.row.from.post.title | truncate(50) }}</b>
</nuxt-link>
</template>
<template #content="scope">
<b :class="{ 'notification-status': scope.row.read }">
{{ scope.row.from.contentExcerpt | removeHtml }}
</b>
</template>
</ds-table>
<ds-grid>
<ds-grid-item>
<ds-flex class="user-section">
<ds-flex-item :width="{ base: '20%' }">
<div>
<base-card :wide-content="true">
<base-icon
v-if="notification.from.post"
name="comment"
v-tooltip="{ content: $t('notifications.comment'), placement: 'right' }"
/>
<base-icon
v-else
name="bookmark"
v-tooltip="{ content: $t('notifications.post'), placement: 'right' }"
/>
</base-card>
</div>
</ds-flex-item>
<ds-flex-item>
<div>
<base-card :wide-content="true">
<ds-space margin-bottom="base">
<client-only>
<user-teaser
:user="notification.from.author"
:date-time="notification.from.createdAt"
:class="{ 'notification-status': notification.read }"
/>
</client-only>
</ds-space>
<ds-text :class="{ 'notification-status': notification.read, reason: true }">
{{ $t(`notifications.reason.${notification.reason}`) }}
</ds-text>
</base-card>
</div>
</ds-flex-item>
</ds-flex>
</ds-grid-item>
<ds-grid-item>
<ds-flex class="content-section" :direction="{ base: 'column', xs: 'row' }">
<ds-flex-item>
<base-card :wide-content="true">
<nuxt-link
class="notification-mention-post"
:class="{ 'notification-status': notification.read }"
:to="{
name: 'post-id-slug',
params: params(notification.from),
hash: hashParam(notification.from),
}"
@click.native="markNotificationAsRead(notification.from.id)"
>
<b>
{{ notification.from.title || notification.from.post.title | truncate(50) }}
</b>
</nuxt-link>
</base-card>
</ds-flex-item>
<ds-flex-item>
<base-card :wide-content="true">
<b :class="{ 'notification-status': notification.read }">
{{ notification.from.contentExcerpt | removeHtml }}
</b>
</base-card>
</ds-flex-item>
</ds-flex>
</ds-grid-item>
</ds-grid>
</ds-grid-item>
</ds-grid>
</div>
<hc-empty v-else icon="alert" :message="$t('notifications.empty')" />
</template>
<script>
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import HcEmpty from '~/components/Empty/Empty'
import BaseCard from '../_new/generic/BaseCard/BaseCard.vue'
import mobile from '~/mixins/mobile'
const maxMobileWidth = 768 // at this point the table breaks down
export default {
components: {
UserTeaser,
HcEmpty,
BaseCard,
},
mixins: [mobile(maxMobileWidth)],
props: {
notifications: { type: Array, default: () => [] },
},
@ -106,4 +152,39 @@ export default {
.notification-status {
opacity: $opacity-soft;
}
/* fix to override flex-wrap style of ds flex component */
.notification-grid .content-section {
flex-wrap: nowrap;
}
.notification-grid .ds-grid.header-grid {
grid-template-columns: 1fr 4fr 3fr 3fr !important;
}
.notification-grid-row {
border-top: 1px dotted #e5e3e8;
}
.notification-grid .base-card {
border-radius: 0;
box-shadow: none;
padding: 16px 4px;
}
/* dirty fix to override broken styleguide inline-styles */
.notification-grid .ds-grid {
grid-template-columns: 5fr 6fr !important;
grid-auto-rows: auto !important;
grid-template-rows: 1fr;
gap: 0px !important;
}
@media screen and (max-width: 768px) {
.notification-grid .ds-grid {
grid-template-columns: 1fr !important;
}
.notification-grid .content-section {
border-top: 1px dotted #e5e3e8;
}
.notification-grid-row {
box-shadow: 0px 12px 26px -4px rgb(0 0 0 / 10%);
margin-top: 5px;
border-top: none;
}
}
</style>

View File

@ -15,7 +15,13 @@
<img :src="post.image | proxyApiUrl" class="image" />
</template>
<client-only>
<user-teaser :user="post.author" :group="post.group" :date-time="post.createdAt" />
<div class="post-user-row">
<user-teaser :user="post.author" :group="post.group" :date-time="post.createdAt" />
<hc-ribbon
:class="[isPinned ? '--pinned' : '', post.image ? 'post-ribbon-w-img' : 'post-ribbon']"
:text="isPinned ? $t('post.pinned') : $t('post.name')"
/>
</div>
</client-only>
<h2 class="title hyphenate-text">{{ post.title }}</h2>
<!-- TODO: replace editor content with tiptap render view -->
@ -26,7 +32,7 @@
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
>
<div class="categories" v-if="categoriesActive">
<hc-category
<category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{
@ -42,7 +48,7 @@
</div>
<div v-else class="categories-placeholder"></div>
<counter-icon
icon="bullhorn"
icon="heart-o"
:count="post.shoutedCount"
:title="$t('contribution.amount-shouts', { amount: post.shoutedCount })"
/>
@ -73,19 +79,15 @@
</client-only>
</footer>
</base-card>
<hc-ribbon
:class="{ '--pinned': isPinned }"
:text="isPinned ? $t('post.pinned') : $t('post.name')"
/>
</nuxt-link>
</template>
<script>
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import Category from '~/components/Category'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import HcRibbon from '~/components/Ribbon'
import HcCategory from '~/components/Category'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import HcRibbon from '~/components/Ribbon'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import { mapGetters } from 'vuex'
import PostMutations from '~/graphql/PostMutations'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
@ -93,11 +95,11 @@ import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostH
export default {
name: 'PostTeaser',
components: {
UserTeaser,
HcCategory,
HcRibbon,
Category,
ContentMenu,
CounterIcon,
HcRibbon,
UserTeaser,
},
props: {
post: {
@ -192,19 +194,38 @@ export default {
display: block;
height: 100%;
color: $text-color-base;
}
> .ribbon {
.post-user-row {
position: relative;
> .post-ribbon-w-img {
position: absolute;
top: 50%;
right: -7px;
// 14px (~height of ribbon element) + 24px(=margin of hero image)
top: -38px;
// 7px+24px(=padding of parent)
right: -31px;
}
> .post-ribbon {
position: absolute;
// 14px (~height of ribbon element) + 24px(=margin of hero image)
top: -24px;
// 7px(=offset)+24px(=margin of parent)
right: -31px;
}
}
.post-teaser > .base-card {
display: flex;
flex-direction: column;
overflow: visible;
height: 100%;
> .hero-image {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&.--blur-image > .hero-image > .image {
filter: blur($blur-radius);
}

View File

@ -4,7 +4,7 @@
:loading="loading"
:disabled="disabled"
:filled="shouted"
icon="bullhorn"
icon="heart-o"
circle
@click="toggle"
/>

View File

@ -21,7 +21,7 @@ storiesOf('Generic/BaseButton', module)
template: `
<div>
<base-button icon="edit">With Text</base-button>
<base-button icon="bullhorn" />
<base-button icon="heart-o" />
<base-button icon="trash" disabled />
<base-button icon="trash" loading />
</div>

View File

@ -74,7 +74,9 @@ describe('FollowList.vue', () => {
expect(wrapper.vm.allConnectionsCount).toBe(user.followingCount)
expect(wrapper.findAll('.user-teaser')).toHaveLength(user.following.length)
expect(wrapper.emitted('fetchAllConnections')).toEqual([['following']])
expect(wrapper.emitted('fetchAllConnections')).toEqual([
['following', user.followingCount],
])
})
})
@ -85,7 +87,9 @@ describe('FollowList.vue', () => {
expect(wrapper.vm.allConnectionsCount).toBe(user.followedByCount)
expect(wrapper.findAll('.user-teaser')).toHaveLength(user.followedBy.length)
expect(wrapper.emitted('fetchAllConnections')).toEqual([['followedBy']])
expect(wrapper.emitted('fetchAllConnections')).toEqual([
['followedBy', user.followedByCount],
])
})
})
})

View File

@ -6,7 +6,7 @@
:allProfilesCount="allConnectionsCount"
:profiles="connections"
:loading="loading"
@fetchAllProfiles="$emit('fetchAllConnections', type)"
@fetchAllProfiles="$emit('fetchAllConnections', type, allConnectionsCount)"
/>
</template>

View File

@ -4,7 +4,7 @@
<div class="metadata">
<span class="counts">
<counter-icon icon="comments" :count="option.commentsCount" soft />
<counter-icon icon="bullhorn" :count="option.shoutedCount" soft />
<counter-icon icon="heart-o" :count="option.shoutedCount" soft />
<counter-icon icon="hand-pointer" :count="option.clickedCount" soft />
<counter-icon icon="eye" :count="option.viewedTeaserCount" soft />
</span>

View File

@ -108,7 +108,7 @@ export const mapUserQuery = (i18n) => {
`
}
export const notificationQuery = (i18n) => {
export const notificationQuery = (_i18n) => {
return gql`
${userFragment}
${commentFragment}
@ -147,7 +147,7 @@ export const notificationQuery = (i18n) => {
`
}
export const markAsReadMutation = (i18n) => {
export const markAsReadMutation = (_i18n) => {
return gql`
${userFragment}
${commentFragment}
@ -183,6 +183,42 @@ export const markAsReadMutation = (i18n) => {
`
}
export const markAllAsReadMutation = (_i18n) => {
return gql`
${userFragment}
${commentFragment}
${postFragment}
mutation {
markAllAsRead {
id
read
reason
createdAt
updatedAt
from {
__typename
... on Post {
...post
author {
...user
}
}
... on Comment {
...comment
post {
...post
author {
...user
}
}
}
}
}
}
`
}
export const notificationAdded = () => {
return gql`
${userFragment}

View File

@ -143,6 +143,19 @@ export const changeGroupMemberRoleMutation = () => {
`
}
export const removeUserFromGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
RemoveUserFromGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
}
}
`
}
// ------ queries
export const groupQuery = (i18n) => {

View File

@ -270,7 +270,7 @@
"filterFollow": "Beiträge von Nutzern filtern, denen ich folge",
"filterMasonryGrid": {
"myFriends": "Nutzer denen ich folge",
"myGroups": "Meine Gruppen",
"myGroups": "Aus meinen Gruppen",
"myTopics": "Meine Themen",
"noFilter": "Beiträge filtern"
},
@ -455,6 +455,7 @@
"message": "Eine Gruppe zu verlassen ist möglicherweise nicht rückgängig zu machen!<br>Gruppe <b>„{name}“</b> verlassen!",
"title": "Möchtest du wirklich die Gruppe verlassen?"
},
"memberRemoved": "Nutzer „{name}“ wurde aus der Gruppe entfernt!",
"members": "Mitglieder",
"membersAdministrationList": {
"avatar": "Avatar",
@ -514,10 +515,13 @@
"no-results": "Keine Beiträge gefunden."
},
"invite-codes": {
"copy-code": "Code:",
"button": {
"tooltip": "Lade deine Freunde ein"
},
"copy-code": "Einladungslink kopieren",
"copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert",
"not-available": "Du hast keinen Einladungscode zur Verfügung!",
"your-code": "Kopiere deinen Einladungscode in die Ablage:"
"your-code": "Sende diesen Link per E-Mail oder in sozialen Medien, um deine Freunde einzuladen:"
},
"login": {
"email": "Deine E-Mail",
@ -639,6 +643,7 @@
"read": "Gelesen",
"unread": "Ungelesen"
},
"markAllAsRead": "Markiere alle als gelesen",
"pageLink": "Alle Benachrichtigungen",
"post": "Beitrag",
"reason": {

View File

@ -270,7 +270,7 @@
"filterFollow": "Filter contributions from users I follow",
"filterMasonryGrid": {
"myFriends": "Users I follow",
"myGroups": "My groups",
"myGroups": "By my groups",
"myTopics": "My topics",
"noFilter": "Filter posts"
},
@ -455,6 +455,7 @@
"message": "Leaving a group may be irreversible!<br>Leave group <b>“{name}”</b>!",
"title": "Do you really want to leave the group?"
},
"memberRemoved": "User “{name}” was removed from group!",
"members": "Members",
"membersAdministrationList": {
"avatar": "Avatar",
@ -514,10 +515,13 @@
"no-results": "No contributions found."
},
"invite-codes": {
"copy-code": "Code:",
"button": {
"tooltip": "Invite your friends"
},
"copy-code": "Copy Invite Link",
"copy-success": "Invite code copied to clipboard",
"not-available": "You have no valid invite code available!",
"your-code": "Copy your invite code to the clipboard:"
"your-code": "Send this link per e-mail or in social media to invite your friends:"
},
"login": {
"email": "Your E-mail",
@ -639,6 +643,7 @@
"read": "Read",
"unread": "Unread"
},
"markAllAsRead": "Mark all as read",
"pageLink": "All notifications",
"post": "Post",
"reason": {

View File

@ -412,6 +412,7 @@
"read": "Leído",
"unread": "No leído"
},
"markAllAsRead": "Marcar todas como leido",
"pageLink": "Todas las notificaciones",
"post": "Contribución",
"reason": {

View File

@ -401,6 +401,7 @@
"read": "Lire",
"unread": "Non lu"
},
"markAllAsRead": "Tout marquer comme lu",
"pageLink": "Toutes les notifications",
"post": "Post",
"reason": {

View File

@ -354,6 +354,7 @@
"read": null,
"unread": null
},
"markAllAsRead": "Segna tutti come letti",
"pageLink": null,
"post": null,
"reason": {

View File

@ -100,6 +100,26 @@
"moreInfo": "Wat is {APPLICATION_NAME}?",
"password": "Uw Wachtwoord"
},
"notifications": {
"comment": null,
"content": null,
"empty": null,
"filterLabel": {
"all": null,
"read": null,
"unread": null
},
"markAllAsRead": "Markeer alles als gelezen",
"pageLink": null,
"post": null,
"reason": {
"commented_on_post": null,
"mentioned_in_comment": null,
"mentioned_in_post": null
},
"title": null,
"user": null
},
"post": {
"moreInfo": {
"name": "Meer info"

View File

@ -199,6 +199,7 @@
}
},
"notifications": {
"markAllAsRead": "Oznacz wszystkie jako przeczytane",
"menu": {
"mentioned": "wspomiał o Tobie we wpisie"
}

View File

@ -390,6 +390,7 @@
"read": "Lido",
"unread": "Não lido"
},
"markAllAsRead": "Marcar todas como lidas",
"pageLink": "Todas as notificações",
"post": "Post",
"reason": {

View File

@ -426,6 +426,7 @@
"read": "Прочитанные",
"unread": "Непрочитанные"
},
"markAllAsRead": "Отметить все как прочитанное",
"pageLink": "Все уведомления",
"post": "Пост",
"reason": {

View File

@ -15,7 +15,7 @@ export default {
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
content: 'initial-scale=1',
},
{
hid: 'description',

View File

@ -62,7 +62,7 @@ export default {
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
content: 'initial-scale=1',
},
{
hid: 'description',

View File

@ -134,15 +134,15 @@ describe('PostIndex', () => {
})
describe('donation-info', () => {
it('shows donation-info on default', () => {
it('hides donation-info on default', () => {
wrapper = Wrapper()
expect(wrapper.find('.top-info-bar').exists()).toBe(true)
expect(wrapper.find('.top-info-bar').exists()).toBe(false)
})
it('hides donation-info if not "showDonations"', async () => {
it('shows donation-info if "showDonations"', async () => {
wrapper = Wrapper()
await wrapper.setData({ showDonations: false })
expect(wrapper.find('.top-info-bar').exists()).toBe(false)
await wrapper.setData({ showDonations: true })
expect(wrapper.find('.top-info-bar').exists()).toBe(true)
})
})
})

View File

@ -43,47 +43,30 @@
&nbsp;
<base-icon class="my-filter-button" :name="filterButtonIcon"></base-icon>
</base-button>
<span v-if="postsFilter['categories_some']">
<base-button class="my-filter-button" right @click="showFilter = !showFilter" filled>
{{ $t('contribution.filterMasonryGrid.myTopics') }}
</base-button>
<base-button
class="filter-remove"
@click="resetCategories"
icon="close"
:title="$t('filter-menu.deleteFilter')"
style="margin-left: -8px"
filled
/>
</span>
<span v-if="postsFilter['author']">
<base-button class="my-filter-button" right @click="showFilter = !showFilter" filled>
{{ $t('contribution.filterMasonryGrid.myFriends') }}
</base-button>
<base-button
class="filter-remove"
@click="resetByFollowed"
icon="close"
:title="$t('filter-menu.deleteFilter')"
style="margin-left: -8px"
filled
/>
</span>
<span v-if="postsFilter['postsInMyGroups']">
<base-button class="my-filter-button" right @click="showFilter = !showFilter" filled>
{{ $t('contribution.filterMasonryGrid.myGroups') }}
</base-button>
<base-button
class="filter-remove"
@click="resetByGroups"
icon="close"
:title="$t('filter-menu.deleteFilter')"
style="margin-left: -8px"
filled
/>
</span>
<header-button
v-if="postsFilter['categories_some']"
:title="$t('contribution.filterMasonryGrid.myTopics')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetCategories"
/>
<header-button
v-if="postsFilter['author']"
:title="$t('contribution.filterMasonryGrid.myFriends')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByFollowed"
/>
<header-button
v-if="postsFilter['postsInMyGroups']"
:title="$t('contribution.filterMasonryGrid.myGroups')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByGroups"
/>
<div id="my-filter" v-if="showFilter">
<div @mouseleave="showFilter = false">
<filter-menu-component @showFilterMenu="showFilterMenu" />
@ -92,16 +75,16 @@
</div>
</div>
</ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
<!-- Placeholder/Space Row -->
<ds-grid-item :row-span="1" column-span="fullWidth" />
<!-- hashtag filter -->
<ds-grid-item v-if="hashtag" :row-span="2" column-span="fullWidth">
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
</ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
<!-- donation info -->
<ds-grid-item v-if="showDonations" class="top-info-bar" :row-span="1" column-span="fullWidth">
<donation-info :goal="goal" :progress="progress" />
</ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
<!-- news feed -->
<template v-if="hasResults">
<masonry-grid-item
@ -142,6 +125,7 @@ import HcEmpty from '~/components/Empty/Empty'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import HeaderButton from '~/components/FilterMenu/HeaderButton'
import { mapGetters, mapMutations } from 'vuex'
import { DonationsQuery } from '~/graphql/Donations'
import { filterPosts } from '~/graphql/PostQuery.js'
@ -159,6 +143,7 @@ export default {
MasonryGrid,
MasonryGridItem,
FilterMenuComponent,
HeaderButton,
},
mixins: [postListActions],
data() {
@ -167,7 +152,7 @@ export default {
hideByScroll: false,
revScrollpos: 0,
showFilter: false,
showDonations: true,
showDonations: false,
goal: 15000,
progress: 7000,
posts: [],
@ -225,6 +210,9 @@ export default {
resetCategories: 'posts/RESET_CATEGORIES',
toggleCategory: 'posts/TOGGLE_CATEGORY',
}),
openFilterMenu() {
this.showFilter = !this.showFilter
},
showFilterMenu(e) {
if (!e || (!e.target.closest('#my-filter') && !e.target.closest('.my-filter-button'))) {
if (!this.showFilter) return
@ -354,13 +342,18 @@ export default {
align-items: center;
}
.filterButtonMenu {
width: 95%;
position: fixed;
z-index: 6;
margin-top: -35px;
padding: 20px 10px 5px 10px;
border-radius: 7px;
background-color: #f5f4f6;
}
@media screen and (max-width: 656px) {
.filterButtonMenu {
margin-top: -50px;
}
}
#my-filter {
background-color: white;
box-shadow: rgb(189 189 189) 1px 9px 15px 1px;
@ -418,5 +411,8 @@ export default {
font-size: 23px;
z-index: 10;
}
.ds-grid {
padding-top: 1em;
}
}
</style>

View File

@ -1,21 +1,22 @@
import { shallowMount, mount } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import NotificationsPage from './index.vue'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import { markAsReadMutation, markAllAsReadMutation } from '~/graphql/User'
const localVue = global.localVue
const stubs = {
'client-only': true,
'notifications-table': true,
}
describe('PostIndex', () => {
let wrapper, Wrapper, mocks, propsData
let wrapper, Wrapper, mocks
beforeEach(() => {
propsData = {}
mocks = {
$t: (string) => string,
$toast: {
@ -37,86 +38,94 @@ describe('PostIndex', () => {
}
})
describe('shallowMount', () => {
beforeEach(() => {
Wrapper = () => {
return shallowMount(NotificationsPage, {
mocks,
localVue,
propsData,
stubs,
})
}
wrapper = Wrapper()
})
it('renders a Notications header', () => {
expect(wrapper.find('ds-heading-stub').exists()).toBe(true)
})
it('renders a `dropdown-filter` component', () => {
expect(wrapper.find('dropdown-filter-stub').exists()).toBe(true)
})
it('renders a `notifications-table` component', () => {
expect(wrapper.find('notifications-table-stub').exists()).toBe(true)
})
})
describe('mount', () => {
jest.clearAllMocks()
beforeEach(() => {
Wrapper = () => {
return mount(NotificationsPage, {
mocks,
localVue,
propsData,
stubs,
})
}
wrapper = Wrapper()
wrapper.setData({
notifications: [
{
id: 'mentioned_in_comment/c4-1/u1',
read: false,
reason: 'mentioned_in_comment',
createdAt: '2023-03-06T14:32:47.924Z',
updatedAt: '2023-03-06T14:32:47.924Z',
},
{
id: 'mentioned_in_post/p8/u1',
read: false,
reason: 'mentioned_in_post',
createdAt: '2023-03-06T14:32:47.667Z',
updatedAt: '2023-03-06T14:32:47.667Z',
},
],
})
})
it('renders a Notications header', () => {
expect(wrapper.find('.ds-heading').exists()).toBe(true)
})
it('renders a `dropdown-filter` component', () => {
expect(wrapper.find('.dropdown-filter').exists()).toBe(true)
})
it('renders a `notifications-table` component', () => {
expect(wrapper.findComponent(NotificationsTable).exists()).toBe(true)
})
it('renders a `mark-all-as-read` button', () => {
expect(wrapper.find('[data-test="markAllAsRead-button"]').exists()).toBe(true)
})
describe('filter', () => {
beforeEach(() => {
propsData.filterOptions = [
{ label: 'All', value: null },
{ label: 'Read', value: true },
{ label: 'Unread', value: false },
]
wrapper = Wrapper()
wrapper.findComponent(DropdownFilter).vm.$emit('filter', propsData.filterOptions[1])
it('has "All" as default', () => {
expect(wrapper.find('a.dropdown-filter').text()).toBe('notifications.filterLabel.all')
})
it('sets `notificationRead` to value of received option', () => {
expect(wrapper.vm.notificationRead).toEqual(propsData.filterOptions[1].value)
})
describe('select Read', () => {
beforeEach(() => {
wrapper.findComponent(DropdownFilter).vm.$emit('filter', wrapper.vm.filterOptions[1])
})
it('set label to the label of the received option', () => {
expect(wrapper.vm.selected).toEqual(propsData.filterOptions[1].label)
})
it('sets `notificationRead` to value of received option', () => {
expect(wrapper.vm.notificationRead).toEqual(wrapper.vm.filterOptions[1].value)
})
it('refreshes the notifications', () => {
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
it('sets label to the label of the received option', () => {
expect(wrapper.vm.selected).toEqual(wrapper.vm.filterOptions[1].label)
})
it('refreshes the notifications', () => {
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
})
})
})
describe('markNotificationAsRead', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper
.findComponent(NotificationsTable)
.vm.$emit('markNotificationAsRead', 'notificationSourceId')
})
it('calls markNotificationAsRead mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({ variables: { id: 'notificationSourceId' } }),
)
it('calls markAllAsRead mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: markAsReadMutation(),
variables: { id: 'notificationSourceId' },
})
})
describe('error handling', () => {
beforeEach(() => {
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ message: 'Some error message' })
wrapper = Wrapper()
wrapper
.findComponent(NotificationsTable)
.vm.$emit('markNotificationAsRead', 'notificationSourceId')
@ -128,6 +137,26 @@ describe('PostIndex', () => {
})
})
describe('markAllNotificationAsRead', () => {
it('calls markAllNotificationAsRead mutation and refreshes notification', async () => {
wrapper.find('button[data-test="markAllAsRead-button"]').trigger('click')
await expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: markAllAsReadMutation(),
})
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
})
describe('error handling', () => {
it('shows an error message if there is an error', async () => {
mocks.$apollo.mutate = jest
.fn()
.mockRejectedValueOnce({ message: 'Another error message' })
await wrapper.find('button[data-test="markAllAsRead-button"]').trigger('click')
expect(mocks.$toast.error).toHaveBeenCalledWith('Another error message')
})
})
})
describe('PaginationButtons', () => {
beforeEach(() => {
wrapper = Wrapper()

View File

@ -15,7 +15,27 @@
@markNotificationAsRead="markNotificationAsRead"
:notifications="notifications"
/>
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @back="back" @next="next" />
<ds-flex class="notifications-footer">
<ds-flex-item :width="{ base: 'auto' }" centered>
<pagination-buttons
:hasNext="hasNext"
:hasPrevious="hasPrevious"
@back="back"
@next="next"
/>
</ds-flex-item>
<ds-flex-item class="notifications-footer-button" :width="{ base: 'auto' }" centered>
<ds-button
primary
:disabled="unreadNotificationsCount === 0"
@click="markAllAsRead"
data-test="markAllAsRead-button"
>
{{ $t('notifications.markAllAsRead') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</base-card>
</template>
@ -23,7 +43,7 @@
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
import { notificationQuery, markAsReadMutation, markAllAsReadMutation } from '~/graphql/User'
export default {
components: {
@ -54,6 +74,15 @@ export default {
{ label: this.$t('notifications.filterLabel.unread'), value: false },
]
},
hasNotifications() {
return this.notifications.length
},
unreadNotificationsCount() {
const result = this.notifications.reduce((count, notification) => {
return notification.read ? count : count + 1
}, 0)
return result
},
},
methods: {
filter(option) {
@ -77,6 +106,20 @@ export default {
next() {
this.offset += this.pageSize
},
async markAllAsRead() {
if (!this.hasNotifications) {
return
}
try {
await this.$apollo.mutate({
mutation: markAllAsReadMutation(this.$i18n),
})
this.$apollo.queries.notifications.refresh()
} catch (error) {
this.$toast.error(error.message)
}
},
},
apollo: {
notifications: {
@ -112,4 +155,8 @@ export default {
.notifications-page-flex {
justify-content: space-between;
}
.notifications-footer {
justify-content: space-evenly;
}
</style>

View File

@ -384,9 +384,9 @@ export default {
this.user.followedByCurrentUser = followedByCurrentUser
this.user.followedBy = followedBy
},
fetchAllConnections(type) {
if (type === 'following') this.followingCount = Infinity
if (type === 'followedBy') this.followedByCount = Infinity
fetchAllConnections(type, count) {
if (type === 'following') this.followingCount = count
if (type === 'followedBy') this.followedByCount = count
},
},
apollo: {