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-secrets
This commit is contained in:
commit
a8cc17db44
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_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.client_payload.ref }}
|
||||||
|
|
||||||
- name: Download Docker Image (Backend)
|
- name: Download Docker Image (Backend)
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v2
|
||||||
with:
|
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
|
echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
|
||||||
- run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
|
- run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
|
||||||
- name: Repository Dispatch
|
- name: Repository Dispatch
|
||||||
uses: peter-evans/repository-dispatch@v1
|
uses: peter-evans/repository-dispatch@v2
|
||||||
with:
|
with:
|
||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
event-type: trigger-build-success
|
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
|
- name: backend | docker-compose
|
||||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
|
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
|
||||||
- name: cypress | Fullstack tests
|
- name: cypress | Fullstack tests
|
||||||
|
id: e2e-tests
|
||||||
run: |
|
run: |
|
||||||
yarn install
|
yarn install
|
||||||
yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} )
|
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
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: cypress-screenshots
|
name: cypress-screenshots
|
||||||
path: 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
|
```bash
|
||||||
# in main folder while docker-compose is up
|
# 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:
|
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
|
||||||
|
|
||||||
Database indexes and constraints need to be created when the database and the
|
Database indexes and constraints need to be created and upgraded when the database and the backend are running:
|
||||||
backend is running:
|
|
||||||
|
|
||||||
{% tabs %}
|
{% tabs %}
|
||||||
{% tab title="Docker" %}
|
{% 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"
|
$ 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 %}
|
{% endtab %}
|
||||||
{% tab title="Without Docker" %}
|
{% tab title="Without Docker" %}
|
||||||
|
|
||||||
@ -107,6 +111,11 @@ $ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
|
|||||||
yarn run db:migrate init
|
yarn run db:migrate init
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in backend/ with database running (In docker or local)
|
||||||
|
yarn run db:migrate up
|
||||||
|
```
|
||||||
|
|
||||||
{% endtab %}
|
{% endtab %}
|
||||||
{% endtabs %}
|
{% endtabs %}
|
||||||
|
|
||||||
@ -134,6 +143,8 @@ $ docker exec backend yarn run db:reset
|
|||||||
$ docker-compose down -v
|
$ docker-compose down -v
|
||||||
# if container is not running, run this command to set up your database indexes and constraints
|
# if container is not running, run this command to set up your database indexes and constraints
|
||||||
$ docker exec backend yarn run db:migrate init
|
$ docker exec backend yarn run db:migrate init
|
||||||
|
# And then upgrade the indexes and const
|
||||||
|
$ docker exec backend yarn run db:migrate up
|
||||||
```
|
```
|
||||||
|
|
||||||
{% endtab %}
|
{% 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
|
// ------ queries
|
||||||
|
|
||||||
export const groupQuery = () => {
|
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({
|
const canCommentPost = rule({
|
||||||
cache: 'no_cache',
|
cache: 'no_cache',
|
||||||
})(async (_parent, args, { user, driver }) => {
|
})(async (_parent, args, { user, driver }) => {
|
||||||
@ -382,6 +418,7 @@ export default shield(
|
|||||||
JoinGroup: isAllowedToJoinGroup,
|
JoinGroup: isAllowedToJoinGroup,
|
||||||
LeaveGroup: isAllowedToLeaveGroup,
|
LeaveGroup: isAllowedToLeaveGroup,
|
||||||
ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole,
|
ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole,
|
||||||
|
RemoveUserFromGroup: canRemoveUserFromGroup,
|
||||||
CreatePost: and(isAuthenticated, isMemberOfGroup),
|
CreatePost: and(isAuthenticated, isMemberOfGroup),
|
||||||
UpdatePost: isAuthor,
|
UpdatePost: isAuthor,
|
||||||
DeletePost: isAuthor,
|
DeletePost: isAuthor,
|
||||||
@ -412,6 +449,7 @@ export default shield(
|
|||||||
blockUser: isAuthenticated,
|
blockUser: isAuthenticated,
|
||||||
unblockUser: isAuthenticated,
|
unblockUser: isAuthenticated,
|
||||||
markAsRead: isAuthenticated,
|
markAsRead: isAuthenticated,
|
||||||
|
markAllAsRead: isAuthenticated,
|
||||||
AddEmailAddress: isAuthenticated,
|
AddEmailAddress: isAuthenticated,
|
||||||
VerifyEmailAddress: isAuthenticated,
|
VerifyEmailAddress: isAuthenticated,
|
||||||
pinPost: isAdmin,
|
pinPost: isAdmin,
|
||||||
|
|||||||
@ -295,25 +295,8 @@ export default {
|
|||||||
LeaveGroup: async (_parent, params, context, _resolveInfo) => {
|
LeaveGroup: async (_parent, params, context, _resolveInfo) => {
|
||||||
const { groupId, userId } = params
|
const { groupId, userId } = params
|
||||||
const session = context.driver.session()
|
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 {
|
try {
|
||||||
return await writeTxResultPromise
|
return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -368,6 +351,17 @@ export default {
|
|||||||
session.close()
|
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: {
|
Group: {
|
||||||
...Resolver('Group', {
|
...Resolver('Group', {
|
||||||
@ -383,3 +377,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,
|
joinGroupMutation,
|
||||||
leaveGroupMutation,
|
leaveGroupMutation,
|
||||||
changeGroupMemberRoleMutation,
|
changeGroupMemberRoleMutation,
|
||||||
|
removeUserFromGroupMutation,
|
||||||
groupMembersQuery,
|
groupMembersQuery,
|
||||||
groupQuery,
|
groupQuery,
|
||||||
} from '../../graphql/groups'
|
} from '../../graphql/groups'
|
||||||
@ -196,7 +197,6 @@ const seedComplexScenarioAndClearAuthentication = async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
// hidden-group
|
// hidden-group
|
||||||
authenticatedUser = await adminMemberUser.toJson()
|
|
||||||
await mutate({
|
await mutate({
|
||||||
mutation: createGroupMutation(),
|
mutation: createGroupMutation(),
|
||||||
variables: {
|
variables: {
|
||||||
@ -214,32 +214,17 @@ const seedComplexScenarioAndClearAuthentication = async () => {
|
|||||||
mutation: changeGroupMemberRoleMutation(),
|
mutation: changeGroupMemberRoleMutation(),
|
||||||
variables: {
|
variables: {
|
||||||
groupId: 'hidden-group',
|
groupId: 'hidden-group',
|
||||||
userId: 'admin-member-user',
|
userId: 'usual-member-user',
|
||||||
roleInGroup: 'usual',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await mutate({
|
|
||||||
mutation: changeGroupMemberRoleMutation(),
|
|
||||||
variables: {
|
|
||||||
groupId: 'hidden-group',
|
|
||||||
userId: 'second-owner-member-user',
|
|
||||||
roleInGroup: 'usual',
|
roleInGroup: 'usual',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await mutate({
|
await mutate({
|
||||||
mutation: changeGroupMemberRoleMutation(),
|
mutation: changeGroupMemberRoleMutation(),
|
||||||
variables: {
|
variables: {
|
||||||
groupId: 'hidden-group',
|
groupId: 'hidden-group',
|
||||||
userId: 'admin-member-user',
|
userId: 'admin-member-user',
|
||||||
roleInGroup: 'usual',
|
roleInGroup: 'admin',
|
||||||
},
|
|
||||||
})
|
|
||||||
await mutate({
|
|
||||||
mutation: changeGroupMemberRoleMutation(),
|
|
||||||
variables: {
|
|
||||||
groupId: 'hidden-group',
|
|
||||||
userId: 'second-owner-member-user',
|
|
||||||
roleInGroup: 'usual',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -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()
|
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: {
|
NOTIFIED: {
|
||||||
id: async (parent) => {
|
id: async (parent) => {
|
||||||
|
|||||||
@ -3,6 +3,11 @@ import gql from 'graphql-tag'
|
|||||||
import { getDriver } from '../../db/neo4j'
|
import { getDriver } from '../../db/neo4j'
|
||||||
import { createTestClient } from 'apollo-server-testing'
|
import { createTestClient } from 'apollo-server-testing'
|
||||||
import createServer from '../.././server'
|
import createServer from '../.././server'
|
||||||
|
import {
|
||||||
|
markAsReadMutation,
|
||||||
|
markAllAsReadMutation,
|
||||||
|
notificationQuery,
|
||||||
|
} from '../../graphql/notifications'
|
||||||
|
|
||||||
const driver = getDriver()
|
const driver = getDriver()
|
||||||
let authenticatedUser
|
let authenticatedUser
|
||||||
@ -146,26 +151,9 @@ describe('given some notifications', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('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', () => {
|
describe('unauthenticated', () => {
|
||||||
it('throws authorization error', async () => {
|
it('throws authorization error', async () => {
|
||||||
const { errors } = await query({ query: notificationQuery })
|
const { errors } = await query({ query: notificationQuery() })
|
||||||
expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
|
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: {
|
data: {
|
||||||
notifications: expect.arrayContaining(expected),
|
notifications: expect.arrayContaining(expected),
|
||||||
},
|
},
|
||||||
@ -246,7 +234,7 @@ describe('given some notifications', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const response = await query({
|
const response = await query({
|
||||||
query: notificationQuery,
|
query: notificationQuery(),
|
||||||
variables: { ...variables, read: false },
|
variables: { ...variables, read: false },
|
||||||
})
|
})
|
||||||
await expect(response).toMatchObject(expected)
|
await expect(response).toMatchObject(expected)
|
||||||
@ -275,14 +263,14 @@ describe('given some notifications', () => {
|
|||||||
|
|
||||||
it('reduces notifications list', async () => {
|
it('reduces notifications list', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
query({ query: notificationQuery, variables: { ...variables, read: false } }),
|
query({ query: notificationQuery(), variables: { ...variables, read: false } }),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
data: { notifications: [expect.any(Object), expect.any(Object)] },
|
data: { notifications: [expect.any(Object), expect.any(Object)] },
|
||||||
errors: undefined,
|
errors: undefined,
|
||||||
})
|
})
|
||||||
await deletePostAction()
|
await deletePostAction()
|
||||||
await expect(
|
await expect(
|
||||||
query({ query: notificationQuery, variables: { ...variables, read: false } }),
|
query({ query: notificationQuery(), variables: { ...variables, read: false } }),
|
||||||
).resolves.toMatchObject({ data: { notifications: [] }, errors: undefined })
|
).resolves.toMatchObject({ data: { notifications: [] }, errors: undefined })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -291,27 +279,10 @@ describe('given some notifications', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('markAsRead', () => {
|
describe('markAsRead', () => {
|
||||||
const markAsReadMutation = gql`
|
|
||||||
mutation ($id: ID!) {
|
|
||||||
markAsRead(id: $id) {
|
|
||||||
from {
|
|
||||||
__typename
|
|
||||||
... on Post {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
... on Comment {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
read
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
describe('unauthenticated', () => {
|
describe('unauthenticated', () => {
|
||||||
it('throws authorization error', async () => {
|
it('throws authorization error', async () => {
|
||||||
const result = await mutate({
|
const result = await mutate({
|
||||||
mutation: markAsReadMutation,
|
mutation: markAsReadMutation(),
|
||||||
variables: { ...variables, id: 'p1' },
|
variables: { ...variables, id: 'p1' },
|
||||||
})
|
})
|
||||||
expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!')
|
expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!')
|
||||||
@ -332,7 +303,7 @@ describe('given some notifications', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns null', async () => {
|
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.data.markAsRead).toEqual(null)
|
||||||
expect(response.errors).toBeUndefined()
|
expect(response.errors).toBeUndefined()
|
||||||
})
|
})
|
||||||
@ -348,7 +319,7 @@ describe('given some notifications', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('updates `read` attribute and returns NOTIFIED relationship', async () => {
|
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({
|
expect(data).toEqual({
|
||||||
markAsRead: {
|
markAsRead: {
|
||||||
from: {
|
from: {
|
||||||
@ -369,7 +340,7 @@ describe('given some notifications', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
it('returns null', async () => {
|
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.data.markAsRead).toEqual(null)
|
||||||
expect(response.errors).toBeUndefined()
|
expect(response.errors).toBeUndefined()
|
||||||
})
|
})
|
||||||
@ -385,7 +356,7 @@ describe('given some notifications', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('updates `read` attribute and returns NOTIFIED relationship', async () => {
|
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({
|
expect(data).toEqual({
|
||||||
markAsRead: {
|
markAsRead: {
|
||||||
from: {
|
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: {} })
|
const result = await query({ query: filterPosts(), variables: {} })
|
||||||
expect(result.data.Post).toHaveLength(3)
|
expect(result.data.Post).toHaveLength(4)
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
data: {
|
data: {
|
||||||
Post: expect.arrayContaining([
|
Post: expect.arrayContaining([
|
||||||
@ -1540,6 +1540,11 @@ describe('Posts in Groups', () => {
|
|||||||
title: 'A post without a group',
|
title: 'A post without a group',
|
||||||
content: 'I am a user who does not belong to a group yet.',
|
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',
|
id: 'post-to-hidden-group',
|
||||||
title: 'A post to a 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: {} })
|
const result = await query({ query: filterPosts(), variables: {} })
|
||||||
expect(result.data.Post).toHaveLength(2)
|
expect(result.data.Post).toHaveLength(4)
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
data: {
|
data: {
|
||||||
Post: expect.arrayContaining([
|
Post: expect.arrayContaining([
|
||||||
@ -1580,6 +1585,16 @@ describe('Posts in Groups', () => {
|
|||||||
title: 'A post without a group',
|
title: 'A post without a group',
|
||||||
content: 'I am a user who does not belong to a group yet.',
|
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,
|
errors: undefined,
|
||||||
@ -1603,9 +1618,9 @@ describe('Posts in Groups', () => {
|
|||||||
authenticatedUser = await allGroupsUser.toJson()
|
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: {} })
|
const result = await query({ query: filterPosts(), variables: {} })
|
||||||
expect(result.data.Post).toHaveLength(3)
|
expect(result.data.Post).toHaveLength(4)
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
data: {
|
data: {
|
||||||
Post: expect.arrayContaining([
|
Post: expect.arrayContaining([
|
||||||
@ -1624,6 +1639,11 @@ describe('Posts in Groups', () => {
|
|||||||
title: 'A post to a closed group',
|
title: 'A post to a closed group',
|
||||||
content: 'I am posting into a closed group as a member of the 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,
|
errors: undefined,
|
||||||
|
|||||||
@ -132,4 +132,9 @@ type Mutation {
|
|||||||
userId: ID!
|
userId: ID!
|
||||||
roleInGroup: GroupMemberRole!
|
roleInGroup: GroupMemberRole!
|
||||||
): User
|
): User
|
||||||
|
|
||||||
|
RemoveUserFromGroup(
|
||||||
|
groupId: ID!
|
||||||
|
userId: ID!
|
||||||
|
): User
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ type Query {
|
|||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
markAsRead(id: ID!): NOTIFIED
|
markAsRead(id: ID!): NOTIFIED
|
||||||
|
markAllAsRead: [NOTIFIED]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subscription {
|
type Subscription {
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
"ignoreTestFiles": "*.js",
|
"ignoreTestFiles": "*.js",
|
||||||
"chromeWebSecurity": false,
|
"chromeWebSecurity": false,
|
||||||
"baseUrl": "http://localhost:3000",
|
"baseUrl": "http://localhost:3000",
|
||||||
|
"video":false,
|
||||||
"retries": {
|
"retries": {
|
||||||
"runMode": 2,
|
"runMode": 2,
|
||||||
"openMode": 0
|
"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
|
[hosted Neo4j Sandbox instance](https://neo4j.com/download/), run Neo4j in one
|
||||||
of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/),
|
of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/),
|
||||||
[spin up Neo4j in a Docker container](https://neo4j.com/developer/docker/),
|
[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/).
|
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
|
Just be sure to update the Neo4j connection string and credentials accordingly
|
||||||
in `backend/.env`.
|
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:
|
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.
|
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 {
|
.filterActive {
|
||||||
background-color: $color-success-active;
|
color: $color-primary-inverse;
|
||||||
|
background-color: $color-primary-active;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<a href="#" slot="default" slot-scope="{ toggleMenu }" @click.prevent="toggleMenu()">
|
<a href="#" slot="default" slot-scope="{ toggleMenu }" @click.prevent="toggleMenu()">
|
||||||
<ds-text bold size="large">{{ $t('admin.categories.name') }}</ds-text>
|
<ds-text bold size="large">{{ $t('admin.categories.name') }}</ds-text>
|
||||||
</a>
|
</a>
|
||||||
<template slot="popover">
|
<template #popover>
|
||||||
<div class="category-menu-options">
|
<div class="category-menu-options">
|
||||||
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
|
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
|
||||||
<categories-filter v-if="categoriesActive" />
|
<categories-filter v-if="categoriesActive" />
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
>
|
>
|
||||||
<base-icon class="dropdown-arrow" name="angle-down" />
|
<base-icon class="dropdown-arrow" name="angle-down" />
|
||||||
</base-button>
|
</base-button>
|
||||||
<template slot="popover">
|
<template #popover>
|
||||||
<filter-menu-component />
|
<filter-menu-component />
|
||||||
</template>
|
</template>
|
||||||
</dropdown>
|
</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 { mount } from '@vue/test-utils'
|
||||||
import GroupMember from './GroupMember.vue'
|
import GroupMember from './GroupMember.vue'
|
||||||
|
import { changeGroupMemberRoleMutation, removeUserFromGroupMutation } from '~/graphql/groups.js'
|
||||||
|
|
||||||
const localVue = global.localVue
|
const localVue = global.localVue
|
||||||
|
|
||||||
const propsData = {
|
const propsData = {
|
||||||
groupId: '',
|
groupId: 'group-id',
|
||||||
groupMembers: [],
|
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', () => {
|
describe('GroupMember', () => {
|
||||||
let wrapper
|
let wrapper
|
||||||
let mocks
|
let mocks
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks = {
|
mocks = {
|
||||||
$t: jest.fn(),
|
$t: jest.fn((t) => t),
|
||||||
|
$apollo: {
|
||||||
|
mutate: apolloMock,
|
||||||
|
},
|
||||||
|
$toast: {
|
||||||
|
error: toastErrorMock,
|
||||||
|
success: toastSuccessMock,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('mount', () => {
|
describe('mount', () => {
|
||||||
const Wrapper = () => {
|
const Wrapper = () => {
|
||||||
return mount(GroupMember, { propsData, mocks, localVue })
|
return mount(GroupMember, { propsData, mocks, localVue, stubs })
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -30,5 +69,120 @@ describe('GroupMember', () => {
|
|||||||
it('renders', () => {
|
it('renders', () => {
|
||||||
expect(wrapper.findAll('.group-member')).toHaveLength(1)
|
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>
|
</ds-chip>
|
||||||
</template>
|
</template>
|
||||||
<template #edit="scope">
|
<template #edit="scope">
|
||||||
<ds-button v-if="scope.row.myRoleInGroup !== 'owner'" size="small" primary disabled>
|
<base-button
|
||||||
<!-- TODO: implement removal of group members -->
|
v-if="scope.row.myRoleInGroup !== 'owner'"
|
||||||
<!-- :disabled="scope.row.myRoleInGroup === 'owner'"
|
size="small"
|
||||||
-->
|
primary
|
||||||
|
@click="
|
||||||
|
isOpen = true
|
||||||
|
userId = scope.row.id
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ $t('group.removeMemberButton') }}
|
{{ $t('group.removeMemberButton') }}
|
||||||
</ds-button>
|
</base-button>
|
||||||
</template>
|
</template>
|
||||||
</ds-table>
|
</ds-table>
|
||||||
<!-- TODO: implement removal of group members -->
|
<ds-modal
|
||||||
<!-- TODO: change to ocelot.social modal -->
|
v-if="isOpen"
|
||||||
<!-- <ds-modal
|
v-model="isOpen"
|
||||||
v-if="isOpen"
|
:title="`${$t('group.removeMember')}`"
|
||||||
v-model="isOpen"
|
force
|
||||||
:title="`${$t('group.removeMember')}`"
|
extended
|
||||||
force
|
:confirm-label="$t('group.removeMember')"
|
||||||
extended
|
:cancel-label="$t('actions.cancel')"
|
||||||
:confirm-label="$t('group.removeMember')"
|
@confirm="removeUser()"
|
||||||
:cancel-label="$t('actions.cancel')"
|
/>
|
||||||
@confirm="deleteMember(memberId)"
|
|
||||||
/> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { changeGroupMemberRoleMutation } from '~/graphql/groups.js'
|
import { changeGroupMemberRoleMutation, removeUserFromGroupMutation } from '~/graphql/groups.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GroupMember',
|
name: 'GroupMember',
|
||||||
@ -96,6 +99,8 @@ export default {
|
|||||||
query: '',
|
query: '',
|
||||||
searchProcess: null,
|
searchProcess: null,
|
||||||
user: {},
|
user: {},
|
||||||
|
isOpen: false,
|
||||||
|
userId: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -139,6 +144,25 @@ export default {
|
|||||||
this.$toast.error(error.message)
|
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>
|
</script>
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<dropdown class="invite-button" offset="8" :placement="placement">
|
<dropdown class="invite-button" offset="8" :placement="placement">
|
||||||
<template #default="{ toggleMenu }">
|
<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>
|
||||||
<template #popover>
|
<template #popover>
|
||||||
<div class="invite-button-menu-popover">
|
<div class="invite-button-menu-popover">
|
||||||
@ -15,10 +24,7 @@
|
|||||||
ghost
|
ghost
|
||||||
@click="copyInviteLink"
|
@click="copyInviteLink"
|
||||||
>
|
>
|
||||||
<ds-text bold>
|
<ds-text bold>{{ $t('invite-codes.copy-code') }}</ds-text>
|
||||||
{{ $t('invite-codes.copy-code') }}
|
|
||||||
{{ inviteCode.code }}
|
|
||||||
</ds-text>
|
|
||||||
</base-button>
|
</base-button>
|
||||||
</base-card>
|
</base-card>
|
||||||
</div>
|
</div>
|
||||||
@ -108,6 +114,6 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invite-code {
|
.invite-code {
|
||||||
left: 50%;
|
margin-left: 25%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export default {
|
|||||||
/* dirty fix to override broken styleguide inline-styles */
|
/* dirty fix to override broken styleguide inline-styles */
|
||||||
.ds-grid {
|
.ds-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)) !important;
|
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)) !important;
|
||||||
gap: 16px !important;
|
gap: 32px 16px !important;
|
||||||
grid-auto-rows: 20px;
|
grid-auto-rows: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,15 +12,24 @@
|
|||||||
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
|
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
|
||||||
</base-button>
|
</base-button>
|
||||||
</template>
|
</template>
|
||||||
<template slot="popover">
|
<template #popover>
|
||||||
<div class="notifications-menu-popover">
|
<div class="notifications-menu-popover">
|
||||||
<notification-list :notifications="notifications" @markAsRead="markAsRead" />
|
<notification-list :notifications="notifications" @markAsRead="markAsRead" />
|
||||||
</div>
|
</div>
|
||||||
<div class="notifications-link-container">
|
<ds-flex class="notifications-link-container">
|
||||||
<nuxt-link :to="{ name: 'notifications' }">
|
<ds-flex-item :width="{ base: 'auto' }" centered>
|
||||||
{{ $t('notifications.pageLink') }}
|
<nuxt-link :to="{ name: 'notifications' }">
|
||||||
</nuxt-link>
|
<ds-button ghost primary>
|
||||||
</div>
|
{{ $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>
|
</template>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</template>
|
</template>
|
||||||
@ -28,7 +37,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
import unionBy from 'lodash/unionBy'
|
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 CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
|
||||||
import Dropdown from '~/components/Dropdown'
|
import Dropdown from '~/components/Dropdown'
|
||||||
import NotificationList from '../NotificationList/NotificationList'
|
import NotificationList from '../NotificationList/NotificationList'
|
||||||
@ -56,8 +70,21 @@ export default {
|
|||||||
mutation: markAsReadMutation(this.$i18n),
|
mutation: markAsReadMutation(this.$i18n),
|
||||||
variables,
|
variables,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
this.$toast.error(err.message)
|
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)
|
}, 0)
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
hasNotifications() {
|
||||||
|
return this.notifications.length
|
||||||
|
},
|
||||||
},
|
},
|
||||||
apollo: {
|
apollo: {
|
||||||
notifications: {
|
notifications: {
|
||||||
@ -118,7 +148,7 @@ export default {
|
|||||||
}
|
}
|
||||||
.notifications-link-container {
|
.notifications-link-container {
|
||||||
background-color: $background-color-softer-active;
|
background-color: $background-color-softer-active;
|
||||||
text-align: center;
|
justify-content: center;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
|
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
|
||||||
>
|
>
|
||||||
<div class="categories" v-if="categoriesActive">
|
<div class="categories" v-if="categoriesActive">
|
||||||
<hc-category
|
<category
|
||||||
v-for="category in post.categories"
|
v-for="category in post.categories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
v-tooltip="{
|
v-tooltip="{
|
||||||
@ -42,7 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="categories-placeholder"></div>
|
<div v-else class="categories-placeholder"></div>
|
||||||
<counter-icon
|
<counter-icon
|
||||||
icon="bullhorn"
|
icon="heart-o"
|
||||||
:count="post.shoutedCount"
|
:count="post.shoutedCount"
|
||||||
:title="$t('contribution.amount-shouts', { amount: post.shoutedCount })"
|
:title="$t('contribution.amount-shouts', { amount: post.shoutedCount })"
|
||||||
/>
|
/>
|
||||||
@ -81,11 +81,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
import Category from '~/components/Category'
|
||||||
import ContentMenu from '~/components/ContentMenu/ContentMenu'
|
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 CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
|
||||||
|
import HcRibbon from '~/components/Ribbon'
|
||||||
|
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
import PostMutations from '~/graphql/PostMutations'
|
import PostMutations from '~/graphql/PostMutations'
|
||||||
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
|
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
|
||||||
@ -93,11 +93,11 @@ import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostH
|
|||||||
export default {
|
export default {
|
||||||
name: 'PostTeaser',
|
name: 'PostTeaser',
|
||||||
components: {
|
components: {
|
||||||
UserTeaser,
|
Category,
|
||||||
HcCategory,
|
|
||||||
HcRibbon,
|
|
||||||
ContentMenu,
|
ContentMenu,
|
||||||
CounterIcon,
|
CounterIcon,
|
||||||
|
HcRibbon,
|
||||||
|
UserTeaser,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
post: {
|
post: {
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:filled="shouted"
|
:filled="shouted"
|
||||||
icon="bullhorn"
|
icon="heart-o"
|
||||||
circle
|
circle
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ storiesOf('Generic/BaseButton', module)
|
|||||||
template: `
|
template: `
|
||||||
<div>
|
<div>
|
||||||
<base-button icon="edit">With Text</base-button>
|
<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" disabled />
|
||||||
<base-button icon="trash" loading />
|
<base-button icon="trash" loading />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<span class="counts">
|
<span class="counts">
|
||||||
<counter-icon icon="comments" :count="option.commentsCount" soft />
|
<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="hand-pointer" :count="option.clickedCount" soft />
|
||||||
<counter-icon icon="eye" :count="option.viewedTeaserCount" soft />
|
<counter-icon icon="eye" :count="option.viewedTeaserCount" soft />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -108,7 +108,7 @@ export const mapUserQuery = (i18n) => {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notificationQuery = (i18n) => {
|
export const notificationQuery = (_i18n) => {
|
||||||
return gql`
|
return gql`
|
||||||
${userFragment}
|
${userFragment}
|
||||||
${commentFragment}
|
${commentFragment}
|
||||||
@ -147,7 +147,7 @@ export const notificationQuery = (i18n) => {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const markAsReadMutation = (i18n) => {
|
export const markAsReadMutation = (_i18n) => {
|
||||||
return gql`
|
return gql`
|
||||||
${userFragment}
|
${userFragment}
|
||||||
${commentFragment}
|
${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 = () => {
|
export const notificationAdded = () => {
|
||||||
return gql`
|
return gql`
|
||||||
${userFragment}
|
${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
|
// ------ queries
|
||||||
|
|
||||||
export const groupQuery = (i18n) => {
|
export const groupQuery = (i18n) => {
|
||||||
|
|||||||
@ -270,7 +270,7 @@
|
|||||||
"filterFollow": "Beiträge von Nutzern filtern, denen ich folge",
|
"filterFollow": "Beiträge von Nutzern filtern, denen ich folge",
|
||||||
"filterMasonryGrid": {
|
"filterMasonryGrid": {
|
||||||
"myFriends": "Nutzer denen ich folge",
|
"myFriends": "Nutzer denen ich folge",
|
||||||
"myGroups": "Meine Gruppen",
|
"myGroups": "Aus meinen Gruppen",
|
||||||
"myTopics": "Meine Themen",
|
"myTopics": "Meine Themen",
|
||||||
"noFilter": "Beiträge filtern"
|
"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!",
|
"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?"
|
"title": "Möchtest du wirklich die Gruppe verlassen?"
|
||||||
},
|
},
|
||||||
|
"memberRemoved": "Nutzer „{name}“ wurde aus der Gruppe entfernt!",
|
||||||
"members": "Mitglieder",
|
"members": "Mitglieder",
|
||||||
"membersAdministrationList": {
|
"membersAdministrationList": {
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
@ -514,10 +515,13 @@
|
|||||||
"no-results": "Keine Beiträge gefunden."
|
"no-results": "Keine Beiträge gefunden."
|
||||||
},
|
},
|
||||||
"invite-codes": {
|
"invite-codes": {
|
||||||
"copy-code": "Code:",
|
"button": {
|
||||||
|
"tooltip": "Lade deine Freunde ein"
|
||||||
|
},
|
||||||
|
"copy-code": "Einladungslink kopieren",
|
||||||
"copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert",
|
"copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert",
|
||||||
"not-available": "Du hast keinen Einladungscode zur Verfügung!",
|
"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": {
|
"login": {
|
||||||
"email": "Deine E-Mail",
|
"email": "Deine E-Mail",
|
||||||
@ -639,6 +643,7 @@
|
|||||||
"read": "Gelesen",
|
"read": "Gelesen",
|
||||||
"unread": "Ungelesen"
|
"unread": "Ungelesen"
|
||||||
},
|
},
|
||||||
|
"markAllAsRead": "Markiere alle als gelesen",
|
||||||
"pageLink": "Alle Benachrichtigungen",
|
"pageLink": "Alle Benachrichtigungen",
|
||||||
"post": "Beitrag",
|
"post": "Beitrag",
|
||||||
"reason": {
|
"reason": {
|
||||||
|
|||||||
@ -270,7 +270,7 @@
|
|||||||
"filterFollow": "Filter contributions from users I follow",
|
"filterFollow": "Filter contributions from users I follow",
|
||||||
"filterMasonryGrid": {
|
"filterMasonryGrid": {
|
||||||
"myFriends": "Users I follow",
|
"myFriends": "Users I follow",
|
||||||
"myGroups": "My groups",
|
"myGroups": "By my groups",
|
||||||
"myTopics": "My topics",
|
"myTopics": "My topics",
|
||||||
"noFilter": "Filter posts"
|
"noFilter": "Filter posts"
|
||||||
},
|
},
|
||||||
@ -455,6 +455,7 @@
|
|||||||
"message": "Leaving a group may be irreversible!<br>Leave group <b>“{name}”</b>!",
|
"message": "Leaving a group may be irreversible!<br>Leave group <b>“{name}”</b>!",
|
||||||
"title": "Do you really want to leave the group?"
|
"title": "Do you really want to leave the group?"
|
||||||
},
|
},
|
||||||
|
"memberRemoved": "User “{name}” was removed from group!",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
"membersAdministrationList": {
|
"membersAdministrationList": {
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
@ -514,10 +515,13 @@
|
|||||||
"no-results": "No contributions found."
|
"no-results": "No contributions found."
|
||||||
},
|
},
|
||||||
"invite-codes": {
|
"invite-codes": {
|
||||||
"copy-code": "Code:",
|
"button": {
|
||||||
|
"tooltip": "Invite your friends"
|
||||||
|
},
|
||||||
|
"copy-code": "Copy Invite Link",
|
||||||
"copy-success": "Invite code copied to clipboard",
|
"copy-success": "Invite code copied to clipboard",
|
||||||
"not-available": "You have no valid invite code available!",
|
"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": {
|
"login": {
|
||||||
"email": "Your E-mail",
|
"email": "Your E-mail",
|
||||||
@ -639,6 +643,7 @@
|
|||||||
"read": "Read",
|
"read": "Read",
|
||||||
"unread": "Unread"
|
"unread": "Unread"
|
||||||
},
|
},
|
||||||
|
"markAllAsRead": "Mark all as read",
|
||||||
"pageLink": "All notifications",
|
"pageLink": "All notifications",
|
||||||
"post": "Post",
|
"post": "Post",
|
||||||
"reason": {
|
"reason": {
|
||||||
|
|||||||
@ -412,6 +412,7 @@
|
|||||||
"read": "Leído",
|
"read": "Leído",
|
||||||
"unread": "No leído"
|
"unread": "No leído"
|
||||||
},
|
},
|
||||||
|
"markAllAsRead": "Marcar todas como leido",
|
||||||
"pageLink": "Todas las notificaciones",
|
"pageLink": "Todas las notificaciones",
|
||||||
"post": "Contribución",
|
"post": "Contribución",
|
||||||
"reason": {
|
"reason": {
|
||||||
|
|||||||
@ -401,6 +401,7 @@
|
|||||||
"read": "Lire",
|
"read": "Lire",
|
||||||
"unread": "Non lu"
|
"unread": "Non lu"
|
||||||
},
|
},
|
||||||
|
"markAllAsRead": "Tout marquer comme lu",
|
||||||
"pageLink": "Toutes les notifications",
|
"pageLink": "Toutes les notifications",
|
||||||
"post": "Post",
|
"post": "Post",
|
||||||
"reason": {
|
"reason": {
|
||||||
|
|||||||
@ -354,6 +354,7 @@
|
|||||||
"read": null,
|
"read": null,
|
||||||
"unread": null
|
"unread": null
|
||||||
},
|
},
|
||||||
|
"markAllAsRead": "Segna tutti come letti",
|
||||||
"pageLink": null,
|
"pageLink": null,
|
||||||
"post": null,
|
"post": null,
|
||||||
"reason": {
|
"reason": {
|
||||||
|
|||||||
@ -100,6 +100,26 @@
|
|||||||
"moreInfo": "Wat is {APPLICATION_NAME}?",
|
"moreInfo": "Wat is {APPLICATION_NAME}?",
|
||||||
"password": "Uw Wachtwoord"
|
"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": {
|
"post": {
|
||||||
"moreInfo": {
|
"moreInfo": {
|
||||||
"name": "Meer info"
|
"name": "Meer info"
|
||||||
|
|||||||
@ -199,6 +199,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
"markAllAsRead": "Oznacz wszystkie jako przeczytane",
|
||||||
"menu": {
|
"menu": {
|
||||||
"mentioned": "wspomiał o Tobie we wpisie"
|
"mentioned": "wspomiał o Tobie we wpisie"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -390,6 +390,7 @@
|
|||||||
"read": "Lido",
|
"read": "Lido",
|
||||||
"unread": "Não lido"
|
"unread": "Não lido"
|
||||||
},
|
},
|
||||||
|
"markAllAsRead": "Marcar todas como lidas",
|
||||||
"pageLink": "Todas as notificações",
|
"pageLink": "Todas as notificações",
|
||||||
"post": "Post",
|
"post": "Post",
|
||||||
"reason": {
|
"reason": {
|
||||||
|
|||||||
@ -426,6 +426,7 @@
|
|||||||
"read": "Прочитанные",
|
"read": "Прочитанные",
|
||||||
"unread": "Непрочитанные"
|
"unread": "Непрочитанные"
|
||||||
},
|
},
|
||||||
|
"markAllAsRead": "Отметить все как прочитанное",
|
||||||
"pageLink": "Все уведомления",
|
"pageLink": "Все уведомления",
|
||||||
"post": "Пост",
|
"post": "Пост",
|
||||||
"reason": {
|
"reason": {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'viewport',
|
name: 'viewport',
|
||||||
content: 'width=device-width, initial-scale=1',
|
content: 'initial-scale=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hid: 'description',
|
hid: 'description',
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'viewport',
|
name: 'viewport',
|
||||||
content: 'width=device-width, initial-scale=1',
|
content: 'initial-scale=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hid: 'description',
|
hid: 'description',
|
||||||
|
|||||||
@ -43,47 +43,30 @@
|
|||||||
|
|
||||||
<base-icon class="my-filter-button" :name="filterButtonIcon"></base-icon>
|
<base-icon class="my-filter-button" :name="filterButtonIcon"></base-icon>
|
||||||
</base-button>
|
</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']">
|
<header-button
|
||||||
<base-button class="my-filter-button" right @click="showFilter = !showFilter" filled>
|
v-if="postsFilter['categories_some']"
|
||||||
{{ $t('contribution.filterMasonryGrid.myGroups') }}
|
:title="$t('contribution.filterMasonryGrid.myTopics')"
|
||||||
</base-button>
|
:clickButton="openFilterMenu"
|
||||||
<base-button
|
:titleRemove="$t('filter-menu.deleteFilter')"
|
||||||
class="filter-remove"
|
:clickRemove="resetCategories"
|
||||||
@click="resetByGroups"
|
/>
|
||||||
icon="close"
|
|
||||||
:title="$t('filter-menu.deleteFilter')"
|
|
||||||
style="margin-left: -8px"
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
|
<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 id="my-filter" v-if="showFilter">
|
||||||
<div @mouseleave="showFilter = false">
|
<div @mouseleave="showFilter = false">
|
||||||
<filter-menu-component @showFilterMenu="showFilterMenu" />
|
<filter-menu-component @showFilterMenu="showFilterMenu" />
|
||||||
@ -92,16 +75,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ds-grid-item>
|
</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">
|
<ds-grid-item v-if="hashtag" :row-span="2" column-span="fullWidth">
|
||||||
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
|
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
|
||||||
</ds-grid-item>
|
</ds-grid-item>
|
||||||
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
|
|
||||||
<!-- donation info -->
|
<!-- donation info -->
|
||||||
<ds-grid-item v-if="showDonations" class="top-info-bar" :row-span="1" column-span="fullWidth">
|
<ds-grid-item v-if="showDonations" class="top-info-bar" :row-span="1" column-span="fullWidth">
|
||||||
<donation-info :goal="goal" :progress="progress" />
|
<donation-info :goal="goal" :progress="progress" />
|
||||||
</ds-grid-item>
|
</ds-grid-item>
|
||||||
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
|
|
||||||
<!-- news feed -->
|
<!-- news feed -->
|
||||||
<template v-if="hasResults">
|
<template v-if="hasResults">
|
||||||
<masonry-grid-item
|
<masonry-grid-item
|
||||||
@ -142,6 +125,7 @@ import HcEmpty from '~/components/Empty/Empty'
|
|||||||
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
|
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
|
||||||
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
|
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
|
||||||
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
|
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
|
||||||
|
import HeaderButton from '~/components/FilterMenu/HeaderButton'
|
||||||
import { mapGetters, mapMutations } from 'vuex'
|
import { mapGetters, mapMutations } from 'vuex'
|
||||||
import { DonationsQuery } from '~/graphql/Donations'
|
import { DonationsQuery } from '~/graphql/Donations'
|
||||||
import { filterPosts } from '~/graphql/PostQuery.js'
|
import { filterPosts } from '~/graphql/PostQuery.js'
|
||||||
@ -159,6 +143,7 @@ export default {
|
|||||||
MasonryGrid,
|
MasonryGrid,
|
||||||
MasonryGridItem,
|
MasonryGridItem,
|
||||||
FilterMenuComponent,
|
FilterMenuComponent,
|
||||||
|
HeaderButton,
|
||||||
},
|
},
|
||||||
mixins: [postListActions],
|
mixins: [postListActions],
|
||||||
data() {
|
data() {
|
||||||
@ -225,6 +210,9 @@ export default {
|
|||||||
resetCategories: 'posts/RESET_CATEGORIES',
|
resetCategories: 'posts/RESET_CATEGORIES',
|
||||||
toggleCategory: 'posts/TOGGLE_CATEGORY',
|
toggleCategory: 'posts/TOGGLE_CATEGORY',
|
||||||
}),
|
}),
|
||||||
|
openFilterMenu() {
|
||||||
|
this.showFilter = !this.showFilter
|
||||||
|
},
|
||||||
showFilterMenu(e) {
|
showFilterMenu(e) {
|
||||||
if (!e || (!e.target.closest('#my-filter') && !e.target.closest('.my-filter-button'))) {
|
if (!e || (!e.target.closest('#my-filter') && !e.target.closest('.my-filter-button'))) {
|
||||||
if (!this.showFilter) return
|
if (!this.showFilter) return
|
||||||
@ -354,13 +342,18 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.filterButtonMenu {
|
.filterButtonMenu {
|
||||||
|
width: 95%;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
margin-top: -35px;
|
margin-top: -35px;
|
||||||
padding: 20px 10px 5px 10px;
|
padding: 20px 10px 5px 10px;
|
||||||
border-radius: 7px;
|
|
||||||
background-color: #f5f4f6;
|
background-color: #f5f4f6;
|
||||||
}
|
}
|
||||||
|
@media screen and (max-width: 656px) {
|
||||||
|
.filterButtonMenu {
|
||||||
|
margin-top: -50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
#my-filter {
|
#my-filter {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
box-shadow: rgb(189 189 189) 1px 9px 15px 1px;
|
box-shadow: rgb(189 189 189) 1px 9px 15px 1px;
|
||||||
@ -418,5 +411,8 @@ export default {
|
|||||||
font-size: 23px;
|
font-size: 23px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
.ds-grid {
|
||||||
|
padding-top: 1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,21 +1,22 @@
|
|||||||
import { shallowMount, mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import NotificationsPage from './index.vue'
|
import NotificationsPage from './index.vue'
|
||||||
|
|
||||||
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
|
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
|
||||||
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
|
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
|
||||||
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
|
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
|
||||||
|
|
||||||
|
import { markAsReadMutation, markAllAsReadMutation } from '~/graphql/User'
|
||||||
const localVue = global.localVue
|
const localVue = global.localVue
|
||||||
|
|
||||||
const stubs = {
|
const stubs = {
|
||||||
'client-only': true,
|
'client-only': true,
|
||||||
|
'notifications-table': true,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('PostIndex', () => {
|
describe('PostIndex', () => {
|
||||||
let wrapper, Wrapper, mocks, propsData
|
let wrapper, Wrapper, mocks
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
propsData = {}
|
|
||||||
mocks = {
|
mocks = {
|
||||||
$t: (string) => string,
|
$t: (string) => string,
|
||||||
$toast: {
|
$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', () => {
|
describe('mount', () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Wrapper = () => {
|
Wrapper = () => {
|
||||||
return mount(NotificationsPage, {
|
return mount(NotificationsPage, {
|
||||||
mocks,
|
mocks,
|
||||||
localVue,
|
localVue,
|
||||||
propsData,
|
|
||||||
stubs,
|
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', () => {
|
describe('filter', () => {
|
||||||
beforeEach(() => {
|
it('has "All" as default', () => {
|
||||||
propsData.filterOptions = [
|
expect(wrapper.find('a.dropdown-filter').text()).toBe('notifications.filterLabel.all')
|
||||||
{ label: 'All', value: null },
|
|
||||||
{ label: 'Read', value: true },
|
|
||||||
{ label: 'Unread', value: false },
|
|
||||||
]
|
|
||||||
wrapper = Wrapper()
|
|
||||||
wrapper.findComponent(DropdownFilter).vm.$emit('filter', propsData.filterOptions[1])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets `notificationRead` to value of received option', () => {
|
describe('select Read', () => {
|
||||||
expect(wrapper.vm.notificationRead).toEqual(propsData.filterOptions[1].value)
|
beforeEach(() => {
|
||||||
})
|
wrapper.findComponent(DropdownFilter).vm.$emit('filter', wrapper.vm.filterOptions[1])
|
||||||
|
})
|
||||||
|
|
||||||
it('set label to the label of the received option', () => {
|
it('sets `notificationRead` to value of received option', () => {
|
||||||
expect(wrapper.vm.selected).toEqual(propsData.filterOptions[1].label)
|
expect(wrapper.vm.notificationRead).toEqual(wrapper.vm.filterOptions[1].value)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refreshes the notifications', () => {
|
it('sets label to the label of the received option', () => {
|
||||||
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
|
expect(wrapper.vm.selected).toEqual(wrapper.vm.filterOptions[1].label)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes the notifications', () => {
|
||||||
|
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('markNotificationAsRead', () => {
|
describe('markNotificationAsRead', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = Wrapper()
|
|
||||||
wrapper
|
wrapper
|
||||||
.findComponent(NotificationsTable)
|
.findComponent(NotificationsTable)
|
||||||
.vm.$emit('markNotificationAsRead', 'notificationSourceId')
|
.vm.$emit('markNotificationAsRead', 'notificationSourceId')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls markNotificationAsRead mutation', () => {
|
it('calls markAllAsRead mutation', () => {
|
||||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
|
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
|
||||||
expect.objectContaining({ variables: { id: 'notificationSourceId' } }),
|
mutation: markAsReadMutation(),
|
||||||
)
|
variables: { id: 'notificationSourceId' },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ message: 'Some error message' })
|
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ message: 'Some error message' })
|
||||||
wrapper = Wrapper()
|
|
||||||
wrapper
|
wrapper
|
||||||
.findComponent(NotificationsTable)
|
.findComponent(NotificationsTable)
|
||||||
.vm.$emit('markNotificationAsRead', 'notificationSourceId')
|
.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', () => {
|
describe('PaginationButtons', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = Wrapper()
|
wrapper = Wrapper()
|
||||||
|
|||||||
@ -15,7 +15,27 @@
|
|||||||
@markNotificationAsRead="markNotificationAsRead"
|
@markNotificationAsRead="markNotificationAsRead"
|
||||||
:notifications="notifications"
|
: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>
|
</base-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -23,7 +43,7 @@
|
|||||||
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
|
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
|
||||||
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
|
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
|
||||||
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
|
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
|
||||||
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
|
import { notificationQuery, markAsReadMutation, markAllAsReadMutation } from '~/graphql/User'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -54,6 +74,15 @@ export default {
|
|||||||
{ label: this.$t('notifications.filterLabel.unread'), value: false },
|
{ 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: {
|
methods: {
|
||||||
filter(option) {
|
filter(option) {
|
||||||
@ -77,6 +106,20 @@ export default {
|
|||||||
next() {
|
next() {
|
||||||
this.offset += this.pageSize
|
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: {
|
apollo: {
|
||||||
notifications: {
|
notifications: {
|
||||||
@ -112,4 +155,8 @@ export default {
|
|||||||
.notifications-page-flex {
|
.notifications-page-flex {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notifications-footer {
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user