mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge branch 'master' into deployment-reseed
This commit is contained in:
commit
ee8d9233a9
5
.github/workflows/publish-branded.yml
vendored
5
.github/workflows/publish-branded.yml
vendored
@ -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:
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@ -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
|
||||
|
||||
11
.github/workflows/test.yml
vendored
11
.github/workflows/test.yml
vendored
@ -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/
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
65
backend/src/graphql/notifications.js
Normal file
65
backend/src/graphql/notifications.js
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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!',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
})
|
||||
*/
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -132,4 +132,9 @@ type Mutation {
|
||||
userId: ID!
|
||||
roleInGroup: GroupMemberRole!
|
||||
): User
|
||||
|
||||
RemoveUserFromGroup(
|
||||
groupId: ID!
|
||||
userId: ID!
|
||||
): User
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ type Query {
|
||||
|
||||
type Mutation {
|
||||
markAsRead(id: ID!): NOTIFIED
|
||||
markAllAsRead: [NOTIFIED]
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"ignoreTestFiles": "*.js",
|
||||
"chromeWebSecurity": false,
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"video":false,
|
||||
"retries": {
|
||||
"runMode": 2,
|
||||
"openMode": 0
|
||||
|
||||
33
deployment/DOCKER_MORE_CLOSELY.md
Normal file
33
deployment/DOCKER_MORE_CLOSELY.md
Normal 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).
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ export default {
|
||||
}
|
||||
}
|
||||
.filterActive {
|
||||
background-color: $color-success-active;
|
||||
color: $color-primary-inverse;
|
||||
background-color: $color-primary-active;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
>
|
||||
<base-icon class="dropdown-arrow" name="angle-down" />
|
||||
</base-button>
|
||||
<template slot="popover">
|
||||
<template #popover>
|
||||
<filter-menu-component />
|
||||
</template>
|
||||
</dropdown>
|
||||
|
||||
57
webapp/components/FilterMenu/HeaderButton.vue
Normal file
57
webapp/components/FilterMenu/HeaderButton.vue
Normal 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>
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:filled="shouted"
|
||||
icon="bullhorn"
|
||||
icon="heart-o"
|
||||
circle
|
||||
@click="toggle"
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
:allProfilesCount="allConnectionsCount"
|
||||
:profiles="connections"
|
||||
:loading="loading"
|
||||
@fetchAllProfiles="$emit('fetchAllConnections', type)"
|
||||
@fetchAllProfiles="$emit('fetchAllConnections', type, allConnectionsCount)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -412,6 +412,7 @@
|
||||
"read": "Leído",
|
||||
"unread": "No leído"
|
||||
},
|
||||
"markAllAsRead": "Marcar todas como leido",
|
||||
"pageLink": "Todas las notificaciones",
|
||||
"post": "Contribución",
|
||||
"reason": {
|
||||
|
||||
@ -401,6 +401,7 @@
|
||||
"read": "Lire",
|
||||
"unread": "Non lu"
|
||||
},
|
||||
"markAllAsRead": "Tout marquer comme lu",
|
||||
"pageLink": "Toutes les notifications",
|
||||
"post": "Post",
|
||||
"reason": {
|
||||
|
||||
@ -354,6 +354,7 @@
|
||||
"read": null,
|
||||
"unread": null
|
||||
},
|
||||
"markAllAsRead": "Segna tutti come letti",
|
||||
"pageLink": null,
|
||||
"post": null,
|
||||
"reason": {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -199,6 +199,7 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"markAllAsRead": "Oznacz wszystkie jako przeczytane",
|
||||
"menu": {
|
||||
"mentioned": "wspomiał o Tobie we wpisie"
|
||||
}
|
||||
|
||||
@ -390,6 +390,7 @@
|
||||
"read": "Lido",
|
||||
"unread": "Não lido"
|
||||
},
|
||||
"markAllAsRead": "Marcar todas como lidas",
|
||||
"pageLink": "Todas as notificações",
|
||||
"post": "Post",
|
||||
"reason": {
|
||||
|
||||
@ -426,6 +426,7 @@
|
||||
"read": "Прочитанные",
|
||||
"unread": "Непрочитанные"
|
||||
},
|
||||
"markAllAsRead": "Отметить все как прочитанное",
|
||||
"pageLink": "Все уведомления",
|
||||
"post": "Пост",
|
||||
"reason": {
|
||||
|
||||
@ -15,7 +15,7 @@ export default {
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
content: 'initial-scale=1',
|
||||
},
|
||||
{
|
||||
hid: 'description',
|
||||
|
||||
@ -62,7 +62,7 @@ export default {
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
content: 'initial-scale=1',
|
||||
},
|
||||
{
|
||||
hid: 'description',
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -43,47 +43,30 @@
|
||||
|
||||
<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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user