fix conflict

This commit is contained in:
senderfm 2019-07-03 10:06:48 +02:00
commit 0c1403dda5
47 changed files with 1303 additions and 400 deletions

View File

@ -1,4 +1,4 @@
FROM node:12.4-alpine as base
FROM node:12.5-alpine as base
LABEL Description="Backend of the Social Network Human-Connection.org" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)"
EXPOSE 4000

View File

@ -61,7 +61,7 @@
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.2",
"graphql-shield": "~5.7.1",
"graphql-shield": "~6.0.2",
"graphql-tag": "~2.10.1",
"graphql-yoga": "~1.18.0",
"helmet": "~3.18.0",

View File

@ -87,6 +87,9 @@ const createOrUpdateLocations = async (userId, locationName, driver) => {
}
const session = driver.session()
if (data.place_type.length > 1) {
data.id = 'region.' + data.id.split('.')[1]
}
await createLocation(session, data)
let parent = data

View File

@ -88,21 +88,26 @@ describe('currentUser { notifications }', () => {
describe('who mentions me again', () => {
beforeEach(async () => {
const updatedContent = `${post.content} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
const updatedTitle = 'this post has been updated'
// The response `post.content` contains a link but the XSSmiddleware
// should have the `mention` CSS class removed. I discovered this
// during development and thought: A feature not a bug! This way we
// can encode a re-mentioning of users when you edit your post or
// comment.
const createPostMutation = `
mutation($id: ID!, $content: String!) {
UpdatePost(id: $id, content: $content) {
const updatePostMutation = `
mutation($id: ID!, $title: String!, $content: String!) {
UpdatePost(id: $id, title: $title, content: $content) {
title
content
}
}
`
authorClient = new GraphQLClient(host, { headers: authorHeaders })
await authorClient.request(createPostMutation, { id: post.id, content: updatedContent })
await authorClient.request(updatePostMutation, {
id: post.id,
content: updatedContent,
title: updatedTitle,
})
})
it('creates exactly one more notification', async () => {

View File

@ -4,7 +4,9 @@ import { rule, shield, deny, allow, or } from 'graphql-shield'
* TODO: implement
* See: https://github.com/Human-Connection/Nitro-Backend/pull/40#pullrequestreview-180898363
*/
const isAuthenticated = rule()(async (parent, args, ctx, info) => {
const isAuthenticated = rule({
cache: 'contextual',
})(async (_parent, _args, ctx, _info) => {
return ctx.user !== null
})
@ -105,7 +107,7 @@ const permissions = shield(
Query: {
'*': deny,
findPosts: allow,
Category: isAdmin,
Category: allow,
Tag: isAdmin,
Report: isModerator,
Notification: isAdmin,

View File

@ -17,6 +17,10 @@ export default {
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
},
UpdatePost: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
},
CreateUser: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User')))
return resolve(root, args, context, info)

View File

@ -18,9 +18,6 @@ export default {
if (!params.content || content.length < COMMENT_MIN_LENGTH) {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
}
if (!postId.trim()) {
throw new UserInputError(NO_POST_ERR_MESSAGE)
}
const session = context.driver.session()
const postQueryRes = await session.run(

View File

@ -23,7 +23,7 @@ afterEach(async () => {
describe('CreateComment', () => {
const createCommentMutation = gql`
mutation($postId: ID, $content: String!) {
mutation($postId: ID!, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
@ -37,13 +37,6 @@ describe('CreateComment', () => {
}
}
`
const commentQueryForPostId = gql`
query($content: String) {
Comment(content: $content) {
postId
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
createCommentVariables = {
@ -191,23 +184,6 @@ describe('CreateComment', () => {
client.request(createCommentMutation, createCommentVariablesWithNonExistentPost),
).rejects.toThrow('Comment cannot be created without a post!')
})
it('does not create the comment with the postId as an attribute', async () => {
const commentQueryVariablesByContent = {
content: "I'm authorised to comment",
}
await client.request(createCommentMutation, createCommentVariables)
const { Comment } = await client.request(
commentQueryForPostId,
commentQueryVariablesByContent,
)
expect(Comment).toEqual([
{
postId: null,
},
])
})
})
})

View File

@ -12,7 +12,6 @@ const storeUpload = ({ createReadStream, fileLocation }) =>
export default async function fileUpload(params, { file, url }, uploadCallback = storeUpload) {
const upload = params[file]
if (upload) {
const { createReadStream, filename } = await upload
const { name } = path.parse(filename)

View File

@ -1,30 +1,74 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import uuid from 'uuid/v4'
import fileUpload from './fileUpload'
export default {
Mutation: {
UpdatePost: async (object, params, context, resolveInfo) => {
const { categoryIds } = params
delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
return neo4jgraphql(object, params, context, resolveInfo, false)
const session = context.driver.session()
const cypherDeletePreviousRelations = `
MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations
RETURN post, category
`
await session.run(cypherDeletePreviousRelations, { params })
let updatePostCypher = `MATCH (post:Post {id: $params.id})
SET post = $params
`
if (categoryIds && categoryIds.length) {
updatePostCypher += `WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
`
}
updatePostCypher += `RETURN post`
const updatePostVariables = { categoryIds, params }
const transactionRes = await session.run(updatePostCypher, updatePostVariables)
const [post] = transactionRes.records.map(record => {
return record.get('post')
})
session.close()
return post.properties
},
CreatePost: async (object, params, context, resolveInfo) => {
const { categoryIds } = params
delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
const result = await neo4jgraphql(object, params, context, resolveInfo, false)
params.id = params.id || uuid()
let createPostCypher = `CREATE (post:Post {params})
WITH post
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
`
if (categoryIds) {
createPostCypher += `WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
`
}
createPostCypher += `RETURN post`
const createPostVariables = { userId: context.user.id, categoryIds, params }
const session = context.driver.session()
await session.run(
'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' +
'MERGE (post)<-[:WROTE]-(author) ' +
'RETURN author',
{
userId: context.user.id,
postId: result.id,
},
)
const transactionRes = await session.run(createPostCypher, createPostVariables)
const [post] = transactionRes.records.map(record => {
return record.get('post')
})
session.close()
return result
return post.properties
},
},
}

View File

@ -4,7 +4,34 @@ import { host, login } from '../../jest/helpers'
const factory = Factory()
let client
const postTitle = 'I am a title'
const postContent = 'Some content'
const oldTitle = 'Old title'
const oldContent = 'Old content'
const newTitle = 'New title'
const newContent = 'New content'
const createPostVariables = { title: postTitle, content: postContent }
const createPostWithCategoriesMutation = `
mutation($title: String!, $content: String!, $categoryIds: [ID]) {
CreatePost(title: $title, content: $content, categoryIds: $categoryIds) {
id
}
}
`
const creatPostWithCategoriesVariables = {
title: postTitle,
content: postContent,
categoryIds: ['cat9', 'cat4', 'cat15'],
}
const postQueryWithCategories = `
query($id: ID) {
Post(id: $id) {
categories {
id
}
}
}
`
beforeEach(async () => {
await factory.create('User', {
email: 'test@example.org',
@ -18,8 +45,8 @@ afterEach(async () => {
describe('CreatePost', () => {
const mutation = `
mutation {
CreatePost(title: "I am a title", content: "Some content") {
mutation($title: String!, $content: String!) {
CreatePost(title: $title, content: $content) {
title
content
slug
@ -32,7 +59,7 @@ describe('CreatePost', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(mutation)).rejects.toThrow('Not Authorised')
await expect(client.request(mutation, createPostVariables)).rejects.toThrow('Not Authorised')
})
})
@ -46,15 +73,15 @@ describe('CreatePost', () => {
it('creates a post', async () => {
const expected = {
CreatePost: {
title: 'I am a title',
content: 'Some content',
title: postTitle,
content: postContent,
},
}
await expect(client.request(mutation)).resolves.toMatchObject(expected)
await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected)
})
it('assigns the authenticated user as author', async () => {
await client.request(mutation)
await client.request(mutation, createPostVariables)
const { User } = await client.request(
`{
User(email:"test@example.org") {
@ -65,49 +92,75 @@ describe('CreatePost', () => {
}`,
{ headers },
)
expect(User).toEqual([{ contributions: [{ title: 'I am a title' }] }])
expect(User).toEqual([{ contributions: [{ title: postTitle }] }])
})
describe('disabled and deleted', () => {
it('initially false', async () => {
const expected = { CreatePost: { disabled: false, deleted: false } }
await expect(client.request(mutation)).resolves.toMatchObject(expected)
await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected)
})
})
describe('language', () => {
it('allows a user to set the language of the post', async () => {
const createPostWithLanguageMutation = `
mutation {
CreatePost(title: "I am a title", content: "Some content", language: "en") {
mutation($title: String!, $content: String!, $language: String) {
CreatePost(title: $title, content: $content, language: $language) {
language
}
}
`
const createPostWithLanguageVariables = {
title: postTitle,
content: postContent,
language: 'en',
}
const expected = { CreatePost: { language: 'en' } }
await expect(client.request(createPostWithLanguageMutation)).resolves.toEqual(
expect.objectContaining(expected),
await expect(
client.request(createPostWithLanguageMutation, createPostWithLanguageVariables),
).resolves.toEqual(expect.objectContaining(expected))
})
})
describe('categories', () => {
it('allows a user to set the categories of the post', async () => {
await Promise.all([
factory.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
}),
factory.create('Category', {
id: 'cat4',
name: 'Environment & Nature',
icon: 'tree',
}),
factory.create('Category', {
id: 'cat15',
name: 'Consumption & Sustainability',
icon: 'shopping-cart',
}),
])
const expected = [{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]
const postWithCategories = await client.request(
createPostWithCategoriesMutation,
creatPostWithCategoriesVariables,
)
const postQueryWithCategoriesVariables = {
id: postWithCategories.CreatePost.id,
}
await expect(
client.request(postQueryWithCategories, postQueryWithCategoriesVariables),
).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] })
})
})
})
})
describe('UpdatePost', () => {
const mutation = `
mutation($id: ID!, $content: String) {
UpdatePost(id: $id, content: $content) {
id
content
}
}
`
let variables = {
id: 'p1',
content: 'New content',
}
let updatePostMutation
let updatePostVariables
beforeEach(async () => {
const asAuthor = Factory()
await asAuthor.create('User', {
@ -120,14 +173,32 @@ describe('UpdatePost', () => {
})
await asAuthor.create('Post', {
id: 'p1',
content: 'Old content',
title: oldTitle,
content: oldContent,
})
updatePostMutation = `
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) {
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
id
content
}
}
`
updatePostVariables = {
id: 'p1',
title: newTitle,
content: newContent,
categoryIds: null,
}
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
@ -139,7 +210,9 @@ describe('UpdatePost', () => {
})
it('throws authorization error', async () => {
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
@ -151,8 +224,59 @@ describe('UpdatePost', () => {
})
it('updates a post', async () => {
const expected = { UpdatePost: { id: 'p1', content: 'New content' } }
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
const expected = { UpdatePost: { id: 'p1', content: newContent } }
await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual(
expected,
)
})
describe('categories', () => {
let postWithCategories
beforeEach(async () => {
await Promise.all([
factory.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
}),
factory.create('Category', {
id: 'cat4',
name: 'Environment & Nature',
icon: 'tree',
}),
factory.create('Category', {
id: 'cat15',
name: 'Consumption & Sustainability',
icon: 'shopping-cart',
}),
factory.create('Category', {
id: 'cat27',
name: 'Animal Protection',
icon: 'paw',
}),
])
postWithCategories = await client.request(
createPostWithCategoriesMutation,
creatPostWithCategoriesVariables,
)
updatePostVariables = {
id: postWithCategories.CreatePost.id,
title: newTitle,
content: newContent,
categoryIds: ['cat27'],
}
})
it('allows a user to update the categories of a post', async () => {
await client.request(updatePostMutation, updatePostVariables)
const expected = [{ id: 'cat27' }]
const postQueryWithCategoriesVariables = {
id: postWithCategories.CreatePost.id,
}
await expect(
client.request(postQueryWithCategories, postQueryWithCategoriesVariables),
).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] })
})
})
})
})

View File

@ -11,12 +11,31 @@ export default {
description: description,
}
const reportQueryRes = await session.run(
`
match (u:User {id:$submitterId}) -[:REPORTED]->(report)-[:REPORTED]-> (resource {id: $resourceId})
return labels(resource)[0] as label
`,
{
resourceId: id,
submitterId: user.id,
},
)
const [rep] = reportQueryRes.records.map(record => {
return {
label: record.get('label'),
}
})
if (rep) {
throw new Error(rep.label)
}
const res = await session.run(
`
MATCH (submitter:User {id: $userId})
MATCH (resource {id: $resourceId})
WHERE resource:User OR resource:Comment OR resource:Post
CREATE (report:Report $reportData)
MERGE (report:Report {id: {reportData}.id })
MERGE (resource)<-[:REPORTED]-(report)
MERGE (report)<-[:REPORTED]-(submitter)
RETURN report, submitter, resource, labels(resource)[0] as type
@ -27,6 +46,7 @@ export default {
reportData,
},
)
session.close()
const [dbResponse] = res.records.map(r => {
@ -59,6 +79,7 @@ export default {
response.user = resource.properties
break
}
return response
},
},

View File

@ -13,7 +13,9 @@ describe('report', () => {
beforeEach(async () => {
returnedObject = '{ description }'
variables = { id: 'whatever' }
variables = {
id: 'whatever',
}
headers = {}
await factory.create('User', {
id: 'u1',
@ -42,7 +44,9 @@ describe('report', () => {
) ${returnedObject}
}
`
client = new GraphQLClient(host, { headers })
client = new GraphQLClient(host, {
headers,
})
return client.request(mutation, variables)
}
@ -53,7 +57,10 @@ describe('report', () => {
describe('authenticated', () => {
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
headers = await login({
email: 'test@example.org',
password: '1234',
})
})
describe('invalid resource id', () => {
@ -66,19 +73,25 @@ describe('report', () => {
describe('valid resource id', () => {
beforeEach(async () => {
variables = { id: 'u2' }
variables = {
id: 'u2',
}
})
it('creates a report', async () => {
await expect(action()).resolves.toEqual({
report: { description: 'Violates code of conduct' },
})
})
/*
it('creates a report', async () => {
await expect(action()).resolves.toEqual({
type: null,
})
})
*/
it('returns the submitter', async () => {
returnedObject = '{ submitter { email } }'
await expect(action()).resolves.toEqual({
report: { submitter: { email: 'test@example.org' } },
report: {
submitter: {
email: 'test@example.org',
},
},
})
})
@ -86,50 +99,72 @@ describe('report', () => {
it('returns type "User"', async () => {
returnedObject = '{ type }'
await expect(action()).resolves.toEqual({
report: { type: 'User' },
report: {
type: 'User',
},
})
})
it('returns resource in user attribute', async () => {
returnedObject = '{ user { name } }'
await expect(action()).resolves.toEqual({
report: { user: { name: 'abusive-user' } },
report: {
user: {
name: 'abusive-user',
},
},
})
})
})
describe('reported resource is a post', () => {
beforeEach(async () => {
await factory.authenticateAs({ email: 'test@example.org', password: '1234' })
await factory.authenticateAs({
email: 'test@example.org',
password: '1234',
})
await factory.create('Post', {
id: 'p23',
title: 'Matt and Robert having a pair-programming',
})
variables = { id: 'p23' }
variables = {
id: 'p23',
}
})
it('returns type "Post"', async () => {
returnedObject = '{ type }'
await expect(action()).resolves.toEqual({
report: { type: 'Post' },
report: {
type: 'Post',
},
})
})
it('returns resource in post attribute', async () => {
returnedObject = '{ post { title } }'
await expect(action()).resolves.toEqual({
report: { post: { title: 'Matt and Robert having a pair-programming' } },
report: {
post: {
title: 'Matt and Robert having a pair-programming',
},
},
})
})
it('returns null in user attribute', async () => {
returnedObject = '{ user { name } }'
await expect(action()).resolves.toEqual({
report: { user: null },
report: {
user: null,
},
})
})
})
/* An der Stelle würde ich den p23 noch mal prüfen, diesmal muss aber eine error meldung kommen.
At this point I would check the p23 again, but this time there must be an error message. */
describe('reported resource is a comment', () => {
beforeEach(async () => {
createPostVariables = {
@ -147,34 +182,54 @@ describe('report', () => {
id: 'c34',
content: 'Robert getting tired.',
})
variables = { id: 'c34' }
variables = {
id: 'c34',
}
})
it('returns type "Comment"', async () => {
returnedObject = '{ type }'
await expect(action()).resolves.toEqual({
report: { type: 'Comment' },
report: {
type: 'Comment',
},
})
})
it('returns resource in comment attribute', async () => {
returnedObject = '{ comment { content } }'
await expect(action()).resolves.toEqual({
report: { comment: { content: 'Robert getting tired.' } },
report: {
comment: {
content: 'Robert getting tired.',
},
},
})
})
})
/* An der Stelle würde ich den c34 noch mal prüfen, diesmal muss aber eine error meldung kommen.
At this point I would check the c34 again, but this time there must be an error message. */
describe('reported resource is a tag', () => {
beforeEach(async () => {
await factory.create('Tag', { id: 't23' })
variables = { id: 't23' }
await factory.create('Tag', {
id: 't23',
})
variables = {
id: 't23',
}
})
it('returns null', async () => {
await expect(action()).resolves.toEqual({ report: null })
await expect(action()).resolves.toEqual({
report: null,
})
})
})
/* An der Stelle würde ich den t23 noch mal prüfen, diesmal muss aber eine error meldung kommen.
At this point I would check the t23 again, but this time there must be an error message. */
})
})
})

View File

@ -143,7 +143,7 @@ describe('users', () => {
let deleteUserVariables
let asAuthor
const deleteUserMutation = gql`
mutation($id: ID!, $resource: [String]) {
mutation($id: ID!, $resource: [Deletable]) {
DeleteUser(id: $id, resource: $resource) {
id
contributions {

View File

@ -1 +1 @@
scalar Upload
scalar Upload

View File

@ -40,7 +40,7 @@ type Mutation {
follow(id: ID!, type: FollowTypeEnum): Boolean!
# Unfollow the given Type and ID
unfollow(id: ID!, type: FollowTypeEnum): Boolean!
DeleteUser(id: ID!, resource: [String]): User
DeleteUser(id: ID!, resource: [Deletable]): User
}
type Statistics {
@ -92,6 +92,11 @@ type Report {
user: User @relation(name: "REPORTED", direction: "OUT")
}
enum Deletable {
Post
Comment
}
enum ShoutTypeEnum {
Post
Organization

View File

@ -1,7 +1,6 @@
type Comment {
id: ID!
activityId: String
postId: ID
author: User @relation(name: "WROTE", direction: "IN")
content: String!
contentExcerpt: String
@ -11,4 +10,24 @@ type Comment {
deleted: Boolean
disabled: Boolean
disabledBy: User @relation(name: "DISABLED", direction: "IN")
}
}
type Mutation {
CreateComment(
id: ID
postId: ID!
content: String!
contentExcerpt: String
deleted: Boolean
disabled: Boolean
createdAt: String
): Comment
UpdateComment(
id: ID!
content: String
contentExcerpt: String
deleted: Boolean
disabled: Boolean
): Comment
DeleteComment(id: ID!): Comment
}

View File

@ -49,3 +49,42 @@ type Post {
"""
)
}
type Mutation {
CreatePost(
id: ID
activityId: String
objectId: String
title: String!
slug: String
content: String!
image: String
imageUpload: Upload
visibility: Visibility
deleted: Boolean
disabled: Boolean
createdAt: String
updatedAt: String
language: String
categoryIds: [ID]
contentExcerpt: String
): Post
UpdatePost(
id: ID!
activityId: String
objectId: String
title: String!
slug: String
content: String!
contentExcerpt: String
image: String
imageUpload: Upload
visibility: Visibility
deleted: Boolean
disabled: Boolean
createdAt: String
updatedAt: String
language: String
categoryIds: [ID]
): Post
}

View File

@ -10,7 +10,7 @@ export default function(params) {
return {
mutation: `
mutation($id: ID!, $postId: ID, $content: String!) {
mutation($id: ID!, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}

View File

@ -1110,10 +1110,10 @@
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.9.tgz#693e76a52f61a2f1e7fb48c0eef167b95ea4ffd0"
integrity sha512-sCZy4SxP9rN2w30Hlmg5dtdRwgYQfYRiLo9usw8X9cxlf+H4FqM1xX7+sNH7NNKVdbXMJWqva7iyy+fxh/V7fA==
"@types/yup@0.26.17":
version "0.26.17"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.17.tgz#5cb7cfc211d8e985b21d88289542591c92cad9dc"
integrity sha512-MN7VHlPsZQ2MTBxLE2Gl+Qfg2WyKsoz+vIr8xN0OSZ4AvJDrrKBlxc8b59UXCCIG9tPn9XhxTXh3j/htHbzC2Q==
"@types/yup@0.26.20":
version "0.26.20"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.20.tgz#3b85a05f5dd76e2e8475abb6a8aeae7777627143"
integrity sha512-LpCsA6NG7vIU7Umv1k4w3YGIBH5ZLZRPEKo8vJLHVbBUqRy2WaJ002kbsRqcwODpkICAOMuyGOqLQJa5isZ8+g==
"@types/zen-observable@^0.5.3":
version "0.5.4"
@ -3788,12 +3788,12 @@ graphql-request@~1.8.2:
dependencies:
cross-fetch "2.2.2"
graphql-shield@~5.7.1:
version "5.7.1"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.7.1.tgz#04095fb8148a463997f7c509d4aeb2a6abf79f98"
integrity sha512-UZ0K1uAqRAoGA1U2DsUu4vIZX2Vents4Xim99GFEUBTgvSDkejiE+k/Dywqfu76lJFEE8qu3vG5fhJN3SmnKbA==
graphql-shield@~6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-6.0.2.tgz#3ebad8faacbada91b8e576029732e91b5a041c7f"
integrity sha512-3qV2qjeNZla1Fyg6Q2NR5J9AsMaNePLbUboOwhRXB7IcMnTnrxSiVn2R//8VnjnmBjF9rcvgAIAvETZ8AKGfsg==
dependencies:
"@types/yup" "0.26.17"
"@types/yup" "0.26.20"
lightercollective "^0.3.0"
object-hash "^1.3.1"
yup "^0.27.0"

View File

@ -23,7 +23,7 @@
"cross-env": "^5.2.0",
"cypress": "^3.3.2",
"cypress-cucumber-preprocessor": "^1.12.0",
"cypress-file-upload": "^3.1.4",
"cypress-file-upload": "^3.2.0",
"cypress-plugin-retries": "^1.2.2",
"dotenv": "^8.0.0",
"faker": "Marak/faker.js#master",

View File

@ -1,4 +1,4 @@
FROM node:12.4-alpine as base
FROM node:12.5-alpine as base
LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)"
EXPOSE 3000

View File

@ -0,0 +1,108 @@
import { mount, createLocalVue } from '@vue/test-utils'
import CategoriesSelect from './CategoriesSelect'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('CategoriesSelect.vue', () => {
let wrapper
let mocks
let democracyAndPolitics
let environmentAndNature
let consumptionAndSustainablity
const categories = [
{
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
},
{
id: 'cat4',
name: 'Environment & Nature',
icon: 'tree',
},
{
id: 'cat15',
name: 'Consumption & Sustainability',
icon: 'shopping-cart',
},
{
name: 'Cooperation & Development',
icon: 'users',
id: 'cat8',
},
]
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return mount(CategoriesSelect, { mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('toggleCategory', () => {
beforeEach(() => {
wrapper.vm.categories = categories
democracyAndPolitics = wrapper.findAll('button').at(0)
democracyAndPolitics.trigger('click')
})
it('adds categories to selectedCategoryIds when clicked', () => {
expect(wrapper.vm.selectedCategoryIds).toEqual([categories[0].id])
})
it('emits an updateCategories event when the selectedCategoryIds changes', () => {
expect(wrapper.emitted().updateCategories[0][0]).toEqual([categories[0].id])
})
it('removes categories when clicked a second time', () => {
democracyAndPolitics.trigger('click')
expect(wrapper.vm.selectedCategoryIds).toEqual([])
})
it('changes the selectedCount when selectedCategoryIds is updated', () => {
expect(wrapper.vm.selectedCount).toEqual(1)
democracyAndPolitics.trigger('click')
expect(wrapper.vm.selectedCount).toEqual(0)
})
it('sets a category to active when it has been selected', () => {
expect(wrapper.vm.isActive(categories[0].id)).toEqual(true)
})
describe('maximum', () => {
beforeEach(() => {
environmentAndNature = wrapper.findAll('button').at(1)
consumptionAndSustainablity = wrapper.findAll('button').at(2)
environmentAndNature.trigger('click')
consumptionAndSustainablity.trigger('click')
})
it('allows three categories to be selected', () => {
expect(wrapper.vm.selectedCategoryIds).toEqual([
categories[0].id,
categories[1].id,
categories[2].id,
])
})
it('sets reachedMaximum to true after three', () => {
expect(wrapper.vm.reachedMaximum).toEqual(true)
})
it('sets other categories to disabled after three', () => {
expect(wrapper.vm.isDisabled(categories[3].id)).toEqual(true)
})
})
})
})
})

View File

@ -0,0 +1,102 @@
<template>
<div>
<ds-flex :gutter="{ base: 'xx-small', md: 'small', lg: 'xx-small' }">
<div v-for="category in categories" :key="category.id">
<ds-flex-item>
<ds-button
size="small"
@click.prevent="toggleCategory(category.id)"
:primary="isActive(category.id)"
:disabled="isDisabled(category.id)"
>
<ds-icon :name="category.icon" />
{{ category.name }}
</ds-button>
</ds-flex-item>
</div>
</ds-flex>
<p class="small-info">
{{
$t('contribution.categories.infoSelectedNoOfMaxCategories', {
chosen: selectedCount,
max: selectedMax,
})
}}
</p>
</div>
</template>
<script>
import gql from 'graphql-tag'
export default {
props: {
existingCategoryIds: { type: Array, default: () => [] },
},
data() {
return {
categories: null,
selectedMax: 3,
selectedCategoryIds: [],
}
},
computed: {
selectedCount() {
return this.selectedCategoryIds.length
},
reachedMaximum() {
return this.selectedCount >= this.selectedMax
},
},
watch: {
selectedCategoryIds(categoryIds) {
this.$emit('updateCategories', categoryIds)
},
existingCategoryIds: {
immediate: true,
handler: function(existingCategoryIds) {
if (!existingCategoryIds || !existingCategoryIds.length) {
return
}
this.selectedCategoryIds = existingCategoryIds
},
},
},
methods: {
toggleCategory(id) {
const index = this.selectedCategoryIds.indexOf(id)
if (index > -1) {
this.selectedCategoryIds.splice(index, 1)
} else {
this.selectedCategoryIds.push(id)
}
},
isActive(id) {
const index = this.selectedCategoryIds.indexOf(id)
if (index > -1) {
return true
}
return false
},
isDisabled(id) {
return !!(this.reachedMaximum && !this.isActive(id))
},
},
apollo: {
Category: {
query() {
return gql(`{
Category {
id
name
icon
}
}`)
},
result(result) {
this.categories = result.data.Category
},
},
},
}
</script>

View File

@ -1,51 +1,50 @@
<template>
<div v-if="(comment.deleted || comment.disabled) && !isModerator">
<ds-text style="padding-left: 40px; font-weight: bold;" color="soft">
<ds-icon name="ban" />
{{ this.$t('comment.content.unavailable-placeholder') }}
</ds-text>
<div v-if="(comment.deleted || comment.disabled) && !isModerator" :class="{ comment: true }">
<ds-card>
<ds-space margin-bottom="base" />
<ds-text style="padding-left: 40px; font-weight: bold;" color="soft">
<ds-icon name="ban" />
{{ this.$t('comment.content.unavailable-placeholder') }}
</ds-text>
<ds-space margin-bottom="base" />
</ds-card>
</div>
<div v-else :class="{ comment: true, 'disabled-content': comment.deleted || comment.disabled }">
<ds-space margin-bottom="x-small">
<hc-user :user="author" :date-time="comment.createdAt" />
</ds-space>
<!-- Content Menu (can open Modals) -->
<no-ssr>
<content-menu
placement="bottom-end"
resource-type="comment"
:resource="comment"
:modalsData="menuModalsData"
style="float-right"
:is-owner="isAuthor(author.id)"
/>
</no-ssr>
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<ds-card>
<ds-space margin-bottom="small">
<hc-user :user="author" :date-time="comment.createdAt" />
</ds-space>
<!-- Content Menu (can open Modals) -->
<no-ssr>
<content-menu
placement="bottom-end"
resource-type="comment"
:resource="comment"
:modalsData="menuModalsData"
style="float-right"
:is-owner="isAuthor(author.id)"
/>
</no-ssr>
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
<div
v-show="comment.content !== comment.contentExcerpt"
style="text-align: right; margin-right: 20px; margin-top: -12px;"
>
<span
v-if="isCollapsed"
style="padding-left: 40px; color: #17b53f; cursor:pointer"
@click="isCollapsed = !isCollapsed"
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
<div
v-show="comment.content !== comment.contentExcerpt"
style="text-align: right; margin-right: 20px; margin-top: -12px;"
>
{{ $t('comment.show.more') }}
</span>
</div>
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
<span
v-if="!isCollapsed"
@click="isCollapsed = !isCollapsed"
style="padding-left: 40px; color: #17b53f; cursor:pointer"
>
{{ $t('comment.show.less') }}
</span>
</div>
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
{{ $t('comment.show.more') }}
</a>
</div>
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
{{ $t('comment.show.less') }}
</a>
</div>
<ds-space margin-bottom="small" />
</ds-card>
</div>
</template>
<!-- eslint-enable vue/no-v-html -->

View File

@ -46,9 +46,9 @@ export default {
modalsData: {
type: Object,
required: false,
// default: () => {
// return {}
// },
default: () => {
return {}
},
},
},
computed: {

View File

@ -1,13 +1,17 @@
import { config, mount, createLocalVue } from '@vue/test-utils'
import ContributionForm from './index.vue'
import ContributionForm from './ContributionForm.vue'
import Styleguide from '@human-connection/styleguide'
import Vuex from 'vuex'
import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import Filters from '~/plugins/vue-filters'
import TeaserImage from '~/components/TeaserImage/TeaserImage'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
@ -21,7 +25,11 @@ describe('ContributionForm.vue', () => {
let propsData
const postTitle = 'this is a title for a post'
const postContent = 'this is a post'
const imageUpload = {
file: { filename: 'avataar.svg', previewElement: '' },
url: 'someUrlToImage',
}
const image = '/uploads/1562010976466-avataaars'
beforeEach(() => {
mocks = {
$t: jest.fn(),
@ -100,7 +108,15 @@ describe('ContributionForm.vue', () => {
beforeEach(async () => {
expectedParams = {
mutation: PostMutations().CreatePost,
variables: { title: postTitle, content: postContent, language: 'en', id: null },
variables: {
title: postTitle,
content: postContent,
language: 'en',
id: null,
categoryIds: null,
imageUpload: null,
image: null,
},
}
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
@ -124,6 +140,21 @@ describe('ContributionForm.vue', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it('supports adding categories', async () => {
const categoryIds = ['cat12', 'cat15', 'cat37']
expectedParams.variables.categoryIds = categoryIds
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it('supports adding a teaser image', async () => {
expectedParams.variables.imageUpload = imageUpload
wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload)
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it("pushes the user to the post's page", async () => {
expect(mocks.$router.push).toHaveBeenCalledTimes(1)
})
@ -143,6 +174,7 @@ describe('ContributionForm.vue', () => {
describe('handles errors', () => {
beforeEach(async () => {
jest.useFakeTimers()
wrapper = Wrapper()
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
@ -150,6 +182,7 @@ describe('ContributionForm.vue', () => {
// second submission causes mutation to reject
await wrapper.find('form').trigger('submit')
})
it('shows an error toaster when apollo mutation rejects', async () => {
await wrapper.find('form').trigger('submit')
await mocks.$apollo.mutate
@ -167,6 +200,8 @@ describe('ContributionForm.vue', () => {
title: 'dies ist ein Post',
content: 'auf Deutsch geschrieben',
language: 'de',
image,
categories: [{ id: 'cat12', name: 'Democracy & Politics' }],
},
}
wrapper = Wrapper()
@ -188,10 +223,6 @@ describe('ContributionForm.vue', () => {
expect(wrapper.vm.form.content).toEqual(propsData.contribution.content)
})
it('sets language equal to contribution language', () => {
expect(wrapper.vm.form.language).toEqual({ value: propsData.contribution.language })
})
it('calls the UpdatePost apollo mutation', async () => {
expectedParams = {
mutation: PostMutations().UpdatePost,
@ -200,6 +231,9 @@ describe('ContributionForm.vue', () => {
content: postContent,
language: propsData.contribution.language,
id: propsData.contribution.id,
categoryIds: ['cat12'],
image,
imageUpload: null,
},
}
postTitleInput = wrapper.find('.ds-input')
@ -208,6 +242,17 @@ describe('ContributionForm.vue', () => {
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it('supports updating categories', async () => {
const categoryIds = ['cat3', 'cat51', 'cat37']
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent)
expectedParams.variables.categoryIds = categoryIds
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
})
})
})

View File

@ -2,11 +2,23 @@
<ds-form ref="contributionForm" v-model="form" :schema="formSchema" @submit="submit">
<template slot-scope="{ errors }">
<ds-card>
<hc-teaser-image :contribution="contribution" @addTeaserImage="addTeaserImage">
<img
v-if="contribution"
class="contribution-image"
:src="contribution.image | proxyApiUrl"
/>
</hc-teaser-image>
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
<no-ssr>
<hc-editor :users="users" :value="form.content" @input="updateEditorContent" />
</no-ssr>
<ds-space margin-bottom="xxx-large" />
<hc-categories-select
model="categoryIds"
@updateCategories="updateCategories"
:existingCategoryIds="form.categoryIds"
/>
<ds-flex class="contribution-form-footer">
<ds-flex-item :width="{ base: '10%', sm: '10%', md: '10%', lg: '15%' }" />
<ds-flex-item :width="{ base: '80%', sm: '30%', md: '30%', lg: '20%' }">
@ -25,7 +37,7 @@
:disabled="loading || disabled"
ghost
class="cancel-button"
@click="$router.back()"
@click.prevent="$router.back()"
>
{{ $t('actions.cancel') }}
</ds-button>
@ -39,6 +51,7 @@
{{ $t('actions.save') }}
</ds-button>
</div>
<ds-space margin-bottom="large" />
</ds-card>
</template>
</ds-form>
@ -50,10 +63,14 @@ import HcEditor from '~/components/Editor'
import orderBy from 'lodash/orderBy'
import locales from '~/locales'
import PostMutations from '~/graphql/PostMutations.js'
import HcCategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import HcTeaserImage from '~/components/TeaserImage/TeaserImage'
export default {
components: {
HcEditor,
HcCategoriesSelect,
HcTeaserImage,
},
props: {
contribution: { type: Object, default: () => {} },
@ -63,8 +80,11 @@ export default {
form: {
title: '',
content: '',
teaserImage: null,
image: null,
language: null,
languageOptions: [],
categoryIds: null,
},
formSchema: {
title: { required: true, min: 3, max: 64 },
@ -88,7 +108,8 @@ export default {
this.slug = contribution.slug
this.form.content = contribution.content
this.form.title = contribution.title
this.form.language = { value: contribution.language }
this.form.image = contribution.image
this.form.categoryIds = this.categoryIds(contribution.categories)
},
},
},
@ -106,22 +127,33 @@ export default {
},
methods: {
submit() {
const { title, content, image, teaserImage, categoryIds } = this.form
let language
if (this.form.language) {
language = this.form.language.value
} else if (this.contribution && this.contribution.language) {
language = this.contribution.language
} else {
language = this.$i18n.locale()
}
this.loading = true
this.$apollo
.mutate({
mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
variables: {
id: this.id,
title: this.form.title,
content: this.form.content,
language: this.form.language ? this.form.language.value : this.$i18n.locale(),
title,
content,
categoryIds,
language,
image,
imageUpload: teaserImage,
},
})
.then(res => {
this.loading = false
this.$toast.success(this.$t('contribution.success'))
this.disabled = true
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({
@ -144,6 +176,19 @@ export default {
this.form.languageOptions.push({ label: locale.name, value: locale.code })
})
},
updateCategories(ids) {
this.form.categoryIds = ids
},
addTeaserImage(file) {
this.form.teaserImage = file
},
categoryIds(categories) {
let categoryIds = []
categories.map(categoryId => {
categoryIds.push(categoryId.id)
})
return categoryIds
},
},
apollo: {
User: {
@ -176,8 +221,4 @@ export default {
padding-right: 0;
}
}
.contribution-form-footer {
border-top: $border-size-base solid $border-color-softest;
}
</style>

View File

@ -111,7 +111,7 @@ export default {
this.$apollo
.mutate({
mutation: gql`
mutation($id: ID!, $resource: [String]) {
mutation($id: ID!, $resource: [Deletable]) {
DeleteUser(id: $id, resource: $resource) {
id
}

View File

@ -89,8 +89,19 @@ export default {
}, 500)
}, 1500)
} catch (err) {
this.$emit('close')
this.success = false
this.$toast.error(err.message)
switch (err.message) {
case 'GraphQL error: User':
this.$toast.error(this.$t('report.user.error'))
break
case 'GraphQL error: Post':
this.$toast.error(this.$t('report.contribution.error'))
break
case 'GraphQL error: Comment':
this.$toast.error(this.$t('report.comment.error'))
break
}
} finally {
this.loading = false
}

View File

@ -10,9 +10,7 @@
>
<div class="field">
<div class="control">
<a v-if="isActive" class="search-clear-btn" @click="clear">
&nbsp;
</a>
<a v-if="isActive" class="search-clear-btn" @click="clear">&nbsp;</a>
<ds-select
:id="id"
ref="input"
@ -41,9 +39,7 @@
<template slot="option" slot-scope="{ option }">
<ds-flex>
<ds-flex-item class="search-option-label">
<ds-text>
{{ option.label | truncate(70) }}
</ds-text>
<ds-text>{{ option.label | truncate(70) }}</ds-text>
</ds-flex-item>
<ds-flex-item class="search-option-meta" width="280px">
<ds-flex>

View File

@ -0,0 +1,61 @@
import { mount, createLocalVue } from '@vue/test-utils'
import TeaserImage from './TeaserImage.vue'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('TeaserImage.vue', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$toast: {
error: jest.fn(),
},
}
})
describe('mount', () => {
const Wrapper = () => {
return mount(TeaserImage, { mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('File upload', () => {
const imageUpload = [
{ file: { filename: 'avataar.svg', previewElement: '' }, url: 'someUrlToImage' },
]
it('supports adding a teaser image', () => {
wrapper.vm.addTeaserImage(imageUpload)
expect(wrapper.emitted().addTeaserImage[0]).toEqual(imageUpload)
})
})
describe('handles errors', () => {
beforeEach(() => jest.useFakeTimers())
const message = 'File upload failed'
const fileError = { status: 'error' }
it('defaults to error false', () => {
expect(wrapper.vm.error).toEqual(false)
})
it('shows an error toaster when verror is called', () => {
wrapper.vm.verror(fileError, message)
expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, message)
})
it('changes error status from false to true to false', () => {
wrapper.vm.verror(fileError, message)
expect(wrapper.vm.error).toEqual(true)
jest.runAllTimers()
expect(wrapper.vm.error).toEqual(false)
})
})
})
})

View File

@ -0,0 +1,197 @@
<template>
<vue-dropzone
:options="dropzoneOptions"
ref="el"
id="postdropzone"
:use-custom-slot="true"
@vdropzone-thumbnail="thumbnail"
@vdropzone-error="verror"
>
<div class="dz-message">
<div
:class="{
'hc-attachments-upload-area-post': createAndUpdate,
'hc-attachments-upload-area-update-post': contribution,
}"
>
<slot></slot>
<div
:class="{
'hc-drag-marker-post': createAndUpdate,
'hc-drag-marker-update-post': contribution,
}"
>
<ds-icon name="image" size="xxx-large" />
</div>
</div>
</div>
</vue-dropzone>
</template>
<script>
import vueDropzone from 'nuxt-dropzone'
export default {
components: {
vueDropzone,
},
props: {
contribution: { type: Object, default: () => {} },
},
data() {
return {
dropzoneOptions: {
url: this.addTeaserImage,
maxFilesize: 5.0,
previewTemplate: this.template(),
},
error: false,
createAndUpdate: true,
}
},
watch: {
error() {
let that = this
setTimeout(function() {
that.error = false
}, 2000)
},
},
methods: {
template() {
return `<div class="dz-preview dz-file-preview">
<div class="dz-image">
<div data-dz-thumbnail-bg></div>
</div>
</div>
`
},
verror(file, message) {
this.error = true
this.$toast.error(file.status, message)
},
addTeaserImage(file) {
this.$emit('addTeaserImage', file[0])
return ''
},
thumbnail: (file, dataUrl) => {
let thumbnailElement, contributionImage, uploadArea
if (file.previewElement) {
thumbnailElement = document.querySelectorAll('#postdropzone')[0]
contributionImage = document.querySelectorAll('.contribution-image')[0]
if (contributionImage) {
uploadArea = document.querySelectorAll('.hc-attachments-upload-area-update-post')[0]
uploadArea.removeChild(contributionImage)
uploadArea.classList.remove('hc-attachments-upload-area-update-post')
}
thumbnailElement.classList.add('image-preview')
thumbnailElement.alt = file.name
thumbnailElement.style.backgroundImage = 'url("' + dataUrl + '")'
}
},
},
}
</script>
<style lang="scss">
#postdropzone {
width: 100%;
min-height: 300px;
background-color: $background-color-softest;
}
#postdropzone.image-preview {
background-repeat: no-repeat;
background-size: cover;
background-position: center;
height: auto;
transition: all 0.2s ease-out;
width: 100%;
}
@media only screen and (max-width: 400px) {
#postdropzone.image-preview {
height: 200px;
}
}
@media only screen and (min-width: 401px) and (max-width: 960px) {
#postdropzone.image-preview {
height: 300px;
}
}
.hc-attachments-upload-area-post {
position: relative;
display: flex;
justify-content: center;
cursor: pointer;
}
.hc-attachments-upload-area-update-post img {
object-fit: cover;
object-position: center;
display: block;
width: 100%;
}
.hc-attachments-upload-area-update-post:hover {
opacity: 0.7;
}
.hc-drag-marker-post {
position: absolute;
width: 122px;
height: 122px;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
color: hsl(0, 0%, 25%);
transition: all 0.2s ease-out;
font-size: 60px;
margin: 80px 5px;
background-color: $background-color-softest;
opacity: 0.65;
&:before {
position: absolute;
content: '';
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 100%;
border: 20px solid $text-color-base;
visibility: hidden;
}
&:after {
position: absolute;
content: '';
top: 10px;
left: 10px;
bottom: 10px;
right: 10px;
border-radius: 100%;
border: $border-size-base dashed $text-color-base;
}
.hc-attachments-upload-area-post:hover & {
opacity: 1;
}
}
.hc-drag-marker-update-post {
opacity: 0;
}
.contribution-form-footer {
border-top: $border-size-base solid $border-color-softest;
}
.contribution-image {
max-height: 300px;
}
</style>

View File

@ -9,7 +9,7 @@
@vdropzone-error="verror"
>
<div class="dz-message" @mouseover="hover = true" @mouseleave="hover = false">
<hc-avatar :user="user" class="profile-avatar" size="x-large"></hc-avatar>
<slot></slot>
<div class="hc-attachments-upload-area">
<div class="hc-drag-marker">
<ds-icon v-if="hover" name="image" size="xxx-large" />
@ -22,12 +22,10 @@
<script>
import vueDropzone from 'nuxt-dropzone'
import gql from 'graphql-tag'
import HcAvatar from '~/components/Avatar/Avatar.vue'
export default {
components: {
vueDropzone,
HcAvatar,
},
props: {
user: { type: Object, default: null },

View File

@ -3,7 +3,7 @@ import gql from 'graphql-tag'
export default () => {
return {
CreateComment: gql`
mutation($postId: ID, $content: String!) {
mutation($postId: ID!, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
contentExcerpt

View File

@ -3,9 +3,20 @@ import gql from 'graphql-tag'
export default () => {
return {
CreatePost: gql`
mutation($title: String!, $content: String!, $language: String) {
CreatePost(title: $title, content: $content, language: $language) {
id
mutation(
$title: String!
$content: String!
$language: String
$categoryIds: [ID]
$imageUpload: Upload
) {
CreatePost(
title: $title
content: $content
language: $language
categoryIds: $categoryIds
imageUpload: $imageUpload
) {
title
slug
content
@ -15,14 +26,31 @@ export default () => {
}
`,
UpdatePost: gql`
mutation($id: ID!, $title: String!, $content: String!, $language: String) {
UpdatePost(id: $id, title: $title, content: $content, language: $language) {
mutation(
$id: ID!
$title: String!
$content: String!
$language: String
$imageUpload: Upload
$categoryIds: [ID]
$image: String
) {
UpdatePost(
id: $id
title: $title
content: $content
language: $language
imageUpload: $imageUpload
categoryIds: $categoryIds
image: $image
) {
id
title
slug
content
contentExcerpt
language
image
}
}
`,

View File

@ -0,0 +1,79 @@
import gql from 'graphql-tag'
export default i18n => {
const lang = i18n.locale().toUpperCase()
return gql(`
query Post($slug: String!) {
Post(slug: $slug) {
id
title
content
createdAt
disabled
deleted
slug
image
author {
id
slug
name
avatar
disabled
deleted
shoutedCount
contributionsCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${lang}
}
badges {
id
key
icon
}
}
tags {
name
}
commentsCount
comments(orderBy: createdAt_desc) {
id
contentExcerpt
createdAt
disabled
deleted
author {
id
slug
name
avatar
disabled
deleted
shoutedCount
contributionsCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${lang}
}
badges {
id
key
icon
}
}
}
categories {
id
name
icon
}
shoutedCount
shoutedByCurrentUser
}
}
`)
}

View File

@ -1,88 +1,98 @@
<template>
<div class="layout-default">
<div class="main-navigation">
<ds-container class="main-navigation-container">
<div class="main-navigation-left">
<a v-router-link style="display: inline-flex" href="/">
<ds-logo />
</a>
</div>
<div class="main-navigation-center hc-navbar-search">
<search-input
id="nav-search"
:delay="300"
:pending="quickSearchPending"
:results="quickSearchResults"
@clear="quickSearchClear"
@search="value => quickSearch({ value })"
@select="goToPost"
/>
</div>
<div class="main-navigation-right">
<no-ssr>
<locale-switch class="topbar-locale-switch" placement="bottom" offset="23" />
</no-ssr>
<template v-if="isLoggedIn">
<no-ssr>
<notification-menu />
</no-ssr>
<no-ssr>
<dropdown class="avatar-menu">
<template slot="default" slot-scope="{ toggleMenu }">
<a
class="avatar-menu-trigger"
:href="
$router.resolve({
name: 'profile-id-slug',
params: { id: user.id, slug: user.slug },
}).href
"
@click.prevent="toggleMenu"
>
<hc-avatar :user="user" />
<ds-icon size="xx-small" name="angle-down" />
</a>
<ds-container class="main-navigation-container" style="padding: 10px 10px;">
<div>
<ds-flex>
<ds-flex-item :width="{ base: '49px', md: '150px' }">
<a v-router-link style="display: inline-flex" href="/">
<ds-logo />
</a>
</ds-flex-item>
<ds-flex-item>
<div id="nav-search-box" v-on:click="unfolded" @blur.capture="foldedup">
<search-input
id="nav-search"
:delay="300"
:pending="quickSearchPending"
:results="quickSearchResults"
@clear="quickSearchClear"
@search="value => quickSearch({ value })"
@select="goToPost"
/>
</div>
</ds-flex-item>
<ds-flex-item width="200px" style="background-color:white">
<div class="main-navigation-right" style="float:right">
<no-ssr>
<locale-switch class="topbar-locale-switch" placement="bottom" offset="23" />
</no-ssr>
<template v-if="isLoggedIn">
<no-ssr>
<notification-menu />
</no-ssr>
<no-ssr>
<dropdown class="avatar-menu">
<template slot="default" slot-scope="{ toggleMenu }">
<a
class="avatar-menu-trigger"
:href="
$router.resolve({
name: 'profile-id-slug',
params: { id: user.id, slug: user.slug },
}).href
"
@click.prevent="toggleMenu"
>
<hc-avatar :user="user" />
<ds-icon size="xx-small" name="angle-down" />
</a>
</template>
<template slot="popover" slot-scope="{ closeMenu }">
<div class="avatar-menu-popover">
{{ $t('login.hello') }}
<b>{{ userName }}</b>
<template v-if="user.role !== 'user'">
<ds-text color="softer" size="small" style="margin-bottom: 0">
{{ user.role | camelCase }}
</ds-text>
</template>
<hr />
<ds-menu :routes="routes" :matcher="matcher">
<ds-menu-item
slot="menuitem"
slot-scope="item"
:route="item.route"
:parents="item.parents"
@click.native="closeMenu(false)"
>
<ds-icon :name="item.route.icon" />
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
<hr />
<nuxt-link class="logout-link" :to="{ name: 'logout' }">
<ds-icon name="sign-out" />
{{ $t('login.logout') }}
</nuxt-link>
</div>
</template>
</dropdown>
</no-ssr>
</template>
<template slot="popover" slot-scope="{ closeMenu }">
<div class="avatar-menu-popover">
{{ $t('login.hello') }}
<b>{{ userName }}</b>
<template v-if="user.role !== 'user'">
<ds-text color="softer" size="small" style="margin-bottom: 0">
{{ user.role | camelCase }}
</ds-text>
</template>
<hr />
<ds-menu :routes="routes" :matcher="matcher">
<ds-menu-item
slot="menuitem"
slot-scope="item"
:route="item.route"
:parents="item.parents"
@click.native="closeMenu(false)"
>
<ds-icon :name="item.route.icon" />
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
<hr />
<nuxt-link class="logout-link" :to="{ name: 'logout' }">
<ds-icon name="sign-out" />
{{ $t('login.logout') }}
</nuxt-link>
</div>
</template>
</dropdown>
</no-ssr>
</template>
</div>
</ds-flex-item>
</ds-flex>
</div>
</ds-container>
</div>
<ds-container>
<div style="padding: 6rem 2rem 5rem;">
<nuxt />
</div>
</ds-container>
<div id="overlay" />
<no-ssr>
<modal />
@ -181,9 +191,23 @@ export default {
}
return this.$route.path.indexOf(url) === 0
},
unfolded: function() {
document.getElementById('nav-search-box').classList.add('unfolded')
},
foldedup: function() {
document.getElementById('nav-search-box').classList.remove('unfolded')
},
},
}
</script>
<style>
.unfolded {
position: absolute;
right: 0px;
left: 0px;
z-index: 1;
}
</style>
<style lang="scss">
.topbar-locale-switch {
@ -199,28 +223,6 @@ export default {
}
}
.main-navigation-container {
padding: $space-x-small $space-large !important;
width: 100%;
align-items: center;
display: flex;
}
.main-navigation-left {
display: flex;
flex: 1;
width: 100%;
align-items: center;
}
.main-navigation-center {
display: flex;
flex: auto;
width: 100%;
padding-right: $space-large;
padding-left: $space-large;
}
.main-navigation-right {
display: flex;
flex: 1;

View File

@ -27,7 +27,7 @@
"code": "Code eingeben",
"description": "Öffne dein E-Mail Postfach und gib den Code ein, den wir geschickt haben.",
"next": "Weiter",
"change-password":{
"change-password": {
"success": "Änderung des Passworts war erfolgreich!",
"error": "Passwort Änderung fehlgeschlagen. Möglicherweise falscher Sicherheitscode?",
"help": "Falls Probleme auftreten, schreib uns gerne eine Mail an:"
@ -313,17 +313,20 @@
"user": {
"title": "Nutzer freigeben",
"type": "Nutzer",
"message": "Bist du sicher, dass du den Nutzer \"<b>{name}</b>\" freigeben möchtest?"
"message": "Bist du sicher, dass du den Nutzer \"<b>{name}</b>\" freigeben möchtest?",
"error": "Den User hast du schon gemeldet!"
},
"contribution": {
"title": "Beitrag freigeben",
"type": "Beitrag",
"message": "Bist du sicher, dass du den Beitrag \"<b>{name}</b>\" freigeben möchtest?"
"message": "Bist du sicher, dass du den Beitrag \"<b>{name}</b>\" freigeben möchtest?",
"error": " Den Beitrag hast du schon gemeldet!"
},
"comment": {
"title": "Kommentar freigeben",
"type": "Kommentar",
"message": "Bist du sicher, dass du den Kommentar \"<b>{name}</b>\" freigeben möchtest?"
"message": "Bist du sicher, dass du den Kommentar \"<b>{name}</b>\" freigeben möchtest?",
"error": "Den Kommentar hast du schon gemeldet!"
}
},
"user": {
@ -336,7 +339,10 @@
"filterFollow": "Beiträge filtern von Usern denen ich folge",
"filterALL": "Alle Beiträge anzeigen",
"success": "Gespeichert!",
"languageSelectLabel": "Sprache"
"languageSelectLabel": "Sprache",
"categories": {
"infoSelectedNoOfMaxCategories": "{chosen} von {max} Kategorien ausgewählt"
}
}
}

View File

@ -286,17 +286,20 @@
"user": {
"title": "Report User",
"type": "User",
"message": "Do you really want to report the user \"<b>{name}</b>\"?"
"message": "Do you really want to report the user \"<b>{name}</b>\"?",
"error": "You already reported the user!"
},
"contribution": {
"title": "Report Post",
"type": "Contribution",
"message": "Do you really want to report the contribution \"<b>{name}</b>\"?"
"message": "Do you really want to report the contribution \"<b>{name}</b>\"?",
"error": "You have already reported the contribution!"
},
"comment": {
"title": "Report Comment",
"type": "Comment",
"message": "Do you really want to report the comment from \"<b>{name}</b>\"?"
"message": "Do you really want to report the comment from \"<b>{name}</b>\"?",
"error": "You have already reported the comment!"
}
},
"followButton": {
@ -336,6 +339,9 @@
"filterFollow": "Filter contributions from users I follow",
"filterALL": "View all contributions",
"success": "Saved!",
"languageSelectLabel": "Language"
"languageSelectLabel": "Language",
"categories": {
"infoSelectedNoOfMaxCategories": "{chosen} of {max} categories selected"
}
}
}

View File

@ -229,17 +229,20 @@
"user": {
"title": "Signaler l'utilisateur",
"type": "Utilisateur",
"message": "Souhaitez-vous vraiment signaler l'utilisateur \" <b> {name} </b> \"?"
"message": "Souhaitez-vous vraiment signaler l'utilisateur \" <b> {name} </b> \"?",
"error": "Vous avez déjà signalé l'utilisateur!"
},
"contribution": {
"title": "Signaler l'entrée",
"type": "Apport",
"message": "Souhaitez-vous vraiment signaler l'entrée\" <b> {name} </b> \"?"
"message": "Souhaitez-vous vraiment signaler l'entrée\" <b> {name} </b> \"?",
"error": "Vous avez déjà rapporté la contribution!"
},
"comment": {
"title": "Signaler un commentaire",
"type": "Commentaire",
"message": "Souhaitez-vous vraiment signaler l'utilisateur \" <b> {name} </b> \"?"
"message": "Souhaitez-vous vraiment signaler l'utilisateur \" <b> {name} </b> \"?",
"error": "Vous avez déjà rapporté le commentaire!"
}
},
"followButton": {

View File

@ -2,11 +2,13 @@ import { config, shallowMount, createLocalVue } from '@vue/test-utils'
import PostSlug from './index.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'

View File

@ -2,7 +2,7 @@
<transition name="fade" appear>
<ds-card
v-if="post && ready"
:image="post.image"
:image="post.image | proxyApiUrl"
:class="{ 'post-card': true, 'disabled-content': post.disabled }"
>
<ds-space margin-bottom="small" />
@ -62,8 +62,6 @@
</template>
<script>
import gql from 'graphql-tag'
import HcCategory from '~/components/Category'
import HcTag from '~/components/Tag'
import ContentMenu from '~/components/ContentMenu'
@ -72,6 +70,7 @@ import HcShoutButton from '~/components/ShoutButton.vue'
import HcCommentForm from '~/components/comments/CommentForm'
import HcCommentList from '~/components/comments/CommentList'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
import PostQuery from '~/graphql/PostQuery.js'
export default {
name: 'PostSlug',
@ -113,80 +112,7 @@ export default {
app: { apolloProvider, $i18n },
} = context
const client = apolloProvider.defaultClient
const query = gql(`
query Post($slug: String!) {
Post(slug: $slug) {
id
title
content
createdAt
disabled
deleted
slug
image
author {
id
slug
name
avatar
disabled
deleted
shoutedCount
contributionsCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${$i18n.locale().toUpperCase()}
}
badges {
id
key
icon
}
}
tags {
name
}
commentsCount
comments(orderBy: createdAt_desc) {
id
contentExcerpt
createdAt
disabled
deleted
author {
id
slug
name
avatar
disabled
deleted
shoutedCount
contributionsCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${$i18n.locale().toUpperCase()}
}
badges {
id
key
icon
}
}
}
categories {
id
name
icon
}
shoutedCount
shoutedByCurrentUser
}
}
`)
const query = PostQuery($i18n)
const variables = { slug: params.slug }
const {
data: { Post },

View File

@ -3,14 +3,12 @@
<ds-flex-item :width="{ base: '100%', md: 3 }">
<hc-contribution-form />
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">
&nbsp;
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item>
</ds-flex>
</template>
<script>
import HcContributionForm from '~/components/ContributionForm'
import HcContributionForm from '~/components/ContributionForm/ContributionForm'
export default {
components: {

View File

@ -9,7 +9,7 @@
<script>
import gql from 'graphql-tag'
import HcContributionForm from '~/components/ContributionForm'
import HcContributionForm from '~/components/ContributionForm/ContributionForm'
export default {
components: {

View File

@ -10,7 +10,9 @@
:class="{ 'disabled-content': user.disabled }"
style="position: relative; height: auto;"
>
<hc-upload v-if="myProfile" :user="user" />
<hc-upload v-if="myProfile" :user="user">
<hc-avatar :user="user" class="profile-avatar" size="x-large"></hc-avatar>
</hc-upload>
<hc-avatar v-else :user="user" class="profile-avatar" size="x-large" />
<!-- Menu -->
<no-ssr>

View File

@ -1822,10 +1822,10 @@ cypress-cucumber-preprocessor@^1.12.0:
glob "^7.1.2"
through "^2.3.8"
cypress-file-upload@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-3.1.4.tgz#cc208cb937a3abb136b52309eaf4637d5676c5bd"
integrity sha512-4aZeJOYFhYiP+nk9Mo5YHWqComsT24J9OBQVJzvkEzw7g1v2ogGe7nLT/U7Fsm/Xjl1Tyxsc0xxECa254WfQqg==
cypress-file-upload@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-3.2.0.tgz#a48282e1fea385ba6aef9ec3296a934026f5fd67"
integrity sha512-C1nFgURTgvtz9MpP7sYKjhKSdgQvDhUs3f4w6hvEH33wDDQUkmXwrozKDvxXSdccc07M7wH4O5JF61sTkvY8lA==
cypress-plugin-retries@^1.2.2:
version "1.2.2"