Merge pull request #5132 from Ocelot-Social-Community/5059-epic-groups

feat: 🍰  EPIC Groups
This commit is contained in:
Wolfgang Huß 2022-10-25 14:16:59 +02:00 committed by GitHub
commit 56fcd26649
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
144 changed files with 12258 additions and 771 deletions

View File

@ -4,7 +4,9 @@ on:
push:
branches:
- master
# - 5093-fix-automatic-deployment # for testing while developing
# - 5059-epic-groups # for testing while developing
# template branches in repo
# - template--separate-branch-auto-deployment--5059-epic-groups
jobs:
##############################################################################
@ -303,16 +305,19 @@ jobs:
# because this step 'kubectl -n default rollout status deployment/* --timeout=600s' does not work as expected
# and we need the pods to be up again for cleaning and seeding the Neo4j database and the backend.
# !!! this is not a perfect solution !!!
# deployments are regularely up again after 3 minutes and 10 seconds
# deployments are regularly up again after 3 minutes and 10 seconds
- name: Sleep for 4 minutes, means 240 seconds
run: sleep 240s
shell: bash
- name: Verify deployment and wait for the pods of each deplyment to get ready for cleaning and seeding of the database
- name: Verify deployment and wait for the pods of each deployment to get ready for cleaning and seeding of the database
run: |
kubectl -n default rollout status deployment/ocelot-backend --timeout=600s
kubectl -n default rollout status deployment/ocelot-neo4j --timeout=600s
kubectl -n default rollout status deployment/ocelot-maintenance --timeout=600s
kubectl -n default rollout status deployment/ocelot-webapp --timeout=600s
- name: Run migrations for Neo4j database via backend for staging
run: |
kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "yarn prod:migrate up"
- name: Reset and seed Neo4j database via backend for staging
# db cleaning and seeding is only possible in production if env 'PRODUCTION_DB_CLEAN_ALLOW=true' is set in deployment
run: |

View File

@ -202,6 +202,8 @@ jobs:
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend
- name: backend | Initialize Database
run: docker-compose exec -T backend yarn db:migrate init
- name: backend | Migrate Database Up
run: docker-compose exec -T backend yarn db:migrate up
- name: backend | Unit test
run: docker-compose exec -T backend yarn test
##########################################################################
@ -267,7 +269,7 @@ jobs:
report_name: Coverage Webapp
type: lcov
result_path: ./coverage/lcov.info
min_coverage: 65
min_coverage: 64
token: ${{ github.token }}
##############################################################################

View File

@ -4,18 +4,43 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.1.1](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.1.0...1.1.1)
#### [2.0.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.1.0...2.0.0)
- feat: 🍰 Search For Groups [`#5543`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5543)
- feat: 🍰 Mobile Footer Menu To Header Menu [`#5524`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5524)
- feat: 🍰 Implement `LOGO_HEADER_CLICK` As Configuration [`#5525`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5525)
- refactor: 🍰 Category Filter In Filter Menu [`#5527`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5527)
- feat: 🍰 Group Invitation [`#5512`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5512)
- feat: 🍰 Implement Post In Group In Webapp [`#5468`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5468)
- feat: :cake: Update Issue Templates [`#5508`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5508)
- refactor: 🍰 Disable Submit Button On group Update Changed Error [`#5489`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5489)
- feat: 🍰 Seed Posts In Groups [`#5503`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5503)
- feat: 🍰 Make Configurable Header Menu Translatable [`#5491`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5491)
- feat: 🍰 Post In Groups [`#5380`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5380)
- feat: 🍰 Refine Group Creation And Group Edit [`#5418`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5418)
- feat: 🍰 Implement Content Menu On Group Profile [`#5419`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5419)
- feat: 🍰 Group Members Management [`#5345`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5345)
- feat: 🍰 Have My Groups In The User Menu And Configure Groups Button In Header [`#5411`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5411)
- chore: 🍰 Implement Automatic Deployment For Groups Branch '5059-epic-groups' [`#5408`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5408)
- Chore: 🍰 Release v1.1.1 Refactor Rebranding [`#5392`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5392)
- chore: 🍰 Refactor Rebranding [`#5390`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5390)
- feat: 🍰 Group Profile Description Etc [`#5368`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5368)
- feat: 🍰 Tooltips For Topics [`#5350`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5350)
- feat: 🍰 Group Profile Members List Etc [`#5335`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5335)
- feat: 🍰 Implement Group Profile Visibility [`#5332`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5332)
- feat: 🍰 Save Categories In Frontend [`#5284`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5284)
- feat: 🍰 Add New Yunite Icons [`#5319`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5319)
- chore: 🍰 Update Neode From v^0.4.7 To v^0.4.8 In Backend [`#5334`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5334)
- feat: 🍰 My Groups Page [`#5148`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5148)
- feat: 🍰 Hidden Groups Shall Not Be Visible For None Or Pending Members In Backend [`#5317`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5317)
- feat: 🍰 Implement Group Profile [`#5197`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5197)
- fix: Category Filter Menu Client Only [`#5301`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5301)
- feat: Save Category Settings [`#5261`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5261)
- feat: 🍰 Implement `UpdateGroup` Resolver [`#5224`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5224)
- feat: Topics Menu [`#5248`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5248)
- docs: 🍰 Document GraqhQL Playground [`#5253`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5253)
- feat: Categories Filter Menu [`#5198`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5198)
- feat: 🍰 Implement `JoinGroup`, `GroupMember`, `SwitchGroupMemberRole` Resolvers [`#5199`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5199)
- fix: 🍰 Fix Test Description From `enter-nonce.vue` To `change-password` [`#5217`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5217)
- Bump cookie-universal-nuxt from 2.1.5 to 2.2.2 in /webapp [`#5218`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5218)
- Bump prettier from 2.2.1 to 2.7.1 in /webapp [`#5170`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5170)
@ -27,10 +52,12 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- feat: 🍰 Change Error Message With `Authorised` To `Authorized` All Over The Place To Have American English [`#5206`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5206)
- Bump cross-env from 7.0.2 to 7.0.3 in /webapp [`#5168`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5168)
- chore: 🍰 Add `--logHeapUsage` To Jest Test Call [`#5182`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5182)
- chore: 🍰 Add Groups To Seeding [`#5185`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5185)
- feat: 🍰 Implement Group GQL Model And CRUD Resolvers First Step [`#5139`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5139)
- refactor: 🍰 Rename `UserGroup` To `UserRole` [`#5143`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5143)
- add new yunite icons [`bb0d632`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/bb0d6329e7e36ea03671318ea8dd128a6d5a5a7a)
- cleanup refactor rebranding [`5f5c0fa`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/5f5c0faa1f28cd4df7681eba335ae5998b2d9cca)
- change color and scss in branding [`52070b8`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/52070b8c570970bf48df561134bf67cb4111b640)
- Refine design and a bit functionality, first step [`13ec4ee`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/13ec4ee776728983e25d87078b804bdaaca66e38)
- add tets for posts in groups [`a9cd661`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/a9cd661e63181dc5ea207b2e1e656f720e0b10d0)
- Refine design and functionality of group list and create, edit group [`7b11122`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/7b11122bea4868624dd1c1641219e71070412e20)
#### [1.1.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.9...1.1.0)

View File

@ -29,4 +29,4 @@ AWS_BUCKET=
EMAIL_DEFAULT_SENDER="devops@ocelot.social"
EMAIL_SUPPORT="devops@ocelot.social"
CATEGORIES_ACTIVE=false
CATEGORIES_ACTIVE=false

View File

@ -7,8 +7,9 @@ Run the following command to install everything through docker.
The installation takes a bit longer on the first pass or on rebuild ...
```bash
# in main folder
$ docker-compose up
# or
# rebuild the containers for a cleanup
$ docker-compose up --build
```
@ -28,6 +29,7 @@ between different local node versions.
Install node dependencies with [yarn](https://yarnpkg.com/en/):
```bash
# in main folder
$ cd backend
$ yarn install
```
@ -45,12 +47,14 @@ a [local Neo4J](http://localhost:7474) instance is up and running.
Start the backend for development with:
```bash
# in backend/
$ yarn run dev
```
or start the backend in production environment with:
```bash
# in backend/
$ yarn run start
```
@ -73,6 +77,7 @@ backend is running:
{% tab title="Docker" %}
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:migrate init
```
@ -80,7 +85,7 @@ $ docker-compose exec backend yarn run db:migrate init
{% tab title="Without Docker" %}
```bash
# in folder backend/
# in folder backend/ while database is running
# make sure your database is running on http://localhost:7474/browser/
yarn run db:migrate init
```
@ -99,12 +104,14 @@ need to seed your database:
In another terminal run:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:seed
```
To reset the database run:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:reset
# you could also wipe out your neo4j database and delete all volumes with:
$ docker-compose down -v
@ -118,12 +125,14 @@ $ docker-compose exec backend yarn run db:migrate init
Run:
```bash
# in backend/ while database is running
$ yarn run db:seed
```
To reset the database run:
```bash
# in backend/ while database is running
$ yarn run db:reset
```
@ -141,6 +150,7 @@ you have to migrate your data e.g. because your data modeling has changed.
Generate a data migration file:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:migrate:create your_data_migration
# Edit the file in ./src/db/migrations/
```
@ -148,6 +158,7 @@ $ docker-compose exec backend yarn run db:migrate:create your_data_migration
To run the migration:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run db:migrate up
```
@ -157,6 +168,7 @@ $ docker-compose exec backend yarn run db:migrate up
Generate a data migration file:
```bash
# in backend/
$ yarn run db:migrate:create your_data_migration
# Edit the file in ./src/db/migrations/
```
@ -164,6 +176,7 @@ $ yarn run db:migrate:create your_data_migration
To run the migration:
```bash
# in backend/ while database is running
$ yarn run db:migrate up
```
@ -181,6 +194,7 @@ database after each test, running the tests will wipe out all your data!
Run the unit tests:
```bash
# in main folder while docker-compose is running
$ docker-compose exec backend yarn run test
```
@ -191,6 +205,7 @@ $ docker-compose exec backend yarn run test
Run the unit tests:
```bash
# in backend/ while database is running
$ yarn run test
```

View File

@ -1,6 +1,6 @@
{
"name": "ocelot-social-backend",
"version": "1.1.1",
"version": "2.0.0",
"description": "GraphQL Backend for ocelot.social",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",

View File

@ -1,3 +1,7 @@
// this file is duplicated in `backend/src/constants/metadata.js` and `webapp/constants/metadata.js`
export const CATEGORIES_MIN = 1
export const CATEGORIES_MAX = 3
export const categories = [
{
icon: 'networking',

View File

@ -0,0 +1,3 @@
// this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js`
export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags
export const DESCRIPTION_EXCERPT_HTML_LENGTH = 250 // with removed HTML tags

View File

@ -0,0 +1,30 @@
import gql from 'graphql-tag'
// ------ mutations
export const signupVerificationMutation = gql`
mutation (
$password: String!
$email: String!
$name: String!
$slug: String
$nonce: String!
$termsAndConditionsAgreedVersion: String!
) {
SignupVerification(
email: $email
password: $password
name: $name
slug: $slug
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
) {
id
slug
}
}
`
// ------ queries
// fill queries in here

View File

@ -0,0 +1,15 @@
import gql from 'graphql-tag'
// ------ mutations
export const createCommentMutation = gql`
mutation ($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}
}
`
// ------ queries
// fill queries in here

View File

@ -0,0 +1,203 @@
import gql from 'graphql-tag'
// ------ mutations
export const createGroupMutation = () => {
return gql`
mutation (
$id: ID
$name: String!
$slug: String
$about: String
$description: String!
$groupType: GroupType!
$actionRadius: GroupActionRadius!
$categoryIds: [ID]
$locationName: String # empty string '' sets it to null
) {
CreateGroup(
id: $id
name: $name
slug: $slug
about: $about
description: $description
groupType: $groupType
actionRadius: $actionRadius
categoryIds: $categoryIds
locationName: $locationName
) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
locationName
location {
name
nameDE
nameEN
}
myRole
}
}
`
}
export const updateGroupMutation = () => {
return gql`
mutation (
$id: ID!
$name: String
$slug: String
$about: String
$description: String
$actionRadius: GroupActionRadius
$categoryIds: [ID]
$avatar: ImageInput
$locationName: String # empty string '' sets it to null
) {
UpdateGroup(
id: $id
name: $name
slug: $slug
about: $about
description: $description
actionRadius: $actionRadius
categoryIds: $categoryIds
avatar: $avatar
locationName: $locationName
) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
# avatar # test this as result
locationName
location {
name
nameDE
nameEN
}
myRole
}
}
`
}
export const joinGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
JoinGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
}
}
`
}
export const leaveGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
LeaveGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
}
}
`
}
export const changeGroupMemberRoleMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) {
ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) {
id
name
slug
myRoleInGroup
}
}
`
}
// ------ queries
export const groupQuery = () => {
return gql`
query ($isMember: Boolean, $id: ID, $slug: String) {
Group(isMember: $isMember, id: $id, slug: $slug) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
avatar {
url
}
locationName
location {
name
nameDE
nameEN
}
myRole
}
}
`
}
export const groupMembersQuery = () => {
return gql`
query ($id: ID!) {
GroupMembers(id: $id) {
id
name
slug
myRoleInGroup
}
}
`
}

View File

@ -0,0 +1,88 @@
import gql from 'graphql-tag'
// ------ mutations
export const createPostMutation = () => {
return gql`
mutation (
$id: ID
$title: String!
$slug: String
$content: String!
$categoryIds: [ID]
$groupId: ID
) {
CreatePost(
id: $id
title: $title
slug: $slug
content: $content
categoryIds: $categoryIds
groupId: $groupId
) {
id
slug
title
content
}
}
`
}
// ------ queries
export const postQuery = () => {
return gql`
query Post($id: ID!) {
Post(id: $id) {
id
title
content
}
}
`
}
export const filterPosts = () => {
return gql`
query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id
title
content
}
}
`
}
export const profilePagePosts = () => {
return gql`
query profilePagePosts(
$filter: _PostFilter
$first: Int
$offset: Int
$orderBy: [_PostOrdering]
) {
profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id
title
content
}
}
`
}
export const searchPosts = () => {
return gql`
query ($query: String!, $firstPosts: Int, $postsOffset: Int) {
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
id
title
content
}
}
}
`
}

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag'
// ------ mutations
export const loginMutation = gql`
mutation ($email: String!, $password: String!) {
login(email: $email, password: $password)
}
`
// ------ queries
// fill queries in here

View File

@ -85,11 +85,11 @@ class Store {
await createDefaultAdminUser(session)
if (CONFIG.CATEGORIES_ACTIVE) await createCategories(session)
const writeTxResultPromise = session.writeTransaction(async (txc) => {
await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices
await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices and contraints
return Promise.all(
[
'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])',
'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])',
'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])',
'CALL db.index.fulltext.createNodeIndex("tag_fulltext_search",["Tag"],["id"])',
].map((statement) => txc.run(statement)),
)

View File

@ -0,0 +1,66 @@
import { getDriver } from '../../db/neo4j'
export const description = `
We introduced a new node label 'Group' and we need two primary keys 'id' and 'slug' for it.
Additional we like to have fulltext indices the keys 'name', 'slug', 'about', and 'description'.
`
export async function up(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
CREATE CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE
`)
await transaction.run(`
CREATE CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE
`)
await transaction.run(`
CALL db.index.fulltext.createNodeIndex("group_fulltext_search",["Group"],["name", "slug", "about", "description"])
`)
await transaction.commit()
next()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
session.close()
}
}
export async function down(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
DROP CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE
`)
await transaction.run(`
DROP CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE
`)
await transaction.run(`
CALL db.index.fulltext.drop("group_fulltext_search")
`)
await transaction.commit()
next()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
session.close()
}
}

View File

@ -5,7 +5,13 @@ import createServer from '../server'
import faker from '@faker-js/faker'
import Factory from '../db/factories'
import { getNeode, getDriver } from '../db/neo4j'
import { gql } from '../helpers/jest'
import {
createGroupMutation,
joinGroupMutation,
changeGroupMemberRoleMutation,
} from './graphql/groups'
import { createPostMutation } from './graphql/posts'
import { createCommentMutation } from './graphql/comments'
import { categories } from '../constants/categories'
if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) {
@ -294,6 +300,302 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}),
])
// Create Groups
authenticatedUser = await peterLustig.toJson()
await Promise.all([
mutate({
mutation: createGroupMutation(),
variables: {
id: 'g0',
name: 'Investigative Journalism',
about: 'Investigative journalists share ideas and insights and can collaborate.',
description: `<p class=""><em>English:</em></p><p class="">This group is hidden.</p><h3>What is our group for?</h3><p>This group was created to allow investigative journalists to share and collaborate.</p><h3>How does it work?</h3><p>Here you can internally share posts and comments about them.</p><p><br></p><p><em>Deutsch:</em></p><p class="">Diese Gruppe ist verborgen.</p><h3>Wofür ist unsere Gruppe?</h3><p class="">Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.</p><h3>Wie funktioniert das?</h3><p class="">Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.</p>`,
groupType: 'hidden',
actionRadius: 'global',
categoryIds: ['cat6', 'cat12', 'cat16'],
locationName: 'Hamburg, Germany',
},
}),
])
await Promise.all([
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g0',
userId: 'u2',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g0',
userId: 'u4',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g0',
userId: 'u6',
},
}),
])
await Promise.all([
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g0',
userId: 'u2',
roleInGroup: 'usual',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g0',
userId: 'u4',
roleInGroup: 'admin',
},
}),
])
// post into group
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p0-g0',
groupId: 'g0',
title: `What happend in Shanghai?`,
content: 'A sack of rise dropped in Shanghai. Should we further investigate?',
categoryIds: ['cat6'],
},
}),
])
authenticatedUser = await bobDerBaumeister.toJson()
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p1-g0',
groupId: 'g0',
title: `The man on the moon`,
content: 'We have to further investigate about the stories of a man living on the moon.',
categoryIds: ['cat12', 'cat16'],
},
}),
])
authenticatedUser = await jennyRostock.toJson()
await Promise.all([
mutate({
mutation: createGroupMutation(),
variables: {
id: 'g1',
name: 'School For Citizens',
about: 'Our children shall receive education for life.',
description: `<p class=""><em>English</em></p><h3>Our goal</h3><p>Only those who enjoy learning and do not lose their curiosity can obtain a good education for life and continue to learn with joy throughout their lives.</p><h3>Curiosity</h3><p>For this we need a school that takes up the curiosity of the children, the people, and satisfies it through a lot of experience.</p><p><br></p><p><em>Deutsch</em></p><h3>Unser Ziel</h3><p class="">Nur wer Spaß am Lernen hat und seine Neugier nicht verliert, kann gute Bildung für's Leben erlangen und sein ganzes Leben mit Freude weiter lernen.</p><h3>Neugier</h3><p class="">Dazu benötigen wir eine Schule, die die Neugier der Kinder, der Menschen, aufnimmt und durch viel Erfahrung befriedigt.</p>`,
groupType: 'closed',
actionRadius: 'national',
categoryIds: ['cat8', 'cat14'],
locationName: 'France',
},
}),
])
await Promise.all([
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u1',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u2',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u5',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u6',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g1',
userId: 'u7',
},
}),
])
await Promise.all([
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g1',
userId: 'u1',
roleInGroup: 'usual',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g1',
userId: 'u5',
roleInGroup: 'admin',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g1',
userId: 'u6',
roleInGroup: 'owner',
},
}),
])
// post into group
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p0-g1',
groupId: 'g1',
title: `Can we use ocelot for education?`,
content: 'I like the concept of this school. Can we use our software in this?',
categoryIds: ['cat8'],
},
}),
])
authenticatedUser = await peterLustig.toJson()
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p1-g1',
groupId: 'g1',
title: `Can we push this idea out of France?`,
content: 'This idea is too inportant to have the scope only on France.',
categoryIds: ['cat14'],
},
}),
])
authenticatedUser = await bobDerBaumeister.toJson()
await Promise.all([
mutate({
mutation: createGroupMutation(),
variables: {
id: 'g2',
name: 'Yoga Practice',
about: 'We do yoga around the clock.',
description: `<h3>What Is yoga?</h3><p>Yoga is not just about practicing asanas. It's about how we do it.</p><p class="">And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.</p><h3>What makes practicing asanas yogic?</h3><p class="">The important thing is:</p><ul><li><p>Use the exercises (consciously) for your personal development.</p></li></ul>`,
groupType: 'public',
actionRadius: 'interplanetary',
categoryIds: ['cat4', 'cat5', 'cat17'],
},
}),
])
await Promise.all([
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u3',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u4',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u5',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u6',
},
}),
mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: 'u7',
},
}),
])
await Promise.all([
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g2',
userId: 'u3',
roleInGroup: 'usual',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g2',
userId: 'u4',
roleInGroup: 'pending',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g2',
userId: 'u5',
roleInGroup: 'admin',
},
}),
mutate({
mutation: changeGroupMemberRoleMutation(),
variables: {
groupId: 'g2',
userId: 'u6',
roleInGroup: 'usual',
},
}),
])
authenticatedUser = await louie.toJson()
await Promise.all([
mutate({
mutation: createPostMutation(),
variables: {
id: 'p0-g2',
groupId: 'g2',
title: `I am a Noob`,
content: 'I am new to Yoga and did not join this group so far.',
categoryIds: ['cat4'],
},
}),
])
// Create Posts
const [p0, p1, p3, p4, p5, p6, p9, p10, p11, p13, p14, p15] = await Promise.all([
Factory.build(
'post',
@ -471,17 +773,10 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
'See <a class="hashtag" data-hashtag-id="NaturphilosophieYoga" href="/?hashtag=NaturphilosophieYoga">#NaturphilosophieYoga</a>, it can really help you!'
const hashtagAndMention1 =
'The new physics of <a class="hashtag" data-hashtag-id="QuantenFlussTheorie" href="/?hashtag=QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" data-hashtag-id="QuantumGravity" href="/?hashtag=QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
const createPostMutation = gql`
mutation ($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) {
CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
id
}
}
`
await Promise.all([
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p2',
title: `Nature Philosophy Yoga`,
@ -490,7 +785,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p7',
title: 'This is post #7',
@ -499,7 +794,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p8',
image: faker.image.unsplash.nature(),
@ -509,7 +804,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
}),
mutate({
mutation: createPostMutation,
mutation: createPostMutation(),
variables: {
id: 'p12',
title: 'This is post #12',
@ -528,13 +823,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
'I heard <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a> has practiced it for 3 years now.'
const mentionInComment2 =
'Did <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> tell you?'
const createCommentMutation = gql`
mutation ($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}
}
`
await Promise.all([
mutate({
mutation: createCommentMutation,

View File

@ -1,5 +1,18 @@
// TODO: can be replaced with: (which is no a fake)
// import gql from 'graphql-tag'
// See issue: https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/5152
//* This is a fake ES2015 template string, just to benefit of syntax
// highlighting of `gql` template strings in certain editors.
export function gql(strings) {
return strings.join('')
}
// sometime we have to wait to check a db state by having a look into the db in a certain moment
// or we wait a bit to check if we missed to set an await somewhere
// see: https://www.sitepoint.com/delay-sleep-pause-wait/
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// usage 4 seconds for example
// await sleep(4 * 1000)

View File

@ -1,26 +1,31 @@
import trunc from 'trunc-html'
import { DESCRIPTION_EXCERPT_HTML_LENGTH } from '../constants/groups'
export default {
Mutation: {
CreateGroup: async (resolve, root, args, context, info) => {
args.descriptionExcerpt = trunc(args.description, DESCRIPTION_EXCERPT_HTML_LENGTH).html
return resolve(root, args, context, info)
},
UpdateGroup: async (resolve, root, args, context, info) => {
args.descriptionExcerpt = trunc(args.description, DESCRIPTION_EXCERPT_HTML_LENGTH).html
return resolve(root, args, context, info)
},
CreatePost: async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 120).html
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
UpdatePost: async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 120).html
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
CreateComment: async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 180).html
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
UpdateComment: async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 180).html
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
},
}

View File

@ -1,6 +1,13 @@
import sanitizeHtml from 'sanitize-html'
import linkifyHtml from 'linkifyjs/html'
export const removeHtmlTags = (input) => {
return sanitizeHtml(input, {
allowedTags: [],
allowedAttributes: {},
})
}
const standardSanitizeHtmlOptions = {
allowedTags: [
'img',

View File

@ -1,12 +1,5 @@
import LanguageDetect from 'languagedetect'
import sanitizeHtml from 'sanitize-html'
const removeHtmlTags = (input) => {
return sanitizeHtml(input, {
allowedTags: [],
allowedAttributes: {},
})
}
import { removeHtmlTags } from '../helpers/cleanHtml.js'
const setPostLanguage = (text) => {
const lngDetector = new LanguageDetect()

View File

@ -1,4 +1,4 @@
import { rule, shield, deny, allow, or } from 'graphql-shield'
import { rule, shield, deny, allow, or, and } from 'graphql-shield'
import { getNeode } from '../db/neo4j'
import CONFIG from '../config'
import { validateInviteCode } from '../schema/resolvers/transactions/inviteCodes'
@ -52,6 +52,203 @@ const isMySocialMedia = rule({
return socialMedia.ownedBy.node.id === user.id
})
const isAllowedToChangeGroupSettings = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const ownerId = user.id
const { id: groupId } = args
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (owner:User {id: $ownerId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
RETURN group {.*}, owner {.*, myRoleInGroup: membership.role}
`,
{ groupId, ownerId },
)
return {
owner: transactionResponse.records.map((record) => record.get('owner'))[0],
group: transactionResponse.records.map((record) => record.get('group'))[0],
}
})
try {
const { owner, group } = await readTxPromise
return !!group && !!owner && ['owner'].includes(owner.myRoleInGroup)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAllowedSeeingGroupMembers = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { id: groupId } = args
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (group:Group {id: $groupId})
OPTIONAL MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group)
RETURN group {.*}, member {.*, myRoleInGroup: membership.role}
`,
{ groupId, userId: user.id },
)
return {
member: transactionResponse.records.map((record) => record.get('member'))[0],
group: transactionResponse.records.map((record) => record.get('group'))[0],
}
})
try {
const { member, group } = await readTxPromise
return (
!!group &&
(group.groupType === 'public' ||
(['closed', 'hidden'].includes(group.groupType) &&
!!member &&
['usual', 'admin', 'owner'].includes(member.myRoleInGroup)))
)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAllowedToChangeGroupMemberRole = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const adminId = user.id
const { groupId, userId, roleInGroup } = args
if (adminId === userId) return false
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (admin:User {id: $adminId})-[adminMembership:MEMBER_OF]->(group:Group {id: $groupId})
OPTIONAL MATCH (group)<-[userMembership:MEMBER_OF]-(member:User {id: $userId})
RETURN group {.*}, admin {.*, myRoleInGroup: adminMembership.role}, member {.*, myRoleInGroup: userMembership.role}
`,
{ groupId, adminId, userId },
)
return {
admin: transactionResponse.records.map((record) => record.get('admin'))[0],
group: transactionResponse.records.map((record) => record.get('group'))[0],
member: transactionResponse.records.map((record) => record.get('member'))[0],
}
})
try {
const { admin, group, member } = await readTxPromise
return (
!!group &&
!!admin &&
(!member ||
(!!member &&
(member.myRoleInGroup === roleInGroup || !['owner'].includes(member.myRoleInGroup)))) &&
((['admin'].includes(admin.myRoleInGroup) &&
['pending', 'usual', 'admin'].includes(roleInGroup)) ||
(['owner'].includes(admin.myRoleInGroup) &&
['pending', 'usual', 'admin', 'owner'].includes(roleInGroup)))
)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAllowedToJoinGroup = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { groupId, userId } = args
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (group:Group {id: $groupId})
OPTIONAL MATCH (group)<-[membership:MEMBER_OF]-(member:User {id: $userId})
RETURN group {.*}, member {.*, myRoleInGroup: membership.role}
`,
{ groupId, userId },
)
return {
group: transactionResponse.records.map((record) => record.get('group'))[0],
member: transactionResponse.records.map((record) => record.get('member'))[0],
}
})
try {
const { group, member } = await readTxPromise
return !!group && (group.groupType !== 'hidden' || (!!member && !!member.myRoleInGroup))
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAllowedToLeaveGroup = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { groupId, userId } = args
if (user.id !== userId) return false
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
RETURN group {.*}, member {.*, myRoleInGroup: membership.role}
`,
{ groupId, userId },
)
return {
group: transactionResponse.records.map((record) => record.get('group'))[0],
member: transactionResponse.records.map((record) => record.get('member'))[0],
}
})
try {
const { group, member } = await readTxPromise
return !!group && !!member && !!member.myRoleInGroup && member.myRoleInGroup !== 'owner'
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isMemberOfGroup = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!(user && user.id)) return false
const { groupId } = args
if (!groupId) return true
const userId = user.id
const session = driver.session()
const readTxPromise = session.readTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (User {id: $userId})-[membership:MEMBER_OF]->(Group {id: $groupId})
RETURN membership.role AS role
`,
{ groupId, userId },
)
return transactionResponse.records.map((record) => record.get('role'))[0]
})
try {
const role = await readTxPromise
return ['usual', 'admin', 'owner'].includes(role)
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
})
const isAuthor = rule({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
@ -78,7 +275,7 @@ const isAuthor = rule({
const isDeletingOwnAccount = rule({
cache: 'no_cache',
})(async (parent, args, context, info) => {
})(async (parent, args, context, _info) => {
return context.user.id === args.id
})
@ -102,11 +299,10 @@ export default shield(
{
Query: {
'*': deny,
findPosts: allow,
findUsers: allow,
searchResults: allow,
searchPosts: allow,
searchUsers: allow,
searchGroups: allow,
searchHashtags: allow,
embed: allow,
Category: allow,
@ -114,6 +310,8 @@ export default shield(
reports: isModerator,
statistics: allow,
currentUser: allow,
Group: isAuthenticated,
GroupMembers: isAllowedSeeingGroupMembers,
Post: allow,
profilePagePosts: allow,
Comment: allow,
@ -140,7 +338,12 @@ export default shield(
Signup: or(publicRegistration, inviteRegistration, isAdmin),
SignupVerification: allow,
UpdateUser: onlyYourself,
CreatePost: isAuthenticated,
CreateGroup: isAuthenticated,
UpdateGroup: isAllowedToChangeGroupSettings,
JoinGroup: isAllowedToJoinGroup,
LeaveGroup: isAllowedToLeaveGroup,
ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole,
CreatePost: and(isAuthenticated, isMemberOfGroup),
UpdatePost: isAuthor,
DeletePost: isAuthor,
fileReport: isAuthenticated,

View File

@ -26,11 +26,22 @@ export default {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User')))
return resolve(root, args, context, info)
},
CreateGroup: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Group')))
return resolve(root, args, context, info)
},
UpdateGroup: async (resolve, root, args, context, info) => {
if (args.name) {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Group')))
}
return resolve(root, args, context, info)
},
CreatePost: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
},
UpdatePost: async (resolve, root, args, context, info) => {
// TODO: is this absolutely correct, see condition in 'UpdateGroup' above? may it works accidentally, because args.slug is always send?
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
},

View File

@ -1,4 +1,5 @@
import slugify from 'slug'
export default async function uniqueSlug(string, isUnique) {
const slug = slugify(string || 'anonymous', {
lower: true,

View File

@ -1,29 +1,34 @@
import Factory, { cleanDatabase } from '../db/factories'
import { gql } from '../helpers/jest'
import { getNeode, getDriver } from '../db/neo4j'
import createServer from '../server'
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../db/factories'
import { createGroupMutation, updateGroupMutation } from '../db/graphql/groups'
import { createPostMutation } from '../db/graphql/posts'
import { signupVerificationMutation } from '../db/graphql/authentications'
let mutate
let authenticatedUser
let variables
const categoryIds = ['cat9']
const driver = getDriver()
const neode = getNeode()
const descriptionAdditional100 =
' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789'
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
const { mutate } = createTestClient(server)
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
mutate = createTestClient(server).mutate
})
afterAll(async () => {
@ -46,6 +51,7 @@ beforeEach(async () => {
await Factory.build('category', {
id: 'cat9',
name: 'Democracy & Politics',
slug: 'democracy-politics',
icon: 'university',
})
authenticatedUser = await admin.toJson()
@ -57,16 +63,296 @@ afterEach(async () => {
})
describe('slugifyMiddleware', () => {
describe('CreatePost', () => {
const categoryIds = ['cat9']
const createPostMutation = gql`
mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) {
CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) {
slug
}
describe('CreateGroup', () => {
beforeEach(() => {
variables = {
...variables,
name: 'The Best Group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
categoryIds,
}
`
})
describe('if slug not exists', () => {
it('generates a slug based on name', async () => {
await expect(
mutate({
mutation: createGroupMutation(),
variables,
}),
).resolves.toMatchObject({
data: {
CreateGroup: {
name: 'The Best Group',
slug: 'the-best-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
},
},
errors: undefined,
})
})
it('generates a slug based on given slug', async () => {
await expect(
mutate({
mutation: createGroupMutation(),
variables: {
...variables,
slug: 'the-group',
},
}),
).resolves.toMatchObject({
data: {
CreateGroup: {
slug: 'the-group',
},
},
errors: undefined,
})
})
})
describe('if slug exists', () => {
beforeEach(async () => {
await mutate({
mutation: createGroupMutation(),
variables: {
...variables,
name: 'Pre-Existing Group',
slug: 'pre-existing-group',
about: 'As an about',
},
})
})
it('chooses another slug', async () => {
await expect(
mutate({
mutation: createGroupMutation(),
variables: {
...variables,
name: 'Pre-Existing Group',
about: 'As an about',
},
}),
).resolves.toMatchObject({
data: {
CreateGroup: {
slug: 'pre-existing-group-1',
},
},
errors: undefined,
})
})
describe('but if the client specifies a slug', () => {
it('rejects CreateGroup', async (done) => {
try {
await expect(
mutate({
mutation: createGroupMutation(),
variables: {
...variables,
name: 'Pre-Existing Group',
about: 'As an about',
slug: 'pre-existing-group',
},
}),
).resolves.toMatchObject({
errors: [
{
message: 'Group with this slug already exists!',
},
],
})
done()
} catch (error) {
throw new Error(`
${error}
Probably your database has no unique constraints!
To see all constraints go to http://localhost:7474/browser/ and
paste the following:
\`\`\`
CALL db.constraints();
\`\`\`
Learn how to setup the database here:
https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints
`)
}
})
})
})
})
describe('UpdateGroup', () => {
let createGroupResult
beforeEach(async () => {
createGroupResult = await mutate({
mutation: createGroupMutation(),
variables: {
name: 'The Best Group',
slug: 'the-best-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
categoryIds,
},
})
})
describe('if group exists', () => {
describe('if new slug not(!) exists', () => {
describe('setting slug by group name', () => {
it('has the new slug', async () => {
await expect(
mutate({
mutation: updateGroupMutation(),
variables: {
id: createGroupResult.data.CreateGroup.id,
name: 'My Best Group',
},
}),
).resolves.toMatchObject({
data: {
UpdateGroup: {
name: 'My Best Group',
slug: 'my-best-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
myRole: 'owner',
},
},
errors: undefined,
})
})
})
describe('setting slug explicitly', () => {
it('has the new slug', async () => {
await expect(
mutate({
mutation: updateGroupMutation(),
variables: {
id: createGroupResult.data.CreateGroup.id,
slug: 'my-best-group',
},
}),
).resolves.toMatchObject({
data: {
UpdateGroup: {
name: 'The Best Group',
slug: 'my-best-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
myRole: 'owner',
},
},
errors: undefined,
})
})
})
})
describe('if new slug exists in another group', () => {
beforeEach(async () => {
await mutate({
mutation: createGroupMutation(),
variables: {
name: 'Pre-Existing Group',
slug: 'pre-existing-group',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
categoryIds,
},
})
})
describe('setting slug by group name', () => {
it('has unique slug "*-1"', async () => {
await expect(
mutate({
mutation: updateGroupMutation(),
variables: {
id: createGroupResult.data.CreateGroup.id,
name: 'Pre-Existing Group',
},
}),
).resolves.toMatchObject({
data: {
UpdateGroup: {
name: 'Pre-Existing Group',
slug: 'pre-existing-group-1',
about: 'Some about',
description: 'Some description' + descriptionAdditional100,
groupType: 'closed',
actionRadius: 'national',
myRole: 'owner',
},
},
errors: undefined,
})
})
})
describe('setting slug explicitly', () => {
it('rejects UpdateGroup', async (done) => {
try {
await expect(
mutate({
mutation: updateGroupMutation(),
variables: {
id: createGroupResult.data.CreateGroup.id,
slug: 'pre-existing-group',
},
}),
).resolves.toMatchObject({
errors: [
{
message: 'Group with this slug already exists!',
},
],
})
done()
} catch (error) {
throw new Error(`
${error}
Probably your database has no unique constraints!
To see all constraints go to http://localhost:7474/browser/ and
paste the following:
\`\`\`
CALL db.constraints();
\`\`\`
Learn how to setup the database here:
https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints
`)
}
})
})
})
})
})
describe('CreatePost', () => {
beforeEach(() => {
variables = {
...variables,
@ -76,18 +362,40 @@ describe('slugifyMiddleware', () => {
}
})
it('generates a slug based on title', async () => {
await expect(
mutate({
mutation: createPostMutation,
variables,
}),
).resolves.toMatchObject({
data: {
CreatePost: {
slug: 'i-am-a-brand-new-post',
describe('if slug not exists', () => {
it('generates a slug based on title', async () => {
await expect(
mutate({
mutation: createPostMutation(),
variables,
}),
).resolves.toMatchObject({
data: {
CreatePost: {
slug: 'i-am-a-brand-new-post',
},
},
},
errors: undefined,
})
})
it('generates a slug based on given slug', async () => {
await expect(
mutate({
mutation: createPostMutation(),
variables: {
...variables,
slug: 'the-post',
},
}),
).resolves.toMatchObject({
data: {
CreatePost: {
slug: 'the-post',
},
},
errors: undefined,
})
})
})
@ -107,16 +415,15 @@ describe('slugifyMiddleware', () => {
})
it('chooses another slug', async () => {
variables = {
...variables,
title: 'Pre-existing post',
content: 'Some content',
categoryIds,
}
await expect(
mutate({
mutation: createPostMutation,
variables,
mutation: createPostMutation(),
variables: {
...variables,
title: 'Pre-existing post',
content: 'Some content',
categoryIds,
},
}),
).resolves.toMatchObject({
data: {
@ -124,21 +431,24 @@ describe('slugifyMiddleware', () => {
slug: 'pre-existing-post-1',
},
},
errors: undefined,
})
})
describe('but if the client specifies a slug', () => {
it('rejects CreatePost', async (done) => {
variables = {
...variables,
title: 'Pre-existing post',
content: 'Some content',
slug: 'pre-existing-post',
categoryIds,
}
try {
await expect(
mutate({ mutation: createPostMutation, variables }),
mutate({
mutation: createPostMutation(),
variables: {
...variables,
title: 'Pre-existing post',
content: 'Some content',
slug: 'pre-existing-post',
categoryIds,
},
}),
).resolves.toMatchObject({
errors: [
{
@ -160,7 +470,7 @@ describe('slugifyMiddleware', () => {
\`\`\`
Learn how to setup the database here:
https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints
https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints
`)
}
})
@ -168,29 +478,9 @@ describe('slugifyMiddleware', () => {
})
})
describe('SignupVerification', () => {
const mutation = gql`
mutation (
$password: String!
$email: String!
$name: String!
$slug: String
$nonce: String!
$termsAndConditionsAgreedVersion: String!
) {
SignupVerification(
email: $email
password: $password
name: $name
slug: $slug
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
) {
slug
}
}
`
it.todo('UpdatePost')
describe('SignupVerification', () => {
beforeEach(() => {
variables = {
...variables,
@ -211,18 +501,40 @@ describe('slugifyMiddleware', () => {
})
})
it('generates a slug based on name', async () => {
await expect(
mutate({
mutation,
variables,
}),
).resolves.toMatchObject({
data: {
SignupVerification: {
slug: 'i-am-a-user',
describe('if slug not exists', () => {
it('generates a slug based on name', async () => {
await expect(
mutate({
mutation: signupVerificationMutation,
variables,
}),
).resolves.toMatchObject({
data: {
SignupVerification: {
slug: 'i-am-a-user',
},
},
},
errors: undefined,
})
})
it('generates a slug based on given slug', async () => {
await expect(
mutate({
mutation: signupVerificationMutation,
variables: {
...variables,
slug: 'the-user',
},
}),
).resolves.toMatchObject({
data: {
SignupVerification: {
slug: 'the-user',
},
},
errors: undefined,
})
})
})
@ -237,7 +549,7 @@ describe('slugifyMiddleware', () => {
it('chooses another slug', async () => {
await expect(
mutate({
mutation,
mutation: signupVerificationMutation,
variables,
}),
).resolves.toMatchObject({
@ -246,6 +558,7 @@ describe('slugifyMiddleware', () => {
slug: 'i-am-a-user-1',
},
},
errors: undefined,
})
})
@ -260,7 +573,7 @@ describe('slugifyMiddleware', () => {
it('rejects SignupVerification (on FAIL Neo4j constraints may not defined in database)', async () => {
await expect(
mutate({
mutation,
mutation: signupVerificationMutation,
variables,
}),
).resolves.toMatchObject({

View File

@ -31,7 +31,7 @@ const setPostCounter = async (postId, relation, context) => {
}
const userClickedPost = async (resolve, root, args, context, info) => {
if (args.id) {
if (args.id && context.user) {
await setPostCounter(args.id, 'CLICKED', context)
}
return resolve(root, args, context, info)

View File

@ -0,0 +1,46 @@
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
name: { type: 'string', disallow: [null], min: 3 },
slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true },
createdAt: {
type: 'string',
isoDate: true,
required: true,
default: () => new Date().toISOString(),
},
updatedAt: {
type: 'string',
isoDate: true,
required: true,
default: () => new Date().toISOString(),
},
deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false },
avatar: {
type: 'relationship',
relationship: 'AVATAR_IMAGE',
target: 'Image',
direction: 'out',
},
about: { type: 'string', allow: [null, ''] },
description: { type: 'string', disallow: [null], min: 100 },
descriptionExcerpt: { type: 'string', allow: [null] },
groupType: { type: 'string', default: 'public' },
actionRadius: { type: 'string', default: 'regional' },
myRole: { type: 'string', default: 'pending' },
locationName: { type: 'string', allow: [null] },
isIn: {
type: 'relationship',
relationship: 'IS_IN',
target: 'Location',
direction: 'out',
},
}

View File

@ -55,7 +55,7 @@ describe('slug', () => {
\`\`\`
Learn how to setup the database here:
https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints
https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints
`)
}
})

View File

@ -4,6 +4,7 @@ export default {
Image: require('./Image.js').default,
Badge: require('./Badge.js').default,
User: require('./User.js').default,
Group: require('./Group.js').default,
EmailAddress: require('./EmailAddress.js').default,
UnverifiedEmailAddress: require('./UnverifiedEmailAddress.js').default,
SocialMedia: require('./SocialMedia.js').default,

View File

@ -20,6 +20,7 @@ export default makeAugmentedSchema({
'FILED',
'REVIEWED',
'Report',
'Group',
],
},
mutation: false,

View File

@ -0,0 +1,347 @@
import { v4 as uuid } from 'uuid'
import { UserInputError } from 'apollo-server'
import CONFIG from '../../config'
import { CATEGORIES_MIN, CATEGORIES_MAX } from '../../constants/categories'
import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups'
import { removeHtmlTags } from '../../middleware/helpers/cleanHtml.js'
import Resolver, {
removeUndefinedNullValuesFromObject,
convertObjectToCypherMapLiteral,
} from './helpers/Resolver'
import { mergeImage } from './images/images'
import { createOrUpdateLocations } from './users/location'
export default {
Query: {
Group: async (_object, params, context, _resolveInfo) => {
const { isMember, id, slug } = params
const matchParams = { id, slug }
removeUndefinedNullValuesFromObject(matchParams)
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (txc) => {
const groupMatchParamsCypher = convertObjectToCypherMapLiteral(matchParams, true)
let groupCypher
if (isMember === true) {
groupCypher = `
MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupMatchParamsCypher})
WITH group, membership
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role}
`
} else {
if (isMember === false) {
groupCypher = `
MATCH (group:Group${groupMatchParamsCypher})
WHERE (NOT (:User {id: $userId})-[:MEMBER_OF]->(group))
WITH group
WHERE group.groupType IN ['public', 'closed']
RETURN group {.*, myRole: NULL}
`
} else {
groupCypher = `
MATCH (group:Group${groupMatchParamsCypher})
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
WITH group, membership
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role}
`
}
}
const transactionResponse = await txc.run(groupCypher, {
userId: context.user.id,
})
return transactionResponse.records.map((record) => record.get('group'))
})
try {
return await readTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
GroupMembers: async (_object, params, context, _resolveInfo) => {
const { id: groupId } = params
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (txc) => {
const groupMemberCypher = `
MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId})
RETURN user {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await txc.run(groupMemberCypher, {
groupId,
})
return transactionResponse.records.map((record) => record.get('user'))
})
try {
return await readTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
},
Mutation: {
CreateGroup: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
delete params.categoryIds
params.locationName = params.locationName === '' ? null : params.locationName
if (CONFIG.CATEGORIES_ACTIVE && (!categoryIds || categoryIds.length < CATEGORIES_MIN)) {
throw new UserInputError('Too view categories!')
}
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > CATEGORIES_MAX) {
throw new UserInputError('Too many categories!')
}
if (
params.description === undefined ||
params.description === null ||
removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN
) {
throw new UserInputError('Description too short!')
}
params.id = params.id || uuid()
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const categoriesCypher =
CONFIG.CATEGORIES_ACTIVE && categoryIds
? `
WITH group, membership
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (group)-[:CATEGORIZED]->(category)
`
: ''
const ownerCreateGroupTransactionResponse = await transaction.run(
`
CREATE (group:Group)
SET group += $params
SET group.createdAt = toString(datetime())
SET group.updatedAt = toString(datetime())
WITH group
MATCH (owner:User {id: $userId})
MERGE (owner)-[:CREATED]->(group)
MERGE (owner)-[membership:MEMBER_OF]->(group)
SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
membership.role = 'owner'
${categoriesCypher}
RETURN group {.*, myRole: membership.role}
`,
{ userId: context.user.id, categoryIds, params },
)
const [group] = await ownerCreateGroupTransactionResponse.records.map((record) =>
record.get('group'),
)
return group
})
try {
const group = await writeTxResultPromise
// TODO: put in a middleware, see "UpdateGroup", "UpdateUser"
await createOrUpdateLocations('Group', params.id, params.locationName, session)
return group
} catch (error) {
if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('Group with this slug already exists!')
throw new Error(error)
} finally {
session.close()
}
},
UpdateGroup: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
delete params.categoryIds
const { id: groupId, avatar: avatarInput } = params
delete params.avatar
params.locationName = params.locationName === '' ? null : params.locationName
if (CONFIG.CATEGORIES_ACTIVE && categoryIds) {
if (categoryIds.length < CATEGORIES_MIN) {
throw new UserInputError('Too view categories!')
}
if (categoryIds.length > CATEGORIES_MAX) {
throw new UserInputError('Too many categories!')
}
}
if (
params.description &&
removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN
) {
throw new UserInputError('Description too short!')
}
const session = context.driver.session()
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
MATCH (group:Group {id: $groupId})-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations
RETURN group, category
`
await session.writeTransaction((transaction) => {
return transaction.run(cypherDeletePreviousRelations, { groupId })
})
}
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let updateGroupCypher = `
MATCH (group:Group {id: $groupId})
SET group += $params
SET group.updatedAt = toString(datetime())
WITH group
`
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
updateGroupCypher += `
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (group)-[:CATEGORIZED]->(category)
WITH group
`
}
updateGroupCypher += `
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
RETURN group {.*, myRole: membership.role}
`
const transactionResponse = await transaction.run(updateGroupCypher, {
groupId,
userId: context.user.id,
categoryIds,
params,
})
const [group] = await transactionResponse.records.map((record) => record.get('group'))
if (avatarInput) {
await mergeImage(group, 'AVATAR_IMAGE', avatarInput, { transaction })
}
return group
})
try {
const group = await writeTxResultPromise
// TODO: put in a middleware, see "CreateGroup", "UpdateUser"
await createOrUpdateLocations('Group', params.id, params.locationName, session)
return group
} catch (error) {
if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('Group with this slug already exists!')
throw new Error(error)
} finally {
session.close()
}
},
JoinGroup: async (_parent, params, context, _resolveInfo) => {
const { groupId, userId } = params
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const joinGroupCypher = `
MATCH (member:User {id: $userId}), (group:Group {id: $groupId})
MERGE (member)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
membership.role =
CASE WHEN group.groupType = 'public'
THEN 'usual'
ELSE 'pending'
END
RETURN member {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId })
const [member] = await transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
LeaveGroup: async (_parent, params, context, _resolveInfo) => {
const { groupId, userId } = params
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const leaveGroupCypher = `
MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
DELETE membership
WITH member, group
OPTIONAL MATCH (p:Post)-[:IN]->(group)
WHERE NOT group.groupType = 'public'
WITH member, group, collect(p) AS posts
FOREACH (post IN posts |
MERGE (member)-[:CANNOT_SEE]->(post))
RETURN member {.*, myRoleInGroup: NULL}
`
const transactionResponse = await transaction.run(leaveGroupCypher, { groupId, userId })
const [member] = await transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
ChangeGroupMemberRole: async (_parent, params, context, _resolveInfo) => {
const { groupId, userId, roleInGroup } = params
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let postRestrictionCypher = ''
if (['usual', 'admin', 'owner'].includes(roleInGroup)) {
postRestrictionCypher = `
WITH group, member, membership
FOREACH (restriction IN [(member)-[r:CANNOT_SEE]->(:Post)-[:IN]->(group) | r] |
DELETE restriction)`
} else {
postRestrictionCypher = `
WITH group, member, membership
FOREACH (post IN [(p:Post)-[:IN]->(group) | p] |
MERGE (member)-[:CANNOT_SEE]->(post))`
}
const joinGroupCypher = `
MATCH (member:User {id: $userId})
MATCH (group:Group {id: $groupId})
MERGE (member)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
membership.role = $roleInGroup
ON MATCH SET
membership.updatedAt = toString(datetime()),
membership.role = $roleInGroup
${postRestrictionCypher}
RETURN member {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await transaction.run(joinGroupCypher, {
groupId,
userId,
roleInGroup,
})
const [member] = await transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
},
Group: {
...Resolver('Group', {
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
hasMany: {
categories: '-[:CATEGORIZED]->(related:Category)',
posts: '<-[:IN]-(related:Post)',
},
hasOne: {
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
location: '-[:IS_IN]->(related:Location)',
},
}),
},
}

File diff suppressed because it is too large Load Diff

View File

@ -121,3 +121,25 @@ export default function Resolver(type, options = {}) {
}
return result
}
export const removeUndefinedNullValuesFromObject = (obj) => {
Object.keys(obj).forEach((key) => {
if ([undefined, null].includes(obj[key])) {
delete obj[key]
}
})
}
export const convertObjectToCypherMapLiteral = (params, addSpaceInfrontIfMapIsNotEmpty = false) => {
// I have found no other way yet. maybe "apoc.convert.fromJsonMap(key)" can help, but couldn't get it how, see: https://stackoverflow.com/questions/43217823/neo4j-cypher-inline-conversion-of-string-to-a-map
// result looks like: '{id: "g0", slug: "yoga"}'
const paramsEntries = Object.entries(params)
let mapLiteral = ''
paramsEntries.forEach((ele, index) => {
mapLiteral += index === 0 ? '{' : ''
mapLiteral += `${ele[0]}: "${ele[1]}"`
mapLiteral += index < paramsEntries.length - 1 ? ', ' : '}'
})
mapLiteral = (addSpaceInfrontIfMapIsNotEmpty && mapLiteral.length > 0 ? ' ' : '') + mapLiteral
return mapLiteral
}

View File

@ -0,0 +1,47 @@
import { mergeWith, isArray } from 'lodash'
const getInvisiblePosts = async (context) => {
const session = context.driver.session()
const readTxResultPromise = await session.readTransaction(async (transaction) => {
let cypher = ''
const { user } = context
if (user && user.id) {
cypher = `
MATCH (post:Post)<-[:CANNOT_SEE]-(user:User { id: $userId })
RETURN collect(post.id) AS invisiblePostIds`
} else {
cypher = `
MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
RETURN collect(post.id) AS invisiblePostIds`
}
const invisiblePostIdsResponse = await transaction.run(cypher, {
userId: user ? user.id : null,
})
return invisiblePostIdsResponse.records.map((record) => record.get('invisiblePostIds'))
})
try {
const [invisiblePostIds] = readTxResultPromise
return invisiblePostIds
} finally {
session.close()
}
}
export const filterInvisiblePosts = async (params, context) => {
const invisiblePostIds = await getInvisiblePosts(context)
if (!invisiblePostIds.length) return params
params.filter = mergeWith(
params.filter,
{
id_not_in: invisiblePostIds,
},
(objValue, srcValue) => {
if (isArray(objValue)) {
return objValue.concat(srcValue)
}
},
)
return params
}

View File

@ -5,6 +5,7 @@ import { UserInputError } from 'apollo-server'
import { mergeImage, deleteImage } from './images/images'
import Resolver from './helpers/Resolver'
import { filterForMutedUsers } from './helpers/filterForMutedUsers'
import { filterInvisiblePosts } from './helpers/filterInvisiblePosts'
import CONFIG from '../../config'
const maintainPinnedPosts = (params) => {
@ -20,15 +21,13 @@ const maintainPinnedPosts = (params) => {
export default {
Query: {
Post: async (object, params, context, resolveInfo) => {
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
params = await maintainPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo)
},
findPosts: async (object, params, context, resolveInfo) => {
params = await filterForMutedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo)
},
profilePagePosts: async (object, params, context, resolveInfo) => {
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo)
},
@ -77,13 +76,37 @@ export default {
},
Mutation: {
CreatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
const { categoryIds, groupId } = params
const { image: imageInput } = params
delete params.categoryIds
delete params.image
delete params.groupId
params.id = params.id || uuid()
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let groupCypher = ''
if (groupId) {
groupCypher = `
WITH post MATCH (group:Group { id: $groupId })
MERGE (post)-[:IN]->(group)`
const groupTypeResponse = await transaction.run(
`
MATCH (group:Group { id: $groupId }) RETURN group.groupType AS groupType`,
{ groupId },
)
const [groupType] = groupTypeResponse.records.map((record) => record.get('groupType'))
if (groupType !== 'public')
groupCypher += `
WITH post, group
MATCH (user:User)-[membership:MEMBER_OF]->(group)
WHERE group.groupType IN ['closed', 'hidden']
AND membership.role IN ['usual', 'admin', 'owner']
WITH post, collect(user.id) AS userIds
OPTIONAL MATCH path =(restricted:User) WHERE NOT restricted.id IN userIds
FOREACH (user IN nodes(path) |
MERGE (user)-[:CANNOT_SEE]->(post)
)`
}
const categoriesCypher =
CONFIG.CATEGORIES_ACTIVE && categoryIds
? `WITH post
@ -103,9 +126,10 @@ export default {
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
${categoriesCypher}
${groupCypher}
RETURN post {.*}
`,
{ userId: context.user.id, params, categoryIds },
{ userId: context.user.id, categoryIds, groupId, params },
)
const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
if (imageInput) {
@ -131,11 +155,11 @@ export default {
delete params.image
const session = context.driver.session()
let updatePostCypher = `
MATCH (post:Post {id: $params.id})
SET post += $params
SET post.updatedAt = toString(datetime())
WITH post
`
MATCH (post:Post {id: $params.id})
SET post += $params
SET post.updatedAt = toString(datetime())
WITH post
`
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
@ -367,6 +391,7 @@ export default {
author: '<-[:WROTE]-(related:User)',
pinnedBy: '<-[:PINNED]-(related:User)',
image: '-[:HERO_IMAGE]->(related:Image)',
group: '-[:IN]->(related:Group)',
},
count: {
commentsCount:

View File

@ -368,7 +368,7 @@ describe('UpdatePost', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { UpdatePost: null },
})

File diff suppressed because it is too large Load Diff

View File

@ -72,19 +72,19 @@ const signupCypher = (inviteCode) => {
(inviteCode:InviteCode {code: $inviteCode})<-[:GENERATED]-(host:User)
`
optionalMerge = `
MERGE(user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE(host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE(user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE(host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE (host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
`
}
const cypher = `
MATCH(email:EmailAddress {nonce: $nonce, email: $email})
MATCH (email:EmailAddress {nonce: $nonce, email: $email})
WHERE NOT (email)-[:BELONGS_TO]->()
${optionalMatch}
CREATE (user:User)
MERGE(user)-[:PRIMARY_EMAIL]->(email)
MERGE(user)<-[:BELONGS_TO]-(email)
MERGE (user)-[:PRIMARY_EMAIL]->(email)
MERGE (user)<-[:BELONGS_TO]-(email)
${optionalMerge}
SET user += $args
SET user.id = randomUUID()
@ -95,6 +95,13 @@ const signupCypher = (inviteCode) => {
SET user.showShoutsPublicly = false
SET user.sendNotificationEmails = true
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
WITH user, collect(post) AS invisiblePosts
FOREACH (invisiblePost IN invisiblePosts |
MERGE (user)-[:CANNOT_SEE]->(invisiblePost)
)
RETURN user {.*}
`
return cypher

View File

@ -23,12 +23,15 @@ const postWhereClause = `WHERE score >= 0.0
AND NOT (
author.deleted = true OR author.disabled = true
OR resource.deleted = true OR resource.disabled = true
OR (:User {id: $userId})-[:MUTED]->(author)
)`
) AND block IS NULL AND restriction IS NULL`
const searchPostsSetup = {
fulltextIndex: 'post_fulltext_search',
match: 'MATCH (resource:Post)<-[:WROTE]-(author:User)',
match: `MATCH (resource:Post)<-[:WROTE]-(author:User)
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[block:MUTED]->(author)
OPTIONAL MATCH (user)-[restriction:CANNOT_SEE]->(resource)
WITH user, resource, author, block, restriction`,
whereClause: postWhereClause,
withClause: `WITH resource, author,
[(resource)<-[:COMMENTS]-(comment:Comment) | comment] AS comments,
@ -63,6 +66,21 @@ const searchHashtagsSetup = {
limit: 'LIMIT $limit',
}
const searchGroupsSetup = {
fulltextIndex: 'group_fulltext_search',
match: `MATCH (resource:Group)
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[membership:MEMBER_OF]->(resource)
WITH user, resource, membership`,
whereClause: `WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
AND (resource.groupType IN ['public', 'closed']
OR membership.role IN ['usual', 'admin', 'owner'])`,
withClause: 'WITH resource, membership',
returnClause: 'resource { .*, myRole: membership.role, __typename: labels(resource)[0] }',
limit: 'LIMIT $limit',
}
const countSetup = {
returnClause: 'toString(size(collect(resource)))',
limit: '',
@ -80,6 +98,10 @@ const countHashtagsSetup = {
...searchHashtagsSetup,
...countSetup,
}
const countGroupsSetup = {
...searchGroupsSetup,
...countSetup,
}
const searchResultPromise = async (session, setup, params) => {
return session.readTransaction(async (transaction) => {
@ -110,14 +132,15 @@ const multiSearchMap = [
{ symbol: '!', setup: searchPostsSetup, resultName: 'posts' },
{ symbol: '@', setup: searchUsersSetup, resultName: 'users' },
{ symbol: '#', setup: searchHashtagsSetup, resultName: 'hashtags' },
{ symbol: '&', setup: searchGroupsSetup, resultName: 'groups' },
]
export default {
Query: {
searchPosts: async (_parent, args, context, _resolveInfo) => {
const { query, postsOffset, firstPosts } = args
const { id: userId } = context.user
let userId = null
if (context.user) userId = context.user.id
return {
postCount: getSearchResults(
context,
@ -175,12 +198,36 @@ export default {
}),
}
},
searchGroups: async (_parent, args, context, _resolveInfo) => {
const { query, groupsOffset, firstGroups } = args
let userId = null
if (context.user) userId = context.user.id
return {
groupCount: getSearchResults(
context,
countGroupsSetup,
{
query: queryString(query),
skip: 0,
userId,
},
countResultCallback,
),
groups: getSearchResults(context, searchGroupsSetup, {
query: queryString(query),
skip: groupsOffset,
limit: firstGroups,
userId,
}),
}
},
searchResults: async (_parent, args, context, _resolveInfo) => {
const { query, limit } = args
const { id: userId } = context.user
let userId = null
if (context.user) userId = context.user.id
const searchType = query.replace(/^([!@#]?).*$/, '$1')
const searchString = query.replace(/^([!@#])/, '')
const searchType = query.replace(/^([!@#&]?).*$/, '$1')
const searchString = query.replace(/^([!@#&])/, '')
const params = {
query: queryString(searchString),
@ -193,6 +240,7 @@ export default {
return [
...(await getSearchResults(context, searchPostsSetup, params)),
...(await getSearchResults(context, searchUsersSetup, params)),
...(await getSearchResults(context, searchGroupsSetup, params)),
...(await getSearchResults(context, searchHashtagsSetup, params)),
]

View File

@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken'
import CONFIG from './../../config'
import Factory, { cleanDatabase } from '../../db/factories'
import { gql } from '../../helpers/jest'
import { loginMutation } from '../../db/graphql/userManagement'
import { createTestClient } from 'apollo-server-testing'
import createServer, { context } from '../../server'
import encode from '../../jwt/encode'
@ -225,12 +226,6 @@ describe('currentUser', () => {
})
describe('login', () => {
const loginMutation = gql`
mutation ($email: String!, $password: String!) {
login(email: $email, password: $password)
}
`
const respondsWith = async (expected) => {
await expect(mutate({ mutation: loginMutation, variables })).resolves.toMatchObject(expected)
}

View File

@ -4,7 +4,7 @@ import { UserInputError, ForbiddenError } from 'apollo-server'
import { mergeImage, deleteImage } from './images/images'
import Resolver from './helpers/Resolver'
import log from './helpers/databaseLogger'
import createOrUpdateLocations from './users/location'
import { createOrUpdateLocations } from './users/location'
const neode = getNeode()
@ -139,9 +139,10 @@ export default {
return blockedUser.toJson()
},
UpdateUser: async (_parent, params, context, _resolveInfo) => {
const { termsAndConditionsAgreedVersion } = params
const { avatar: avatarInput } = params
delete params.avatar
params.locationName = params.locationName === '' ? null : params.locationName
const { termsAndConditionsAgreedVersion } = params
if (termsAndConditionsAgreedVersion) {
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)
if (!regEx.test(termsAndConditionsAgreedVersion)) {
@ -169,7 +170,8 @@ export default {
})
try {
const user = await writeTxResultPromise
await createOrUpdateLocations(params.id, params.locationName, session)
// TODO: put in a middleware, see "CreateGroup", "UpdateGroup"
await createOrUpdateLocations('User', params.id, params.locationName, session)
return user
} catch (error) {
throw new UserInputError(error.message)

View File

@ -161,7 +161,7 @@ describe('UpdateUser', () => {
$id: ID!
$name: String
$termsAndConditionsAgreedVersion: String
$locationName: String
$locationName: String # empty string '' sets it to null
) {
UpdateUser(
id: $id
@ -174,6 +174,11 @@ describe('UpdateUser', () => {
termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt
locationName
location {
name
nameDE
nameEN
}
}
}
`
@ -289,11 +294,39 @@ describe('UpdateUser', () => {
expect(errors[0]).toHaveProperty('message', 'Invalid version format!')
})
it('supports updating location', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States' } },
errors: undefined,
describe('supports updating location', () => {
describe('change location to "Hamburg, New Jersey, United States"', () => {
it('has updated location to "Hamburg, New Jersey, United States"', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: {
UpdateUser: {
locationName: 'Hamburg, New Jersey, United States',
location: expect.objectContaining({
name: 'Hamburg',
nameDE: 'Hamburg',
nameEN: 'Hamburg',
}),
},
},
errors: undefined,
})
})
})
describe('change location to unset location', () => {
it('has updated location to unset location', async () => {
variables = { ...variables, locationName: '' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: {
UpdateUser: {
locationName: null,
location: null,
},
},
errors: undefined,
})
})
})
})
})

View File

@ -1,6 +1,5 @@
import request from 'request'
import { UserInputError } from 'apollo-server'
import isEmpty from 'lodash/isEmpty'
import Debug from 'debug'
import asyncForEach from '../../../helpers/asyncForEach'
import CONFIG from '../../../config'
@ -62,77 +61,86 @@ const createLocation = async (session, mapboxData) => {
})
}
const createOrUpdateLocations = async (userId, locationName, session) => {
if (isEmpty(locationName)) {
return
}
const res = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
locationName,
)}.json?access_token=${CONFIG.MAPBOX_TOKEN}&types=region,place,country&language=${locales.join(
',',
)}`,
)
export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, session) => {
if (locationName === undefined) return
debug(res)
let locationId
if (!res || !res.features || !res.features[0]) {
throw new UserInputError('locationName is invalid')
}
if (locationName !== null) {
const res = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
locationName,
)}.json?access_token=${
CONFIG.MAPBOX_TOKEN
}&types=region,place,country&language=${locales.join(',')}`,
)
let data
debug(res)
res.features.forEach((item) => {
if (item.matching_place_name === locationName) {
data = item
if (!res || !res.features || !res.features[0]) {
throw new UserInputError('locationName is invalid')
}
})
if (!data) {
data = res.features[0]
}
if (!data || !data.place_type || !data.place_type.length) {
throw new UserInputError('locationName is invalid')
}
let data
if (data.place_type.length > 1) {
data.id = 'region.' + data.id.split('.')[1]
}
await createLocation(session, data)
let parent = data
if (data.context) {
await asyncForEach(data.context, async (ctx) => {
await createLocation(session, ctx)
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId})
MERGE (child)<-[:IS_IN]-(parent)
RETURN child.id, parent.id
`,
{
parentId: parent.id,
childId: ctx.id,
},
)
})
parent = ctx
res.features.forEach((item) => {
if (item.matching_place_name === locationName) {
data = item
}
})
if (!data) {
data = res.features[0]
}
if (!data || !data.place_type || !data.place_type.length) {
throw new UserInputError('locationName is invalid')
}
if (data.place_type.length > 1) {
data.id = 'region.' + data.id.split('.')[1]
}
await createLocation(session, data)
let parent = data
if (data.context) {
await asyncForEach(data.context, async (ctx) => {
await createLocation(session, ctx)
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId})
MERGE (child)<-[:IS_IN]-(parent)
RETURN child.id, parent.id
`,
{
parentId: parent.id,
childId: ctx.id,
},
)
})
parent = ctx
})
}
locationId = data.id
} else {
locationId = 'non-existent-id'
}
// delete all current locations from user and add new location
// delete all current locations from node and add new location
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (user:User {id: $userId})-[relationship:IS_IN]->(location:Location)
DETACH DELETE relationship
WITH user
MATCH (location:Location {id: $locationId})
MERGE (user)-[:IS_IN]->(location)
RETURN location.id, user.id
`,
{ userId: userId, locationId: data.id },
MATCH (node:${nodeLabel} {id: $nodeId})
OPTIONAL MATCH (node)-[relationship:IS_IN]->(:Location)
DELETE relationship
WITH node
MATCH (location:Location {id: $locationId})
MERGE (node)-[:IS_IN]->(location)
RETURN location.id, node.id
`,
{ nodeId, locationId },
)
})
}
@ -147,5 +155,3 @@ export const queryLocations = async ({ place, lang }) => {
}
return res.features
}
export default createOrUpdateLocations

View File

@ -0,0 +1,7 @@
enum GroupActionRadius {
regional
national
continental
global
interplanetary
}

View File

@ -0,0 +1,6 @@
enum GroupMemberRole {
pending
usual
admin
owner
}

View File

@ -0,0 +1,5 @@
enum GroupType {
public
closed
hidden
}

View File

@ -0,0 +1,133 @@
enum _GroupOrdering {
id_asc
id_desc
name_asc
name_desc
slug_asc
slug_desc
locationName_asc
locationName_desc
about_asc
about_desc
createdAt_asc
createdAt_desc
updatedAt_asc
updatedAt_desc
}
type Group {
id: ID!
name: String! # title
slug: String!
createdAt: String!
updatedAt: String!
deleted: Boolean
disabled: Boolean
avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT")
about: String # goal
description: String!
descriptionExcerpt: String!
groupType: GroupType!
actionRadius: GroupActionRadius!
locationName: String
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
myRole: GroupMemberRole # if 'null' then the current user is no member
posts: [Post] @relation(name: "IN", direction: "IN")
}
input _GroupFilter {
AND: [_GroupFilter!]
OR: [_GroupFilter!]
name_contains: String
slug_contains: String
about_contains: String
description_contains: String
groupType_in: [GroupType!]
actionRadius_in: [GroupActionRadius!]
myRole_in: [GroupMemberRole!]
id: ID
id_not: ID
id_in: [ID!]
id_not_in: [ID!]
}
type Query {
Group(
isMember: Boolean # if 'undefined' or 'null' then get all groups
id: ID
slug: String
# first: Int # not implemented yet
# offset: Int # not implemented yet
# orderBy: [_GroupOrdering] # not implemented yet
# filter: _GroupFilter # not implemented yet
): [Group]
GroupMembers(
id: ID!
# first: Int # not implemented yet
# offset: Int # not implemented yet
# orderBy: [_UserOrdering] # not implemented yet
# filter: _UserFilter # not implemented yet
): [User]
# AvailableGroupTypes: [GroupType]!
# AvailableGroupActionRadii: [GroupActionRadius]!
# AvailableGroupMemberRoles: [GroupMemberRole]!
}
type Mutation {
CreateGroup(
id: ID
name: String!
slug: String
about: String
description: String!
groupType: GroupType!
actionRadius: GroupActionRadius!
categoryIds: [ID]
# avatar: ImageInput # a group can not be created with an avatar
locationName: String # empty string '' sets it to null
): Group
UpdateGroup(
id: ID!
name: String
slug: String
about: String
description: String
# groupType: GroupType # is not possible at the moment and has to be discussed. may be in the stronger direction: public → closed → hidden
actionRadius: GroupActionRadius
categoryIds: [ID]
avatar: ImageInput # test this as result
locationName: String # empty string '' sets it to null
): Group
# DeleteGroup(id: ID!): Group
JoinGroup(
groupId: ID!
userId: ID!
): User
LeaveGroup(
groupId: ID!
userId: ID!
): User
ChangeGroupMemberRole(
groupId: ID!
userId: ID!
roleInGroup: GroupMemberRole!
): User
}

View File

@ -0,0 +1,5 @@
type MEMBER_OF {
createdAt: String!
updatedAt: String!
role: GroupMemberRole!
}

View File

@ -81,6 +81,7 @@ input _PostFilter {
emotions_none: _PostEMOTEDFilter
emotions_single: _PostEMOTEDFilter
emotions_every: _PostEMOTEDFilter
group: _GroupFilter
}
enum _PostOrdering {
@ -167,6 +168,8 @@ type Post {
emotions: [EMOTED]
emotionsCount: Int!
@cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)")
group: Group @relation(name: "IN", direction: "OUT")
}
input _PostInput {
@ -184,6 +187,7 @@ type Mutation {
language: String
categoryIds: [ID]
contentExcerpt: String
groupId: ID
): Post
UpdatePost(
id: ID!
@ -225,18 +229,4 @@ type Query {
PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int!
PostsEmotionsByCurrentUser(postId: ID!): [String]
profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post]
findPosts(query: String!, limit: Int = 10, filter: _PostFilter): [Post]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('post_fulltext_search', $query)
YIELD node as post, score
MATCH (post)<-[:WROTE]-(user:User)
WHERE score >= 0.2
AND NOT user.deleted = true AND NOT user.disabled = true
AND NOT post.deleted = true AND NOT post.disabled = true
AND NOT user.id in COALESCE($filter.author_not.id_in, [])
RETURN post
LIMIT $limit
"""
)
}

View File

@ -1,4 +1,4 @@
union SearchResult = Post | User | Tag
union SearchResult = Post | User | Tag | Group
type postSearchResults {
postCount: Int
@ -15,9 +15,15 @@ type hashtagSearchResults {
hashtags: [Tag]!
}
type groupSearchResults {
groupCount: Int
groups: [Group]!
}
type Query {
searchPosts(query: String!, firstPosts: Int, postsOffset: Int): postSearchResults!
searchUsers(query: String!, firstUsers: Int, usersOffset: Int): userSearchResults!
searchGroups(query: String!, firstGroups: Int, groupsOffset: Int): groupSearchResults!
searchHashtags(query: String!, firstHashtags: Int, hashtagsOffset: Int): hashtagSearchResults!
searchResults(query: String!, limit: Int = 5): [SearchResult]!
}

View File

@ -33,8 +33,8 @@ type User {
invitedBy: User @relation(name: "INVITED", direction: "IN")
invited: [User] @relation(name: "INVITED", direction: "OUT")
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
locationName: String
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
about: String
socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN")
@ -122,6 +122,8 @@ type User {
RETURN collect(category.id)
"""
)
myRoleInGroup: GroupMemberRole
}
@ -164,19 +166,19 @@ input _UserFilter {
type Query {
User(
id: ID
email: String # admins need to search for a user sometimes
name: String
slug: String
role: UserRole
locationName: String
about: String
createdAt: String
updatedAt: String
first: Int
offset: Int
orderBy: [_UserOrdering]
filter: _UserFilter
id: ID
email: String # admins need to search for a user sometimes
name: String
slug: String
role: UserRole
locationName: String
about: String
createdAt: String
updatedAt: String
first: Int
offset: Int
orderBy: [_UserOrdering]
filter: _UserFilter
): [User]
availableRoles: [UserRole]!
@ -184,18 +186,6 @@ type Query {
blockedUsers: [User]
isLoggedIn: Boolean!
currentUser: User
findUsers(query: String!,limit: Int = 10, filter: _UserFilter): [User]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('user_fulltext_search', $query)
YIELD node as post, score
MATCH (user)
WHERE score >= 0.2
AND NOT user.deleted = true AND NOT user.disabled = true
RETURN user
LIMIT $limit
"""
)
}
enum Deletable {
@ -205,19 +195,19 @@ enum Deletable {
type Mutation {
UpdateUser (
id: ID!
name: String
email: String
slug: String
avatar: ImageInput
locationName: String
about: String
termsAndConditionsAgreedVersion: String
termsAndConditionsAgreedAt: String
allowEmbedIframes: Boolean
showShoutsPublicly: Boolean
sendNotificationEmails: Boolean
locale: String
id: ID!
name: String
email: String
slug: String
avatar: ImageInput
locationName: String # empty string '' sets it to null
about: String
termsAndConditionsAgreedVersion: String
termsAndConditionsAgreedAt: String
allowEmbedIframes: Boolean
showShoutsPublicly: Boolean
sendNotificationEmails: Boolean
locale: String
): User
DeleteUser(id: ID!, resource: [Deletable]): User

View File

@ -997,9 +997,9 @@
tslib "1.11.1"
"@hapi/address@2.x.x":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.2.tgz#1c794cd6dbf2354d1eb1ef10e0303f573e1c7222"
integrity sha512-O4QDrx+JoGKZc6aN64L04vqa7e41tIiLU+OvKdcYaEMP97UttL0f9GIi9/0A4WAMx0uBd6SidDIhktZhgOcN8Q==
version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
integrity sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ==
"@hapi/address@^4.0.1":
version "4.0.1"
@ -1018,10 +1018,10 @@
resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128"
integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==
"@hapi/hoek@8.x.x":
version "8.2.4"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.2.4.tgz#684a14f4ca35d46f44abc87dfc696e5e4fe8a020"
integrity sha512-Ze5SDNt325yZvNO7s5C4fXDscjJ6dcqLFXJQ/M7dZRQCewuDj2iDUuBi6jLQt+APbW9RjjVEvLr35FXuOEqjow==
"@hapi/hoek@8.x.x", "@hapi/hoek@^8.3.0":
version "8.5.1"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06"
integrity sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow==
"@hapi/hoek@^9.0.0":
version "9.0.0"
@ -1055,11 +1055,11 @@
integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==
"@hapi/topo@3.x.x":
version "3.1.3"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.3.tgz#c7a02e0d936596d29f184e6d7fdc07e8b5efce11"
integrity sha512-JmS9/vQK6dcUYn7wc2YZTqzIKubAQcJKu2KCKAru6es482U5RT5fP1EXCPtlXpiK7PR0On/kpQKI4fRKkzpZBQ==
version "3.1.6"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.6.tgz#68d935fa3eae7fdd5ab0d7f953f3205d8b2bfc29"
integrity sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ==
dependencies:
"@hapi/hoek" "8.x.x"
"@hapi/hoek" "^8.3.0"
"@hapi/topo@^5.0.0":
version "5.0.0"
@ -2681,6 +2681,11 @@ base64-js@^1.0.2:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base@^0.11.1:
version "0.11.2"
resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
@ -2850,6 +2855,14 @@ buffer@4.9.1:
ieee754 "^1.1.4"
isarray "^1.0.0"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.2.1"
busboy@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.3.1.tgz#170899274c5bf38aae27d5c62b71268cd585fd1b"
@ -3929,7 +3942,7 @@ dot-prop@^4.1.0:
dotenv@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d"
integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0=
integrity sha512-XcaMACOr3JMVcEv0Y/iUM2XaOsATRZ3U1In41/1jjK6vJZ2PZbQ1bzCG8uvaByfaBpl9gqc9QWJovpUGBXLLYQ==
dotenv@^6.1.0:
version "6.2.0"
@ -5516,6 +5529,11 @@ ieee754@1.1.13, ieee754@^1.1.4:
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ienoopen@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974"
@ -7528,18 +7546,19 @@ negotiator@0.6.2:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
neo4j-driver-bolt-connection@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.3.4.tgz#de642bb6a62ffc6ae2e280dccf21395b4d1705a2"
integrity sha512-yxbvwGav+N7EYjcEAINqL6D3CZV+ee2qLInpAhx+iNurwbl3zqtBGiVP79SZ+7tU++y3Q1fW5ofikH06yc+LqQ==
neo4j-driver-bolt-connection@^4.4.7:
version "4.4.7"
resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.4.7.tgz#0582d54de1f213e60c374209193d1f645ba523ea"
integrity sha512-6Q4hCtvWE6gzN64N09UqZqf/3rDl7FUWZZXiVQL0ZRbaMkJpZNC2NmrDIgGXYE05XEEbRBexf2tVv5OTYZYrow==
dependencies:
neo4j-driver-core "^4.3.4"
text-encoding-utf-8 "^1.0.2"
buffer "^6.0.3"
neo4j-driver-core "^4.4.7"
string_decoder "^1.3.0"
neo4j-driver-core@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.3.4.tgz#b445a4fbf94dce8441075099bd6ac3133c1cf5ee"
integrity sha512-3tn3j6IRUNlpXeehZ9Xv7dLTZPB4a7APaoJ+xhQyMmYQO3ujDM4RFHc0pZcG+GokmaltT5pUCIPTDYx6ODdhcA==
neo4j-driver-core@^4.4.7:
version "4.4.7"
resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.4.7.tgz#d2475e107b3fea2b9d1c36b0c273da5c5a291c37"
integrity sha512-NhvVuQYgG7eO/vXxRaoJfkWUNkjvIpmCIS9UWU9Bbhb4V+wCOyX/MVOXqD0Yizhs4eyIkD7x90OXb79q+vi+oA==
neo4j-driver@^4.0.1, neo4j-driver@^4.0.2:
version "4.0.2"
@ -7552,13 +7571,13 @@ neo4j-driver@^4.0.1, neo4j-driver@^4.0.2:
uri-js "^4.2.2"
neo4j-driver@^4.2.2:
version "4.3.4"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.3.4.tgz#a54f0562f868ee94dff7509df74e3eb2c1f95a85"
integrity sha512-AGrsFFqnoZv4KhJdmKt4mOBV5mnxmV3+/t8KJTOM68jQuEWoy+RlmAaRRaCSU4eY586OFN/R8lg9MrJpZdSFjw==
version "4.4.7"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.4.7.tgz#51b3fb48241e66eb3be94e90032cc494c44e59f3"
integrity sha512-N7GddPhp12gVJe4eB84u5ik5SmrtRv8nH3rK47Qy7IUKnJkVEos/F1QjOJN6zt1jLnDXwDcGzCKK8XklYpzogw==
dependencies:
"@babel/runtime" "^7.5.5"
neo4j-driver-bolt-connection "^4.3.4"
neo4j-driver-core "^4.3.4"
neo4j-driver-bolt-connection "^4.4.7"
neo4j-driver-core "^4.4.7"
rxjs "^6.6.3"
neo4j-graphql-js@^2.11.5:
@ -9603,7 +9622,7 @@ string.prototype.trimstart@^1.0.1:
define-properties "^1.1.3"
es-abstract "^1.17.5"
string_decoder@^1.1.1:
string_decoder@^1.1.1, string_decoder@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==

View File

@ -1,7 +1,7 @@
import { When } from "cypress-cucumber-preprocessor/steps";
When('I click on the author', () => {
cy.get('.user-teaser')
cy.get('[data-test="avatarUserLink"]')
.click()
.url().should('include', '/profile/')
})

View File

@ -5,7 +5,7 @@ Then("I should see my comment", () => {
.should("contain", "Ocelot.social rocks")
.get(".user-teaser span.slug")
.should("contain", "@peter-pan") // specific enough
.get(".user-avatar img")
.get(".profile-avatar img")
.should("have.attr", "src")
.and("contain", 'https://') // some url
.get(".user-teaser > .info > .text")

View File

@ -4,5 +4,5 @@ Then("I cannot upload a picture", () => {
cy.get(".base-card")
.children()
.should("not.have.id", "customdropzone")
.should("have.class", "user-avatar");
.should("have.class", "profile-avatar");
});

View File

@ -9,7 +9,7 @@ Then("I should be able to change my profile picture", () => {
{ subjectType: "drag-n-drop", force: true }
);
});
cy.get(".profile-avatar img")
cy.get(".profile-page-avatar img")
.should("have.attr", "src")
.and("contains", "onourjourney");
cy.contains(".iziToast-message", "Upload successful")

View File

@ -1,6 +1,6 @@
{
"name": "ocelot-social",
"version": "1.1.1",
"version": "2.0.0",
"description": "Free and open source software program code available to run social networks.",
"author": "ocelot.social Community",
"license": "MIT",

View File

@ -4,4 +4,4 @@ PUBLIC_REGISTRATION=false
INVITE_REGISTRATION=true
WEBSOCKETS_URI=ws://localhost:3000/api/graphql
GRAPHQL_URI=http://localhost:4000/
CATEGORIES_ACTIVE=false
CATEGORIES_ACTIVE=false

View File

@ -268,6 +268,7 @@ $size-avatar-large: 114px;
* @presenter Spacing
*/
$size-button-large: 50px;
$size-button-base: 36px;
$size-button-small: 26px;

View File

@ -42,9 +42,9 @@ describe('AvatarMenu.vue', () => {
wrapper = Wrapper()
})
it('renders the UserAvatar component', () => {
it('renders the ProfileAvatar component', () => {
wrapper.find('.avatar-menu-trigger').trigger('click')
expect(wrapper.find('.user-avatar').exists()).toBe(true)
expect(wrapper.find('.profile-avatar').exists()).toBe(true)
})
describe('given a userName', () => {
@ -90,6 +90,13 @@ describe('AvatarMenu.vue', () => {
expect(profileLink.exists()).toBe(true)
})
it('displays a link to "My groups"', () => {
const profileLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex((route) => route.path === '/my-groups'))
expect(profileLink.exists()).toBe(true)
})
it('displays a link to the notifications page', () => {
const notificationsLink = wrapper
.findAll('.ds-menu-item span')
@ -103,6 +110,11 @@ describe('AvatarMenu.vue', () => {
.at(wrapper.vm.routes.findIndex((route) => route.path === '/settings'))
expect(settingsLink.exists()).toBe(true)
})
it('displays a total of 4 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(4)
})
})
describe('role moderator', () => {
@ -125,9 +137,9 @@ describe('AvatarMenu.vue', () => {
expect(moderationLink.exists()).toBe(true)
})
it('displays a total of 4 links', () => {
it('displays a total of 5 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(4)
expect(allLinks).toHaveLength(5)
})
})
@ -151,9 +163,9 @@ describe('AvatarMenu.vue', () => {
expect(adminLink.exists()).toBe(true)
})
it('displays a total of 5 links', () => {
it('displays a total of 6 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(5)
expect(allLinks).toHaveLength(6)
})
})
})

View File

@ -11,7 +11,7 @@
"
@click.prevent="toggleMenu"
>
<user-avatar :user="user" size="small" />
<profile-avatar :profile="user" size="small" />
<base-icon class="dropdown-arrow" name="angle-down" />
</a>
</template>
@ -50,12 +50,12 @@
<script>
import { mapGetters } from 'vuex'
import Dropdown from '~/components/Dropdown'
import UserAvatar from '~/components/_new/generic/UserAvatar/UserAvatar'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
export default {
components: {
Dropdown,
UserAvatar,
ProfileAvatar,
},
props: {
placement: { type: String, default: 'top-end' },
@ -72,10 +72,15 @@ export default {
}
const routes = [
{
name: this.$t('profile.name'),
name: this.$t('header.avatarMenu.myProfile'),
path: `/profile/${this.user.id}/${this.user.slug}`,
icon: 'user',
},
{
name: this.$t('header.avatarMenu.myGroups'),
path: '/my-groups',
icon: 'users',
},
{
name: this.$t('notifications.pageLink'),
path: '/notifications',
@ -130,7 +135,7 @@ export default {
align-items: center;
padding-left: $space-xx-small;
> .user-avatar {
> .profile-avatar {
margin-right: $space-xx-small;
}
}

View File

@ -19,7 +19,6 @@ import { followUserMutation, unfollowUserMutation } from '~/graphql/User'
export default {
name: 'HcFollowButton',
props: {
followId: { type: String, default: null },
isFollowed: { type: Boolean, default: false },

View File

@ -0,0 +1,136 @@
<template>
<base-button
class="join-leave-button"
:disabled="disabled"
:loading="localLoading"
:icon="icon"
:filled="isMember && !hovered"
:danger="isMember && hovered"
@mouseenter.native="onHover"
@mouseleave.native="hovered = false"
@click.prevent="toggle"
>
{{ label }}
</base-button>
</template>
<script>
import { mapMutations } from 'vuex'
import { joinGroupMutation, leaveGroupMutation } from '~/graphql/groups'
export default {
name: 'JoinLeaveButton',
props: {
group: { type: Object, required: true },
userId: { type: String, required: true },
isMember: { type: Boolean, required: true },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
},
data() {
return {
localLoading: this.loading,
hovered: false,
}
},
computed: {
icon() {
if (this.isMember && this.hovered) {
return 'close'
} else {
return this.isMember ? 'check' : 'plus'
}
},
label() {
if (this.isMember) {
return this.$t('group.joinLeaveButton.iAmMember')
} else {
return this.$t('group.joinLeaveButton.join')
}
},
},
watch: {
isMember() {
this.localLoading = false
this.hovered = false
},
loading() {
this.localLoading = this.loading
},
},
methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
}),
onHover() {
if (!this.disabled && !this.localLoading) {
this.hovered = true
}
},
toggle() {
if (this.isMember) {
this.openLeaveModal()
} else {
this.joinLeave()
}
},
openLeaveModal() {
this.commitModalData(this.leaveModalData())
},
leaveModalData() {
return {
name: 'confirm',
data: {
type: '',
resource: { id: '' },
modalData: {
titleIdent: 'group.leaveModal.title',
messageIdent: 'group.leaveModal.message',
messageParams: {
name: this.group.name,
},
buttons: {
confirm: {
danger: true,
icon: 'sign-out',
textIdent: 'group.leaveModal.confirmButton',
callback: this.joinLeave,
},
cancel: {
icon: 'close',
textIdent: 'actions.cancel',
callback: () => {},
},
},
},
},
}
},
async joinLeave() {
const join = !this.isMember
const mutation = join ? joinGroupMutation() : leaveGroupMutation()
this.hovered = false
this.$emit('prepare', join)
try {
const { data } = await this.$apollo.mutate({
mutation,
variables: { groupId: this.group.id, userId: this.userId },
})
const joinedLeftGroupResult = join ? data.JoinGroup : data.LeaveGroup
this.$emit('update', joinedLeftGroupResult)
} catch (error) {
this.$toast.error(error.message)
}
},
},
}
</script>
<style lang="scss">
.join-leave-button {
display: block;
width: 100%;
}
</style>

View File

@ -12,7 +12,6 @@
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
delay: { show: 1500 },
}"
>
{{ $t(`contribution.category.name.${category.slug}`) }}
@ -22,6 +21,7 @@
<script>
import CategoryQuery from '~/graphql/CategoryQuery'
import { CATEGORIES_MAX } from '~/constants/categories.js'
import xor from 'lodash/xor'
export default {
@ -37,7 +37,7 @@ export default {
data() {
return {
categories: null,
selectedMax: 3,
selectedMax: CATEGORIES_MAX,
selectedCategoryIds: this.existingCategoryIds,
}
},

View File

@ -7,7 +7,6 @@
icon="ellipsis-v"
size="small"
circle
ghost
@click.prevent="toggleMenu()"
/>
</slot>

View File

@ -0,0 +1,38 @@
import { config, mount } from '@vue/test-utils'
import GroupContentMenu from './GroupContentMenu.vue'
const localVue = global.localVue
config.stubs['router-link'] = '<span><slot /></span>'
const propsData = {
usage: 'groupTeaser',
resource: {},
group: {},
resourceType: 'group',
}
describe('GroupContentMenu', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupContentMenu, { propsData, mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.findAll('.group-content-menu')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,98 @@
<template>
<dropdown class="group-content-menu" :placement="placement" offset="5">
<template #default="{ toggleMenu }">
<slot name="button" :toggleMenu="toggleMenu">
<base-button
icon="ellipsis-v"
size="small"
circle
@click.prevent="toggleMenu()"
data-test="group-menu-button"
/>
</slot>
</template>
<template #popover="{ toggleMenu }">
<div class="group-menu-popover">
<ds-menu :routes="routes">
<template #menuitem="item">
{{ item.parents }}
<ds-menu-item
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<base-icon :name="item.route.icon" />
{{ item.route.label }}
</ds-menu-item>
</template>
</ds-menu>
</div>
</template>
</dropdown>
</template>
<script>
import Dropdown from '~/components/Dropdown'
export default {
name: 'GroupContentMenu',
components: {
Dropdown,
},
props: {
usage: {
type: String,
required: true,
validator: (value) => {
return value.match(/(groupTeaser|groupProfile)/)
},
},
group: { type: Object, required: true },
placement: { type: String, default: 'bottom-end' },
},
computed: {
routes() {
const routes = []
if (this.usage !== 'groupProfile') {
routes.push({
label: this.$t('group.contentMenu.visitGroupPage'),
icon: 'home',
name: 'group-id-slug',
params: { id: this.group.id, slug: this.group.slug },
})
}
if (this.group.myRole === 'owner') {
routes.push({
label: this.$t('admin.settings.name'),
path: `/group/edit/${this.group.id}`,
icon: 'edit',
})
}
return routes
},
},
methods: {
openItem(route, toggleMenu) {
if (route.callback) {
route.callback()
} else {
this.$router.push(route)
}
toggleMenu()
},
},
}
</script>
<style lang="scss">
.group-menu-popover {
nav {
margin-top: -$space-xx-small;
margin-bottom: -$space-xx-small;
margin-left: -$space-x-small;
margin-right: -$space-x-small;
}
}
</style>

View File

@ -4,7 +4,7 @@ import ContributionForm from './ContributionForm.vue'
import Vuex from 'vuex'
import PostMutations from '~/graphql/PostMutations.js'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
import ImageUploader from '~/components/Uploader/ImageUploader'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
@ -138,6 +138,7 @@ describe('ContributionForm.vue', () => {
categoryIds: [],
id: null,
image: null,
groupId: null,
},
}
postTitleInput = wrapper.find('.ds-input')
@ -260,6 +261,7 @@ describe('ContributionForm.vue', () => {
content: propsData.contribution.content,
categoryIds: [],
id: propsData.contribution.id,
groupId: null,
image: {
sensitive: false,
},

View File

@ -83,7 +83,7 @@ import { mapGetters } from 'vuex'
import HcEditor from '~/components/Editor/Editor'
import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
import ImageUploader from '~/components/Uploader/ImageUploader'
import links from '~/constants/links.js'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
@ -99,6 +99,10 @@ export default {
type: Object,
default: () => ({}),
},
groupId: {
type: String,
default: () => null,
},
},
data() {
const { title, content, image, categories } = this.contribution
@ -173,6 +177,7 @@ export default {
categoryIds,
id: this.contribution.id || null,
image,
groupId: this.groupId,
},
})
.then(({ data }) => {

View File

@ -20,7 +20,6 @@
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
delay: { show: 1500 },
}"
/>
</li>

View File

@ -0,0 +1,5 @@
<template>
<div>
<nuxt-link to="/my-groups"><base-button icon="users" circle ghost /></nuxt-link>
</div>
</template>

View File

@ -0,0 +1,39 @@
import { config, mount } from '@vue/test-utils'
import GroupForm from './GroupForm.vue'
const localVue = global.localVue
config.stubs['nuxt-link'] = '<span><slot /></span>'
const propsData = {
update: false,
group: {},
}
describe('GroupForm', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
$env: {
CATEGORIES_ACTIVE: true,
},
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupForm, { propsData, mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.findAll('.group-form')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,440 @@
<template>
<div>
<ds-form
class="group-form"
ref="groupForm"
v-model="formData"
:schema="formSchema"
@submit="submit"
>
<!-- "errors" is only working if you use a submit event on the form -->
<template #default="{ errors }">
<!-- group Name -->
<ds-input
name="name"
:label="$t('group.name')"
model="name"
autofocus
:placeholder="`${$t('group.name')} …`"
/>
<ds-chip size="base" :color="errors && errors.name ? 'danger' : 'medium'">
{{ `${formData.name.length} / ${formSchema.name.min}${formSchema.name.max}` }}
<base-icon v-if="errors && errors.name" name="warning" />
</ds-chip>
<!-- group Slug -->
<ds-input
v-if="update"
:label="$t('group.labelSlug')"
model="slug"
icon="at"
:placeholder="`${$t('group.labelSlug')} …`"
></ds-input>
<ds-space v-if="update" margin-top="small" />
<!-- groupType -->
<ds-text class="select-label">
{{ $t('group.type') }}
</ds-text>
<!-- TODO: change it has to be implemented later -->
<!-- TODO: move 'ds-select' from style guide to main code and implement missing translation etc. functionality -->
<select
class="select ds-input appearance--auto"
name="groupType"
model="groupType"
:value="formData.groupType"
:disabled="update"
@change="changeGroupType($event)"
>
<option v-for="groupType in groupTypeOptions" :key="groupType" :value="groupType">
{{ $t(`group.types.${groupType}`) }}
</option>
</select>
<ds-chip
size="base"
:color="errors && errors.groupType && formData.groupType === '' ? 'danger' : 'medium'"
>
{{ `${formData.groupType === '' ? 0 : 1} / 1` }}
<base-icon
v-if="errors && errors.groupType && formData.groupType === ''"
name="warning"
/>
</ds-chip>
<!-- goal -->
<ds-input
name="about"
:label="$t('group.goal')"
v-model="formData.about"
:placeholder="$t('group.goal') + ' …'"
rows="3"
/>
<ds-space margin-top="small" />
<!-- description -->
<ds-text class="select-label">
{{ $t('group.description') }}
</ds-text>
<editor
name="description"
model="description"
:users="null"
:value="formData.description"
:hashtags="null"
@input="updateEditorDescription"
/>
<ds-chip size="base" :color="errors && errors.description ? 'danger' : 'medium'">
{{ `${descriptionLength} / ${formSchema.description.min}` }}
<base-icon v-if="errors && errors.description" name="warning" />
</ds-chip>
<!-- actionRadius -->
<ds-text class="select-label">
{{ $t('group.actionRadius') }}
</ds-text>
<!-- TODO: move 'ds-select' from styleguide to main code and implement missing translation etc. functionality -->
<select
class="select ds-input appearance--auto"
name="actionRadius"
:value="formData.actionRadius"
@change="changeActionRadius($event)"
>
<option
v-for="actionRadius in actionRadiusOptions"
:key="actionRadius"
:value="actionRadius"
>
{{ $t(`group.actionRadii.${actionRadius}`) }}
</option>
</select>
<ds-chip
size="base"
:color="
errors && errors.actionRadius && formData.actionRadius === '' ? 'danger' : 'medium'
"
>
{{ `${formData.actionRadius === '' ? 0 : 1} / 1` }}
<base-icon
v-if="errors && errors.actionRadius && formData.actionRadius === ''"
name="warning"
/>
</ds-chip>
<!-- location -->
<ds-select
id="city"
:label="$t('settings.data.labelCity') + locationNameLabelAddOnOldName"
v-model="formData.locationName"
:options="cities"
icon="map-marker"
:icon-right="null"
:placeholder="$t('settings.data.labelCity') + ' …'"
:loading="loadingGeo"
@input.native="handleCityInput"
/>
<base-button
v-if="formLocationName !== ''"
icon="close"
ghost
size="small"
style="position: relative; display: inline-block; right: -96%; top: -33px; width: 26px"
@click="formData.locationName = ''"
></base-button>
<ds-space margin-top="small" />
<!-- category -->
<categories-select
v-if="categoriesActive"
model="categoryIds"
name="categoryIds"
:existingCategoryIds="formData.categoryIds"
/>
<ds-chip
v-if="categoriesActive"
size="base"
:color="errors && errors.categoryIds ? 'danger' : 'medium'"
>
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<!-- submit -->
<ds-space margin-top="large">
<nuxt-link to="/my-groups">
<ds-button>{{ $t('actions.cancel') }}</ds-button>
</nuxt-link>
<ds-button type="submit" icon="save" primary :disabled="checkFormError(errors)" fill>
{{ update ? $t('group.update') : $t('group.save') }}
</ds-button>
</ds-space>
</template>
</ds-form>
</div>
</template>
<script>
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import { CATEGORIES_MIN, CATEGORIES_MAX } from '~/constants/categories.js'
import {
NAME_LENGTH_MIN,
NAME_LENGTH_MAX,
DESCRIPTION_WITHOUT_HTML_LENGTH_MIN,
} from '~/constants/groups.js'
import Editor from '~/components/Editor/Editor'
import { queryLocations } from '~/graphql/location'
let timeout
export default {
name: 'GroupForm',
components: {
CategoriesSelect,
Editor,
},
props: {
update: {
type: Boolean,
required: false,
default: false,
},
group: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
const { name, slug, groupType, about, description, actionRadius, locationName, categories } =
this.group
return {
categoriesActive: this.$env.CATEGORIES_ACTIVE,
disabled: false,
groupTypeOptions: ['public', 'closed', 'hidden'],
actionRadiusOptions: ['regional', 'national', 'continental', 'global'],
loadingGeo: false,
cities: [],
formData: {
name: name || '',
slug: slug || '',
groupType: groupType || '',
about: about || '',
description: description || '',
// from database 'locationName' comes as "string | null"
// 'formData.locationName':
// see 'created': tries to set it to a "requestGeoData" object and fills the menu if possible
// if user selects one from menu we get a "requestGeoData" object here
// "requestGeoData" object: "{ id: String, label: String, value: String }"
// otherwise it's a string: empty or none empty
locationName: locationName || '',
actionRadius: actionRadius || '',
categoryIds: categories ? categories.map((category) => category.id) : [],
},
formSchema: {
name: { required: true, min: NAME_LENGTH_MIN, max: NAME_LENGTH_MAX },
slug: { required: false, min: NAME_LENGTH_MIN },
groupType: { required: true, min: 1 },
about: { required: false },
description: {
type: 'string',
required: true,
min: DESCRIPTION_WITHOUT_HTML_LENGTH_MIN,
validator: (_, value = '') => {
if (this.$filters.removeHtml(value).length < this.formSchema.description.min) {
return [new Error()]
}
return []
},
},
actionRadius: { required: true, min: 1 },
locationName: { required: false },
categoryIds: {
type: 'array',
required: this.categoriesActive,
validator: (_, value = []) => {
if (
this.categoriesActive &&
(value.length < CATEGORIES_MIN || value.length > CATEGORIES_MAX)
) {
return [new Error(this.$t('common.validations.categories'))]
}
return []
},
},
},
}
},
async created() {
// set to "requestGeoData" object and fill select menu if possible
this.formData.locationName =
(await this.requestGeoData(this.formLocationName)) || this.formLocationName
},
computed: {
formLocationName() {
const isNestedValue =
typeof this.formData.locationName === 'object' &&
typeof this.formData.locationName.value === 'string'
const isDirectString = typeof this.formData.locationName === 'string'
return isNestedValue
? this.formData.locationName.value
: isDirectString
? this.formData.locationName
: ''
},
locationNameLabelAddOnOldName() {
return this.formLocationName !== '' ? ' — ' + this.formLocationName : ''
},
descriptionLength() {
return this.$filters.removeHtml(this.formData.description).length
},
sameLocation() {
const dbLocationName = this.group.locationName || ''
return dbLocationName === this.formLocationName
},
sameCategories() {
if (this.group.categories.length !== this.formData.categoryIds.length) return false
const groupCategories = []
this.group.categories.forEach((categories) => {
groupCategories.push(categories.id)
const some = this.formData.categoryIds.some((item) => item === categories.id)
if (!some) return false
})
return true
},
disableButtonByUpdate() {
if (!this.update) return true
return (
this.group.name === this.formData.name &&
this.group.slug === this.formData.slug &&
this.group.about === this.formData.about &&
this.group.description === this.formData.description &&
this.group.actionRadius === this.formData.actionRadius &&
this.sameLocation &&
this.sameCategories
)
},
},
methods: {
checkFormError(error) {
if (!this.update && error && !!error && this.disableButtonByUpdate) return true
if (this.update && !error && this.disableButtonByUpdate) return true
return false
},
changeGroupType(event) {
this.formData.groupType = event.target.value
},
changeActionRadius(event) {
this.formData.actionRadius = event.target.value
},
updateEditorDescription(value) {
this.$refs.groupForm.update('description', value)
},
submit() {
const { name, about, description, groupType, actionRadius, /* locationName, */ categoryIds } =
this.formData
const variables = {
name,
about,
description,
groupType,
actionRadius,
locationName: this.formLocationName,
categoryIds,
}
this.update
? this.$emit('updateGroup', {
...variables,
id: this.group.id,
})
: this.$emit('createGroup', variables)
},
handleCityInput(event) {
clearTimeout(timeout)
timeout = setTimeout(
() => this.requestGeoData(event.target ? event.target.value.trim() : ''),
500,
)
},
processLocationsResult(places) {
if (!places.length) {
return []
}
const result = []
places.forEach((place) => {
result.push({
label: place.place_name,
value: place.place_name,
id: place.id,
})
})
return result
},
async requestGeoData(value) {
if (value === '') {
this.cities = []
return
}
this.loadingGeo = true
const place = encodeURIComponent(value)
const lang = this.$i18n.locale()
const {
data: { queryLocations: result },
} = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } })
this.cities = this.processLocationsResult(result)
this.loadingGeo = false
return this.cities.find((city) => city.value === value)
},
},
}
</script>
<style lang="scss">
.appearance--auto {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
.select-label {
margin-bottom: 0;
padding-bottom: 4px;
color: #70677e;
font-size: 1rem;
}
.textarea-label {
padding-bottom: 14px;
}
.group-form {
display: flex;
flex-direction: column;
> .ds-form-item {
margin: 0;
}
> .ds-chip {
align-self: flex-end;
margin: $space-xx-small 0 $space-base;
cursor: default;
}
> .select-field {
align-self: flex-end;
}
> .buttons {
align-self: flex-end;
margin-top: $space-base;
}
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<div>
<div>
<ds-space><h3>Link zur Gruppe</h3></ds-space>
<ds-space>
<ds-copy-field>Copy Link for Invite Member please!</ds-copy-field>
</ds-space>
</div>
</div>
</template>
<script>
export default {
name: 'GroupLink',
}
</script>

View File

@ -0,0 +1,33 @@
import { mount } from '@vue/test-utils'
import GroupList from './GroupList.vue'
const localVue = global.localVue
const propsData = {
groups: [],
}
describe('GroupList', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupList, { propsData, mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.findAll('.group-list')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,21 @@
<template>
<div class="group-list">
<ds-space margin-bottom="small" v-for="group in groups" :key="group.id">
<group-teaser :group="group" />
</ds-space>
</div>
</template>
<script>
import GroupTeaser from '~/components/Group/GroupTeaser'
export default {
name: 'GroupList',
components: {
GroupTeaser,
},
props: {
groups: { type: Array, default: () => [] },
},
}
</script>

View File

@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import GroupMember from './GroupMember.vue'
const localVue = global.localVue
const propsData = {
groupId: '',
groupMembers: [],
}
describe('GroupMember', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(GroupMember, { propsData, mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.findAll('.group-member')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,234 @@
<template>
<div class="group-member">
<base-card>
<h2 class="title">{{ $t('group.addUser') }}</h2>
<ds-form v-model="form" @submit="submit">
<ds-flex gutter="small">
<ds-flex-item width="90%">
<ds-input
name="query"
model="query"
:placeholder="$t('group.addUserPlaceholder')"
icon="search"
/>
</ds-flex-item>
<ds-flex-item width="30px">
<!-- <base-button filled circle type="submit" icon="search" :loading="$apollo.loading" /> -->
<base-button filled circle type="submit" icon="search" />
</ds-flex-item>
</ds-flex>
</ds-form>
<div v-if="noSlug">Kein User mit diesem Slug gefunden!</div>
<div v-if="slugUser.length > 0">
<ds-space margin="base" />
<ds-flex>
<ds-flex-item>
<ds-avatar online size="small" :name="slugUser[0].name"></ds-avatar>
</ds-flex-item>
<ds-flex-item>{{ slugUser[0].name }}</ds-flex-item>
<ds-flex-item>{{ slugUser[0].slug }}</ds-flex-item>
<ds-flex-item>
<ds-button size="small" primary @click="addMemberToGroup(slugUser)">
{{ $t('group.addMemberToGroup') }}
</ds-button>
</ds-flex-item>
</ds-flex>
<ds-space margin="base" />
</div>
</base-card>
<ds-table :fields="tableFields" :data="groupMembers" condensed>
<template #avatar="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
}"
>
<ds-avatar online size="small" :name="scope.row.name"></ds-avatar>
</nuxt-link>
</template>
<template #name="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
}"
>
<ds-text>
<b>{{ scope.row.name | truncate(20) }}</b>
</ds-text>
</nuxt-link>
</template>
<template #slug="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
}"
>
<ds-text>
<b>{{ `@${scope.row.slug}` | truncate(20) }}</b>
</ds-text>
</nuxt-link>
</template>
<template #roleInGroup="scope">
<select
v-if="scope.row.myRoleInGroup !== 'owner'"
:options="['pending', 'usual', 'admin', 'owner']"
:value="`${scope.row.myRoleInGroup}`"
@change="changeMemberRole(scope.row.id, $event)"
>
<option v-for="role in ['pending', 'usual', 'admin', 'owner']" :key="role" :value="role">
{{ $t(`group.roles.${role}`) }}
</option>
</select>
<ds-chip v-else color="primary">
{{ $t(`group.roles.${scope.row.myRoleInGroup}`) }}
</ds-chip>
</template>
<template #edit="scope">
<ds-button
v-if="scope.row.myRoleInGroup !== 'owner'"
size="small"
primary
@click="deleteMember(scope.row.id)"
>
<!-- TODO: implement removal of group members -->
<!-- :disabled="scope.row.myRoleInGroup === 'owner'"
-->
{{ $t('group.removeMemberButton') }}
</ds-button>
</template>
</ds-table>
<!-- TODO: implement removal of group members -->
<!-- TODO: change to ocelot.social modal -->
<!-- <ds-modal
v-if="isOpen"
v-model="isOpen"
:title="`${$t('group.removeMember')}`"
force
extended
:confirm-label="$t('group.removeMember')"
:cancel-label="$t('actions.cancel')"
@confirm="deleteMember(memberId)"
/> -->
</div>
</template>
<script>
import { minimisedUserQuery } from '~/graphql/User'
import { changeGroupMemberRoleMutation } from '~/graphql/groups.js'
export default {
name: 'GroupMember',
props: {
groupId: {
type: String,
required: true,
},
groupMembers: {
type: Array,
required: false,
},
},
data() {
return {
isOpen: false,
memberId: null,
noSlug: false,
slugUser: [],
form: {
query: '',
},
}
},
computed: {
tableFields() {
return {
avatar: {
label: this.$t('group.membersAdministrationList.avatar'),
align: 'left',
},
name: {
label: this.$t('group.membersAdministrationList.name'),
align: 'left',
},
slug: {
label: this.$t('group.membersAdministrationList.slug'),
align: 'left',
},
roleInGroup: {
label: this.$t('group.membersAdministrationList.roleInGroup'),
align: 'left',
},
edit: {
label: '',
align: 'left',
},
}
},
},
methods: {
async changeMemberRole(id, event) {
const newRole = event.target.value
try {
await this.$apollo.mutate({
mutation: changeGroupMemberRoleMutation(),
variables: { groupId: this.groupId, userId: id, roleInGroup: newRole },
})
this.$toast.success(
this.$t('group.changeMemberRole', { role: this.$t(`group.roles.${newRole}`) }),
)
} catch (error) {
this.$toast.error(error.message)
}
},
async addMemberToGroup() {
const newRole = 'usual'
try {
await this.$apollo.mutate({
mutation: changeGroupMemberRoleMutation(),
variables: { groupId: this.groupId, userId: this.slugUser[0].id, roleInGroup: newRole },
})
// this.$apollo.queries.GroupMembers.refetch()
this.$emit('loadGroupMembers')
this.slugUser = []
this.form.query = ''
this.$toast.success(
this.$t('group.changeMemberRole', { role: this.$t(`group.roles.${newRole}`) }),
)
} catch (error) {
this.$toast.error(error.message)
}
},
async submit() {
try {
const {
data: { User },
} = await this.$apollo.query({
query: minimisedUserQuery(),
variables: {
slug: this.form.query,
},
})
if (User.length === 0) {
this.noSlug = true
} else {
this.noSlug = false
this.slugUser = User
}
} catch (error) {
this.noSlug = true
} finally {
}
},
// TODO: implement removal of group members
// openModal(row) {
// this.isOpen = true
// this.memberId = row.id
// },
deleteMember(id) {
alert('deleteMember: ' + id)
},
},
}
</script>

View File

@ -0,0 +1,178 @@
<template>
<nuxt-link
class="group-teaser"
:to="{ name: 'group-id-slug', params: { id: group.id, slug: group.slug } }"
>
<base-card
:class="{
'disabled-content': group.disabled,
}"
>
<h2 class="title hyphenate-text">{{ group.name }}</h2>
<div class="slug-location">
<!-- group slug -->
<div>
<ds-text color="soft">
<base-icon name="at" />
{{ group.slug }}
</ds-text>
</div>
<!-- group location -->
<div class="location-item">
<ds-text v-if="group && group.location" color="soft">
<base-icon name="map-marker" />
{{ group && group.location ? group.location.name : '' }}
</ds-text>
</div>
</div>
<!-- TODO: replace editor content with tiptap render view -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="content hyphenate-text" v-html="descriptionExcerpt" />
<footer class="footer">
<div>
<!-- group my role in group -->
<ds-chip color="primary">
{{ group && group.myRole ? $t('group.roles.' + group.myRole) : '' }}
</ds-chip>
<!-- group type -->
<ds-chip color="primary">
{{ group && group.groupType ? $t('group.types.' + group.groupType) : '' }}
</ds-chip>
<!-- group action radius -->
<ds-chip color="primary">
{{ group && group.actionRadius ? $t('group.actionRadii.' + group.actionRadius) : '' }}
</ds-chip>
</div>
<!-- group categories -->
<div class="categories" v-if="categoriesActive">
<category
v-for="category in group.categories"
:key="category.id"
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
}"
:icon="category.icon"
/>
</div>
<div v-else class="categories-placeholder"></div>
<!-- group context menu -->
<client-only>
<group-content-menu :usage="'groupTeaser'" :group="group || {}" placement="bottom-end" />
</client-only>
</footer>
<footer class="footer">
<!-- group goal -->
<div class="labeled-chip">
<ds-text class="label-text hyphenate-text" color="soft" size="small">
{{ $t('group.goal') }}
</ds-text>
<div class="chip">
<ds-chip v-if="group && group.about">{{ group ? group.about : '' }}</ds-chip>
</div>
</div>
</footer>
</base-card>
</nuxt-link>
</template>
<script>
import Category from '~/components/Category'
import GroupContentMenu from '~/components/ContentMenu/GroupContentMenu'
export default {
name: 'GroupTeaser',
components: {
Category,
GroupContentMenu,
},
props: {
group: {
type: Object,
required: true,
},
width: {
type: Object,
default: () => {},
},
},
data() {
return {
categoriesActive: this.$env.CATEGORIES_ACTIVE,
}
},
computed: {
descriptionExcerpt() {
return this.$filters.removeLinks(this.group.descriptionExcerpt)
},
},
}
</script>
<style lang="scss" scoped>
.group-teaser,
.group-teaser:hover,
.group-teaser:active {
position: relative;
display: block;
height: 100%;
color: $text-color-base;
> .ribbon {
position: absolute;
top: 50%;
right: -7px;
}
}
.group-teaser > .base-card {
display: flex;
flex-direction: column;
height: 100%;
> .title {
font-size: 32px;
}
> .slug-location {
display: flex;
margin-bottom: $space-small;
> .location-item {
margin-left: $space-small;
}
}
> .content {
flex-grow: 1;
margin-bottom: $space-small;
}
> .footer {
display: flex;
justify-content: space-between;
align-items: center;
> .categories-placeholder {
flex-grow: 1;
}
> .labeled-chip {
margin-top: $space-xx-small;
> .chip {
margin-top: -$space-small + $space-x-small;
}
}
> .content-menu {
position: relative;
z-index: $z-index-post-teaser-link;
}
}
.user-teaser {
margin-bottom: $space-small;
}
}
</style>

View File

@ -15,13 +15,12 @@
<img :src="post.image | proxyApiUrl" class="image" />
</template>
<client-only>
<user-teaser :user="post.author" :date-time="post.createdAt" />
<user-teaser :user="post.author" :group="post.group" :date-time="post.createdAt" />
</client-only>
<h2 class="title hyphenate-text">{{ post.title }}</h2>
<!-- TODO: replace editor content with tiptap render view -->
<!-- eslint-disable vue/no-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="content hyphenate-text" v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
<footer
class="footer"
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
@ -31,9 +30,8 @@
v-for="category in post.categories"
:key="category.id"
v-tooltip="{
content: $t(`contribution.category.name.${category.slug}`),
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
delay: { show: 1500 },
}"
:icon="category.icon"
/>

View File

@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import Vue from 'vue'
import Upload from '.'
import AvatarUploader from './AvatarUploader'
const localVue = global.localVue
describe('Upload', () => {
describe('AvatarUploader', () => {
let wrapper
const mocks = {
@ -26,21 +26,22 @@ describe('Upload', () => {
}
const propsData = {
user: {
profile: {
avatar: { url: '/api/generic.jpg' },
},
updateMutation: jest.fn(),
}
beforeEach(() => {
jest.useFakeTimers()
wrapper = shallowMount(Upload, { localVue, propsData, mocks })
wrapper = shallowMount(AvatarUploader, { localVue, propsData, mocks })
})
afterEach(() => {
jest.clearAllMocks()
})
it('sends a the UpdateUser mutation when vddrop is called', () => {
it('sends the UpdateUser mutation when vddrop is called', () => {
wrapper.vm.vddrop([{ filename: 'avatar.jpg' }])
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="avatar-uploader">
<vue-dropzone
id="customdropzone"
:key="avatarUrl"
@ -10,8 +10,8 @@
>
<div class="dz-message" @mouseover="hover = true" @mouseleave="hover = false">
<slot></slot>
<div class="hc-attachments-upload-area">
<div class="hc-drag-marker">
<div class="avatar-attachments-upload-area">
<div class="avatar-drag-marker">
<base-icon v-if="hover" name="image" />
</div>
</div>
@ -21,14 +21,15 @@
</template>
<script>
import vueDropzone from 'nuxt-dropzone'
import { updateUserMutation } from '~/graphql/User.js'
export default {
name: 'AvatarUploader',
components: {
vueDropzone,
},
props: {
user: { type: Object, default: null },
profile: { type: Object, required: true },
updateMutation: { type: Function, required: true },
},
data() {
return {
@ -43,7 +44,7 @@ export default {
},
computed: {
avatarUrl() {
const { avatar } = this.user
const { avatar } = this.profile
return avatar && avatar.url
},
},
@ -68,16 +69,16 @@ export default {
const avatarUpload = file[0]
this.$apollo
.mutate({
mutation: updateUserMutation(),
mutation: this.updateMutation(),
variables: {
avatar: {
upload: avatarUpload,
},
id: this.user.id,
id: this.profile.id,
},
})
.then(() => {
this.$toast.success(this.$t('user.avatar.submitted'))
this.$toast.success(this.$t('profile.avatar.submitted'))
})
.catch((error) => this.$toast.error(error.message))
},
@ -115,7 +116,7 @@ export default {
width: 100%;
}
.hc-attachments-upload-area {
.avatar-attachments-upload-area {
position: relative;
display: flex;
align-items: center;
@ -123,11 +124,11 @@ export default {
cursor: pointer;
}
.hc-attachments-upload-button {
.avatar-attachments-upload-button {
pointer-events: none;
}
.hc-drag-marker {
.avatar-drag-marker {
position: relative;
width: 122px;
height: 122px;
@ -165,7 +166,7 @@ export default {
border-radius: 100%;
border: 1px dashed hsl(0, 0%, 25%);
}
.hc-attachments-upload-area:hover & {
.avatar-attachments-upload-area:hover & {
opacity: 1;
}
}

View File

@ -56,6 +56,41 @@ export const user = {
followedBy: [],
socialMedia: [],
}
export const group = {
id: 'g2',
name: 'Yoga Practice',
slug: 'yoga-practice',
about: null,
description: `<h3>What Is yoga?</h3><p>Yoga is not just about practicing asanas. It's about how we do it.</p><p class="">And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.</p><h3>What makes practicing asanas yogic?</h3><p class="">The important thing is:</p><ul><li><p>Use the exercises (consciously) for your personal development.</p></li></ul>`,
descriptionExcerpt: `<h3>What Is yoga?</h3><p>Yoga is not just about practicing asanas. It's about how we do it.</p><p>And practicing asanas doesn't have to be yoga, it can be more athletic than yogic.</p><h3>What makes practicing asanas yogic?</h3><p>The important thing is:</p><ul><li><p>Use the exercises …</p></li></ul>`,
groupType: 'public',
actionRadius: 'interplanetary',
categories: [
{
id: 'cat4',
icon: 'psyche',
name: 'psyche',
slug: 'psyche',
description: 'Seele, Gefühle, Glück',
},
{
id: 'cat5',
icon: 'movement',
name: 'body-and-excercise',
slug: 'body-and-excercise',
description: 'Sport, Yoga, Massage, Tanzen, Entspannung',
},
{
id: 'cat17',
icon: 'spirituality',
name: 'spirituality',
slug: 'spirituality',
description: 'Religion, Werte, Ethik',
},
],
locationName: null,
location: null,
}
storiesOf('UserTeaser', module)
.addDecorator(withA11y)
@ -68,7 +103,7 @@ storiesOf('UserTeaser', module)
}),
template: '<user-teaser :user="user" />',
}))
.add('with Date', () => ({
.add('with date', () => ({
components: { UserTeaser },
store: helpers.store,
data: () => ({
@ -98,3 +133,21 @@ storiesOf('UserTeaser', module)
}),
template: '<user-teaser :user="user" :date-time="new Date()" />',
}))
.add('with group and date', () => ({
components: { UserTeaser },
store: helpers.store,
data: () => ({
user,
group,
}),
template: '<user-teaser :user="user" :group="group" :date-time="new Date()" />',
}))
.add('with group and date wide', () => ({
components: { UserTeaser },
store: helpers.store,
data: () => ({
user,
group,
}),
template: '<user-teaser :user="user" :group="group" wide :date-time="new Date()" />',
}))

View File

@ -1,41 +1,61 @@
<template>
<div class="user-teaser" v-if="displayAnonymous">
<user-avatar v-if="showAvatar" size="small" />
<profile-avatar v-if="showAvatar" size="small" />
<span class="info anonymous">{{ $t('profile.userAnonym') }}</span>
</div>
<div v-else :class="[{ 'disabled-content': user.disabled }]" placement="top-start">
<nuxt-link :to="userLink" :class="['user-teaser']">
<user-avatar v-if="showAvatar" :user="user" size="small" />
<div class="info">
<span class="text">
<span class="slug">{{ userSlug }}</span>
<span v-if="dateTime">{{ userName }}</span>
</span>
<span v-if="dateTime" class="text">
<div :class="['user-teaser']">
<nuxt-link :to="userLink" data-test="avatarUserLink">
<profile-avatar v-if="showAvatar" :profile="user" size="small" />
</nuxt-link>
<div class="info flex-direction-column">
<div :class="wide ? 'flex-direction-row' : 'flex-direction-column'">
<nuxt-link :to="userLink">
<span class="text">
<span class="slug">{{ userSlug }}</span>
<span v-if="!userOnly" class="name">{{ userName }}</span>
</span>
</nuxt-link>
<span v-if="wide">&nbsp;</span>
<span v-if="group">
<span class="text">
{{ $t('group.in') }}
</span>
<nuxt-link :to="groupLink">
<span class="text">
<span class="slug">{{ groupSlug }}</span>
<span v-if="!userOnly" class="name">{{ groupName }}</span>
</span>
</nuxt-link>
</span>
</div>
<span v-if="!userOnly" class="text">
<base-icon name="clock" />
<hc-relative-date-time :date-time="dateTime" />
<relative-date-time :date-time="dateTime" />
<slot name="dateTime"></slot>
</span>
<span v-else class="text">{{ userName }}</span>
</div>
</nuxt-link>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import HcRelativeDateTime from '~/components/RelativeDateTime'
import UserAvatar from '~/components/_new/generic/UserAvatar/UserAvatar'
import RelativeDateTime from '~/components/RelativeDateTime'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
export default {
name: 'UserTeaser',
components: {
HcRelativeDateTime,
UserAvatar,
RelativeDateTime,
ProfileAvatar,
},
props: {
user: { type: Object, default: null },
group: { type: Object, default: null },
wide: { type: Boolean, default: false },
showAvatar: { type: Boolean, default: true },
dateTime: { type: [Date, String], default: null },
showPopover: { type: Boolean, default: true },
@ -64,6 +84,22 @@ export default {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
userOnly() {
return !this.dateTime && !this.group
},
groupLink() {
const { id, slug } = this.group
if (!(id && slug)) return ''
return { name: 'group-id-slug', params: { slug, id } }
},
groupSlug() {
const { slug } = this.group || {}
return slug && `&${slug}`
},
groupName() {
const { name } = this.group || {}
return name || this.$t('profile.userAnonym')
},
},
methods: {
optimisticFollow({ followedByCurrentUser }) {
@ -88,22 +124,16 @@ export default {
display: flex;
flex-wrap: nowrap;
> .user-avatar {
> .profile-avatar {
flex-shrink: 0;
}
> .info {
display: flex;
flex-direction: column;
justify-content: center;
padding-left: $space-xx-small;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-color-soft;
font-size: $font-size-small;
&.anonymous {
font-size: $font-size-base;
}
@ -112,6 +142,23 @@ export default {
color: $color-primary;
font-size: $font-size-base;
}
.name {
color: $text-color-soft;
font-size: $font-size-small;
}
}
.flex-direction-column {
display: flex;
flex-direction: column;
justify-content: center;
}
.flex-direction-row {
display: flex;
flex-direction: row;
justify-content: center;
}
.text {

View File

@ -36,6 +36,7 @@ describe('SearchResults', () => {
}
propsData = {
pageSize: 12,
search: '',
}
wrapper = Wrapper()
})
@ -169,7 +170,7 @@ describe('SearchResults', () => {
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 12 })
).toMatchObject({ query: '', firstPosts: 12, postsOffset: 12 })
})
it('displays the next page button when next-button is clicked', async () => {
@ -199,7 +200,7 @@ describe('SearchResults', () => {
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 24 })
).toMatchObject({ query: '', firstPosts: 12, postsOffset: 24 })
})
it('deactivates next page button when next-button is clicked twice', async () => {
@ -234,7 +235,7 @@ describe('SearchResults', () => {
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 0 })
).toMatchObject({ query: '', firstPosts: 12, postsOffset: 0 })
})
})
})

View File

@ -59,6 +59,14 @@
</base-card>
</ds-grid-item>
</template>
<!-- groups -->
<template v-if="activeTab === 'Group'">
<ds-grid-item v-for="group in activeResources" :key="group.id" :row-span="2">
<base-card :wideContent="true" class="group-teaser-card-wrapper">
<group-teaser :group="{ ...group, name: group.groupName }" />
</base-card>
</ds-grid-item>
</template>
<!-- hashtags -->
<template v-if="activeTab === 'Hashtag'">
<ds-grid-item v-for="hashtag in activeResources" :key="hashtag.id" :row-span="2">
@ -100,17 +108,19 @@
<script>
import postListActions from '~/mixins/postListActions'
import { searchPosts, searchUsers, searchHashtags } from '~/graphql/Search.js'
import { searchPosts, searchUsers, searchGroups, searchHashtags } from '~/graphql/Search.js'
import HcEmpty from '~/components/Empty/Empty'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem'
import PostTeaser from '~/components/PostTeaser/PostTeaser'
import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import GroupTeaser from '~/components/Group/GroupTeaser'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import HcHashtag from '~/components/Hashtag/Hashtag'
export default {
name: 'SearchResults',
components: {
TabNavigation,
HcEmpty,
@ -119,6 +129,7 @@ export default {
PostTeaser,
PaginationButtons,
UserTeaser,
GroupTeaser,
HcHashtag,
},
mixins: [postListActions],
@ -135,24 +146,29 @@ export default {
return {
posts: [],
users: [],
groups: [],
hashtags: [],
postCount: 0,
userCount: 0,
groupCount: 0,
hashtagCount: 0,
postPage: 0,
userPage: 0,
groupPage: 0,
hashtagPage: 0,
activeTab: null,
firstPosts: this.pageSize,
firstUsers: this.pageSize,
firstGroups: this.pageSize,
firstHashtags: this.pageSize,
postsOffset: 0,
usersOffset: 0,
groupsOffset: 0,
hashtagsOffset: 0,
}
},
@ -160,18 +176,21 @@ export default {
activeResources() {
if (this.activeTab === 'Post') return this.posts
if (this.activeTab === 'User') return this.users
if (this.activeTab === 'Group') return this.groups
if (this.activeTab === 'Hashtag') return this.hashtags
return []
},
activeResourceCount() {
if (this.activeTab === 'Post') return this.postCount
if (this.activeTab === 'User') return this.userCount
if (this.activeTab === 'Group') return this.groupCount
if (this.activeTab === 'Hashtag') return this.hashtagCount
return 0
},
activePage() {
if (this.activeTab === 'Post') return this.postPage
if (this.activeTab === 'User') return this.userPage
if (this.activeTab === 'Group') return this.groupPage
if (this.activeTab === 'Hashtag') return this.hashtagPage
return 0
},
@ -189,6 +208,12 @@ export default {
count: this.userCount,
disabled: this.userCount === 0,
},
{
type: 'Group',
title: this.$t('search.heading.Group', {}, this.groupCount),
count: this.groupCount,
disabled: this.groupCount === 0,
},
{
type: 'Hashtag',
title: this.$t('search.heading.Tag', {}, this.hashtagCount),
@ -200,24 +225,27 @@ export default {
hasPrevious() {
if (this.activeTab === 'Post') return this.postsOffset > 0
if (this.activeTab === 'User') return this.usersOffset > 0
if (this.activeTab === 'Group') return this.groupsOffset > 0
if (this.activeTab === 'Hashtag') return this.hashtagsOffset > 0
return false
},
hasNext() {
if (this.activeTab === 'Post') return (this.postPage + 1) * this.pageSize < this.postCount
if (this.activeTab === 'User') return (this.userPage + 1) * this.pageSize < this.userCount
if (this.activeTab === 'Group') return (this.groupPage + 1) * this.pageSize < this.groupCount
if (this.activeTab === 'Hashtag')
return (this.hashtagPage + 1) * this.pageSize < this.hashtagCount
return false
},
searchCount() {
return this.postCount + this.userCount + this.hashtagCount
return this.postCount + this.userCount + this.groupCount + this.hashtagCount
},
},
methods: {
clearPage() {
this.postPage = 0
this.userPage = 0
this.groupPage = 0
this.hashtagPage = 0
},
switchTab(tabType) {
@ -235,6 +263,10 @@ export default {
this.userPage--
this.usersOffset = this.userPage * this.pageSize
break
case 'Group':
this.groupPage--
this.groupsOffset = this.groupPage * this.pageSize
break
case 'Hashtag':
this.hashtagPage--
this.hashtagsOffset = this.hashtagPage * this.pageSize
@ -252,6 +284,10 @@ export default {
this.userPage++
this.usersOffset += this.pageSize
break
case 'Group':
this.groupPage++
this.groupsOffset += this.pageSize
break
case 'Hashtag':
this.hashtagPage++
this.hashtagsOffset += this.pageSize
@ -270,7 +306,7 @@ export default {
variables() {
const { firstHashtags, hashtagsOffset, search } = this
return {
query: search,
query: search.replace(/^([!@#&])/, ''),
firstHashtags,
hashtagsOffset,
}
@ -281,7 +317,12 @@ export default {
update({ searchHashtags }) {
this.hashtags = searchHashtags.hashtags
this.hashtagCount = searchHashtags.hashtagCount
if (this.postCount === 0 && this.userCount === 0 && this.hashtagCount > 0)
if (
this.postCount === 0 &&
this.userCount === 0 &&
this.groupCount === 0 &&
this.hashtagCount > 0
)
this.activeTab = 'Hashtag'
},
fetchPolicy: 'cache-and-network',
@ -293,7 +334,7 @@ export default {
variables() {
const { firstUsers, usersOffset, search } = this
return {
query: search,
query: search.replace(/^([!@#&])/, ''),
firstUsers,
usersOffset,
}
@ -315,7 +356,7 @@ export default {
variables() {
const { firstPosts, postsOffset, search } = this
return {
query: search,
query: search.replace(/^([!@#&])/, ''),
firstPosts,
postsOffset,
}
@ -330,6 +371,29 @@ export default {
},
fetchPolicy: 'cache-and-network',
},
searchGroups: {
query() {
return searchGroups(this.i18n)
},
variables() {
const { firstGroups, groupsOffset, search } = this
return {
query: search.replace(/^([!@#&])/, ''),
firstGroups,
groupsOffset,
}
},
skip() {
return !this.search
},
update({ searchGroups }) {
this.groups = searchGroups.groups
this.groupCount = searchGroups.groupCount
if (this.postCount === 0 && this.userCount === 0 && this.groupCount > 0)
this.activeTab = 'Group'
},
fetchPolicy: 'cache-and-network',
},
},
}
</script>
@ -365,6 +429,10 @@ export default {
opacity: 0.8;
}
}
.group-teaser-card-wrapper {
padding: 0;
}
}
.grid-total-search-results {

View File

@ -1,7 +1,7 @@
<template>
<button
:class="buttonClass"
:disabled="loading"
:disabled="disabled || loading"
:type="type"
@click.capture="(event) => $emit('click', event)"
>
@ -46,7 +46,7 @@ export default {
type: String,
default: 'regular',
validator(value) {
return value.match(/(small|regular)/)
return value.match(/(small|regular|large)/)
},
},
type: {
@ -56,6 +56,10 @@ export default {
return value.match(/(button|submit)/)
},
},
disabled: {
// type: Boolean, // makes some errors that an Object was passed instead a Boolean and could not find how to solve in a acceptable time
default: false,
},
},
computed: {
buttonClass() {
@ -66,6 +70,7 @@ export default {
if (this.danger) buttonClass += ' --danger'
if (this.loading) buttonClass += ' --loading'
if (this.size === 'small') buttonClass += ' --small'
if (this.size === 'large') buttonClass += ' --large'
if (this.filled) buttonClass += ' --filled'
else if (this.ghost) buttonClass += ' --ghost'
@ -123,6 +128,15 @@ export default {
}
}
&.--large {
height: $size-button-large;
font-size: $font-size-large;
&.--circle {
width: $size-button-large;
}
}
&:not(.--icon-only) > .base-icon {
margin-right: $space-xx-small;
}

View File

@ -1,10 +1,10 @@
import { mount } from '@vue/test-utils'
import UserAvatar from './UserAvatar.vue'
import ProfileAvatar from './ProfileAvatar'
import BaseIcon from '~/components/_new/generic/BaseIcon/BaseIcon'
const localVue = global.localVue
describe('UserAvatar.vue', () => {
describe('ProfileAvatar', () => {
let propsData, wrapper
beforeEach(() => {
propsData = {}
@ -12,7 +12,7 @@ describe('UserAvatar.vue', () => {
})
const Wrapper = () => {
return mount(UserAvatar, { propsData, localVue })
return mount(ProfileAvatar, { propsData, localVue })
}
it('renders no image', () => {
@ -23,39 +23,39 @@ describe('UserAvatar.vue', () => {
expect(wrapper.find(BaseIcon).exists()).toBe(true)
})
describe('given a user', () => {
describe('given a profile', () => {
describe('with no image', () => {
beforeEach(() => {
propsData = {
user: {
profile: {
name: 'Matt Rider',
},
}
wrapper = Wrapper()
})
describe('no user name', () => {
describe('no profile name', () => {
it('renders an icon', () => {
propsData = { user: { name: null } }
propsData = { profile: { name: null } }
wrapper = Wrapper()
expect(wrapper.find(BaseIcon).exists()).toBe(true)
})
})
describe("user name is 'Anonymous'", () => {
describe("profile name is 'Anonymous'", () => {
it('renders an icon', () => {
propsData = { user: { name: 'Anonymous' } }
propsData = { profile: { name: 'Anonymous' } }
wrapper = Wrapper()
expect(wrapper.find(BaseIcon).exists()).toBe(true)
})
})
it('displays user initials', () => {
it('displays profile initials', () => {
expect(wrapper.find('.initials').text()).toEqual('MR')
})
it('displays no more than 3 initials', () => {
propsData = { user: { name: 'Ana Paula Nunes Marques' } }
propsData = { profile: { name: 'Ana Paula Nunes Marques' } }
wrapper = Wrapper()
expect(wrapper.find('.initials').text()).toEqual('APN')
})
@ -64,7 +64,7 @@ describe('UserAvatar.vue', () => {
describe('with a relative avatar url', () => {
beforeEach(() => {
propsData = {
user: {
profile: {
name: 'Not Anonymous',
avatar: {
url: '/avatar.jpg',
@ -82,7 +82,7 @@ describe('UserAvatar.vue', () => {
describe('with an absolute avatar url', () => {
beforeEach(() => {
propsData = {
user: {
profile: {
name: 'Not Anonymous',
avatar: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',

View File

@ -1,7 +1,7 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import StoryRouter from 'storybook-vue-router'
import UserAvatar from '~/components/_new/generic/UserAvatar/UserAvatar'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
import helpers from '~/storybook/helpers'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'
import imageFile from './storybook/critical-avatar-white-background.png'
@ -22,56 +22,56 @@ const userWithAvatar = {
name: 'Jochen Image',
avatar: { url: imageFile },
}
storiesOf('UserAvatar', module)
storiesOf('ProfileAvatar', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.addDecorator(StoryRouter())
.add('normal, with image', () => ({
components: { UserAvatar },
components: { ProfileAvatar },
data: () => ({
user: userWithAvatar,
}),
template: '<user-avatar :user="user" />',
template: '<profile-avatar :profile="user" />',
}))
.add('normal without image, anonymous user', () => ({
components: { UserAvatar },
components: { ProfileAvatar },
data: () => ({
user: anonymousUser,
}),
template: '<user-avatar :user="user" />',
template: '<profile-avatar :profile="user" />',
}))
.add('normal without image, user initials', () => ({
components: { UserAvatar },
components: { ProfileAvatar },
data: () => ({
user: userWithoutAvatar,
}),
template: '<user-avatar :user="user" />',
template: '<profile-avatar :profile="user" />',
}))
.add('small, with image', () => ({
components: { UserAvatar },
components: { ProfileAvatar },
data: () => ({
user: userWithAvatar,
}),
template: '<user-avatar :user="user" size="small"/>',
template: '<profile-avatar :profile="user" size="small"/>',
}))
.add('small', () => ({
components: { UserAvatar },
components: { ProfileAvatar },
data: () => ({
user: userWithoutAvatar,
}),
template: '<user-avatar :user="user" size="small"/>',
template: '<profile-avatar :profile="user" size="small"/>',
}))
.add('large, with image', () => ({
components: { UserAvatar },
components: { ProfileAvatar },
data: () => ({
user: userWithAvatar,
}),
template: '<user-avatar :user="user" size="large"/>',
template: '<profile-avatar :profile="user" size="large"/>',
}))
.add('large', () => ({
components: { UserAvatar },
components: { ProfileAvatar },
data: () => ({
user: userWithoutAvatar,
}),
template: '<user-avatar :user="user" size="large"/>',
template: '<profile-avatar :profile="user" size="large"/>',
}))

View File

@ -1,14 +1,14 @@
<template>
<div :class="['user-avatar', size && `--${this.size}`, !isAvatar && '--no-image']">
<div :class="['profile-avatar', size && `--${this.size}`, !isAvatar && '--no-image']">
<!-- '--no-image' is neccessary, because otherwise we still have a little unwanted boarder araund the image for images with white backgrounds -->
<span class="initials">{{ userInitials }}</span>
<span class="initials">{{ profileInitials }}</span>
<base-icon v-if="isAnonymous" name="eye-slash" />
<img
v-if="isAvatar"
:src="user.avatar | proxyApiUrl"
:src="profile.avatar | proxyApiUrl"
class="image"
:alt="user.name"
:title="user.name"
:alt="profile.name"
:title="profile.name"
@error="$event.target.style.display = 'none'"
/>
</div>
@ -16,7 +16,7 @@
<script>
export default {
name: 'UserAvatar',
name: 'ProfileAvatar',
props: {
size: {
type: String,
@ -25,30 +25,30 @@ export default {
return value.match(/(small|large)/)
},
},
user: {
profile: {
type: Object,
default: null,
},
},
computed: {
isAnonymous() {
return !this.user || !this.user.name || this.user.name.toLowerCase() === 'anonymous'
return !this.profile || !this.profile.name || this.profile.name.toLowerCase() === 'anonymous'
},
isAvatar() {
// TODO may we could test as well if the image is reachable? otherwise the background gets white and the initails can not be read
return this.user && this.user.avatar
return this.profile && this.profile.avatar
},
userInitials() {
profileInitials() {
if (this.isAnonymous) return ''
return this.user.name.match(/\b\w/g).join('').substring(0, 3).toUpperCase()
return this.profile.name.match(/\b\w/g).join('').substring(0, 3).toUpperCase()
},
},
}
</script>
<style lang="scss">
.user-avatar {
.profile-avatar {
position: relative;
height: $size-avatar-base;
width: $size-avatar-base;

View File

@ -0,0 +1,41 @@
<template>
<profile-list
:uniqueName="`${type}Filter`"
:title="$filters.truncate(userName, 15) + ' ' + $t(`profile.network.${type}`)"
:titleNobody="$filters.truncate(userName, 15) + ' ' + $t(`profile.network.${type}Nobody`)"
:allProfilesCount="allConnectionsCount"
:profiles="connections"
:loading="loading"
@fetchAllProfiles="$emit('fetchAllConnections', type)"
/>
</template>
<script>
import ProfileList, { profileListVisibleCount } from '~/components/features/ProfileList/ProfileList'
export const followListVisibleCount = profileListVisibleCount
export default {
name: 'FollowerList',
components: {
ProfileList,
},
props: {
user: { type: Object, default: null },
type: { type: String, default: 'following' },
loading: { type: Boolean, default: false },
},
computed: {
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
allConnectionsCount() {
return this.user[`${this.type}Count`]
},
connections() {
return this.user[this.type]
},
},
}
</script>

View File

@ -1,10 +1,10 @@
<template>
<base-card class="follow-list">
<template v-if="connections && connections.length">
<base-card class="profile-list">
<template v-if="profiles.length">
<h5 class="title spacer-x-small">
{{ userName | truncate(15) }} {{ $t(`profile.network.${type}`) }}
{{ title }}
</h5>
<ul :class="connectionsClass">
<ul :class="profilesClass">
<li
v-for="connection in filteredConnections"
:key="connection.id"
@ -13,30 +13,30 @@
<user-teaser :user="connection" />
</li>
</ul>
<base-button
v-if="hasMore"
:loading="loading"
class="spacer-x-small"
size="small"
@click="$emit('fetchAllConnections', type)"
>
{{
$t('profile.network.andMore', {
number: allConnectionsCount - connections.length,
})
}}
</base-button>
<ds-input
v-if="!hasMore"
:name="`${type}Filter`"
v-if="isMoreAsVisible"
:name="uniqueName"
:placeholder="filter"
class="spacer-x-small"
icon="filter"
size="small"
@input.native="setFilter"
/>
<base-button
v-if="hasMore"
:loading="loading"
class="spacer-x-small"
size="small"
@click="$emit('fetchAllProfiles')"
>
{{
$t('profile.network.andMore', {
number: allProfilesCount - profiles.length,
})
}}
</base-button>
</template>
<p v-else class="nobody-message">{{ userName }} {{ $t(`profile.network.${type}Nobody`) }}</p>
<p v-else-if="titleNobody" class="nobody-message">{{ titleNobody }}</p>
</base-card>
</template>
@ -44,41 +44,40 @@
import { escape } from 'xregexp/xregexp-all.js'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
export const profileListVisibleCount = 7
export default {
name: 'FollowerList',
name: 'ProfileList',
components: {
UserTeaser,
},
props: {
user: { type: Object, default: null },
type: { type: String, default: 'following' },
uniqueName: { type: String, required: true },
title: { type: String, required: true },
titleNobody: { type: String, default: null },
allProfilesCount: { type: Number, required: true },
profiles: { type: Array, required: true },
loading: { type: Boolean, default: false },
},
data() {
return {
profileListVisibleCount,
filter: null,
}
},
computed: {
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
allConnectionsCount() {
return this.user[`${this.type}Count`]
},
connections() {
return this.user[this.type]
},
hasMore() {
return this.allConnectionsCount > this.connections.length
return this.allProfilesCount > this.profiles.length
},
connectionsClass() {
return `connections${this.hasMore ? '' : ' --overflow'}`
isMoreAsVisible() {
return this.profiles.length > this.profileListVisibleCount
},
profilesClass() {
return `profiles${this.isMoreAsVisible ? ' --overflow' : ''}`
},
filteredConnections() {
if (!this.filter) {
return this.connections
return this.profiles
}
// @example
@ -89,7 +88,7 @@ export default {
'i',
)
const fuzzyScores = this.connections
const fuzzyScores = this.profiles
.map((user) => {
const match = user.name.match(fuzzyExpression)
@ -122,7 +121,7 @@ export default {
</script>
<style lang="scss">
.follow-list {
.profile-list {
display: flex;
flex-direction: column;
position: relative;
@ -133,7 +132,7 @@ export default {
font-size: $font-size-base;
}
.connections {
.profiles {
height: $size-height-connections;
padding: $space-none;
list-style-type: none;

View File

@ -0,0 +1,38 @@
<template>
<section class="search-group">
<p class="label">{{ option.groupName | truncate(70) }}</p>
<div class="metadata"></div>
</section>
</template>
<script>
export default {
name: 'SearchGroup',
props: {
option: { type: Object, required: true },
},
}
</script>
<style lang="scss">
.search-group {
display: flex;
> .label {
flex-grow: 1;
padding: 0 $space-x-small;
}
> .metadata {
display: flex;
flex-direction: column;
align-items: flex-end;
color: $text-color-softer;
font-size: $font-size-small;
> .counts > .counter-icon {
margin: 0 $space-x-small;
}
}
}
</style>

View File

@ -35,6 +35,12 @@
>
<search-post :option="option" />
</p>
<p
v-if="option.__typename === 'Group'"
:class="{ 'option-with-heading': isFirstOfType(option) }"
>
<search-group :option="option" />
</p>
<p
v-if="option.__typename === 'Tag'"
:class="{ 'option-with-heading': isFirstOfType(option) }"
@ -51,6 +57,7 @@
import { isEmpty } from 'lodash'
import SearchHeading from '~/components/generic/SearchHeading/SearchHeading.vue'
import SearchPost from '~/components/generic/SearchPost/SearchPost.vue'
import SearchGroup from '~/components/generic/SearchGroup/SearchGroup.vue'
import HcHashtag from '~/components/Hashtag/Hashtag.vue'
import UserTeaser from '~/components/UserTeaser/UserTeaser.vue'
@ -58,6 +65,7 @@ export default {
name: 'SearchableInput',
components: {
SearchHeading,
SearchGroup,
SearchPost,
HcHashtag,
UserTeaser,
@ -140,8 +148,17 @@ export default {
this.searchValue = this.previousSearchTerm
})
},
isPost(item) {
return item.__typename === 'Post'
getRouteName(item) {
switch (item.__typename) {
case 'Post':
return 'post-id-slug'
case 'User':
return 'profile-id-slug'
case 'Group':
return 'group-id-slug'
default:
return null
}
},
isTag(item) {
return item.__typename === 'Tag'
@ -150,7 +167,7 @@ export default {
this.$nextTick(() => {
if (!this.isTag(item)) {
this.$router.push({
name: this.isPost(item) ? 'post-id-slug' : 'profile-id-slug',
name: this.getRouteName(item),
params: { id: item.id, slug: item.slug },
})
} else {

Some files were not shown because too many files have changed in this diff Show More