mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge branch 'master' of https://github.com/Ocelot-Social-Community/Ocelot-Social into fix_docker
This commit is contained in:
commit
9dbe8bd257
@ -69,6 +69,7 @@
|
||||
"helmet": "~3.22.0",
|
||||
"ioredis": "^4.16.1",
|
||||
"jsonwebtoken": "~8.5.1",
|
||||
"languagedetect": "^2.0.0",
|
||||
"linkifyjs": "~2.1.8",
|
||||
"lodash": "~4.17.14",
|
||||
"merge-graphql-schemas": "^1.7.7",
|
||||
|
||||
@ -5,6 +5,7 @@ import { hashSync } from 'bcryptjs'
|
||||
import { Factory } from 'rosie'
|
||||
import { getDriver, getNeode } from './neo4j'
|
||||
import CONFIG from '../config/index.js'
|
||||
import generateInviteCode from '../schema/resolvers/helpers/generateInviteCode.js'
|
||||
|
||||
const neode = getNeode()
|
||||
|
||||
@ -205,7 +206,7 @@ const emailDefaults = {
|
||||
}
|
||||
|
||||
Factory.define('emailAddress')
|
||||
.attr(emailDefaults)
|
||||
.attrs(emailDefaults)
|
||||
.after((buildObject, options) => {
|
||||
return neode.create('EmailAddress', buildObject)
|
||||
})
|
||||
@ -216,6 +217,28 @@ Factory.define('unverifiedEmailAddress')
|
||||
return neode.create('UnverifiedEmailAddress', buildObject)
|
||||
})
|
||||
|
||||
const inviteCodeDefaults = {
|
||||
code: () => generateInviteCode(),
|
||||
createdAt: () => new Date().toISOString(),
|
||||
expiresAt: () => null,
|
||||
}
|
||||
|
||||
Factory.define('inviteCode')
|
||||
.attrs(inviteCodeDefaults)
|
||||
.option('generatedById', null)
|
||||
.option('generatedBy', ['generatedById'], (generatedById) => {
|
||||
if (generatedById) return neode.find('User', generatedById)
|
||||
return Factory.build('user')
|
||||
})
|
||||
.after(async (buildObject, options) => {
|
||||
const [inviteCode, generatedBy] = await Promise.all([
|
||||
neode.create('InviteCode', buildObject),
|
||||
options.generatedBy,
|
||||
])
|
||||
await Promise.all([inviteCode.relateTo(generatedBy, 'generated')])
|
||||
return inviteCode
|
||||
})
|
||||
|
||||
Factory.define('location')
|
||||
.attrs({
|
||||
name: 'Germany',
|
||||
|
||||
@ -541,6 +541,16 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
),
|
||||
])
|
||||
|
||||
await Factory.build(
|
||||
'inviteCode',
|
||||
{
|
||||
code: 'AAAAAA',
|
||||
},
|
||||
{
|
||||
generatedBy: jennyRostock,
|
||||
},
|
||||
)
|
||||
|
||||
authenticatedUser = await louie.toJson()
|
||||
const mention1 =
|
||||
'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, what\'s up?'
|
||||
|
||||
@ -14,6 +14,7 @@ import notifications from './notifications/notificationsMiddleware'
|
||||
import hashtags from './hashtags/hashtagsMiddleware'
|
||||
import email from './email/emailMiddleware'
|
||||
import sentry from './sentryMiddleware'
|
||||
import languages from './languages/languages'
|
||||
|
||||
export default (schema) => {
|
||||
const middlewares = {
|
||||
@ -30,6 +31,7 @@ export default (schema) => {
|
||||
softDelete,
|
||||
includedFields,
|
||||
orderBy,
|
||||
languages,
|
||||
}
|
||||
|
||||
let order = [
|
||||
@ -39,6 +41,7 @@ export default (schema) => {
|
||||
// 'activityPub', disabled temporarily
|
||||
'validation',
|
||||
'sluggify',
|
||||
'languages',
|
||||
'excerpt',
|
||||
'email',
|
||||
'notifications',
|
||||
|
||||
28
backend/src/middleware/languages/languages.js
Normal file
28
backend/src/middleware/languages/languages.js
Normal file
@ -0,0 +1,28 @@
|
||||
import LanguageDetect from 'languagedetect'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
|
||||
const removeHtmlTags = (input) => {
|
||||
return sanitizeHtml(input, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
})
|
||||
}
|
||||
|
||||
const setPostLanguage = (text) => {
|
||||
const lngDetector = new LanguageDetect()
|
||||
lngDetector.setLanguageType('iso2')
|
||||
return lngDetector.detect(removeHtmlTags(text), 1)[0][0]
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreatePost: async (resolve, root, args, context, info) => {
|
||||
args.language = await setPostLanguage(args.content)
|
||||
return resolve(root, args, context, info)
|
||||
},
|
||||
UpdatePost: async (resolve, root, args, context, info) => {
|
||||
args.language = await setPostLanguage(args.content)
|
||||
return resolve(root, args, context, info)
|
||||
},
|
||||
},
|
||||
}
|
||||
132
backend/src/middleware/languages/languages.spec.js
Normal file
132
backend/src/middleware/languages/languages.spec.js
Normal file
@ -0,0 +1,132 @@
|
||||
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'
|
||||
|
||||
let mutate
|
||||
let authenticatedUser
|
||||
let variables
|
||||
|
||||
const driver = getDriver()
|
||||
const neode = getNeode()
|
||||
|
||||
beforeAll(async () => {
|
||||
const { server } = createServer({
|
||||
context: () => {
|
||||
return {
|
||||
driver,
|
||||
neode,
|
||||
user: authenticatedUser,
|
||||
}
|
||||
},
|
||||
})
|
||||
mutate = createTestClient(server).mutate
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
const createPostMutation = gql`
|
||||
mutation($title: String!, $content: String!, $categoryIds: [ID]) {
|
||||
CreatePost(title: $title, content: $content, categoryIds: $categoryIds) {
|
||||
language
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('languagesMiddleware', () => {
|
||||
variables = {
|
||||
title: 'Test post languages',
|
||||
categoryIds: ['cat9'],
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanDatabase()
|
||||
const user = await Factory.build('user')
|
||||
authenticatedUser = await user.toJson()
|
||||
await Factory.build('category', {
|
||||
id: 'cat9',
|
||||
name: 'Democracy & Politics',
|
||||
icon: 'university',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects German', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
content: 'Jeder sollte vor seiner eigenen Tür kehren.',
|
||||
}
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createPostMutation,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
CreatePost: {
|
||||
language: 'de',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('detects English', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
content: 'A journey of a thousand miles begins with a single step.',
|
||||
}
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createPostMutation,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
CreatePost: {
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('detects Spanish', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
content: 'A caballo regalado, no le mires el diente.',
|
||||
}
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createPostMutation,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
CreatePost: {
|
||||
language: 'es',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('detects German in between lots of html tags', async () => {
|
||||
variables = {
|
||||
...variables,
|
||||
content:
|
||||
'<strong>Jeder</strong> <strike>sollte</strike> <strong>vor</strong> <span>seiner</span> eigenen <blockquote>Tür</blockquote> kehren.',
|
||||
}
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createPostMutation,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
CreatePost: {
|
||||
language: 'de',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -106,6 +106,8 @@ export default shield(
|
||||
notifications: isAuthenticated,
|
||||
Donations: isAuthenticated,
|
||||
userData: isAuthenticated,
|
||||
MyInviteCodes: isAuthenticated,
|
||||
isValidInviteCode: allow,
|
||||
},
|
||||
Mutation: {
|
||||
'*': deny,
|
||||
@ -149,6 +151,7 @@ export default shield(
|
||||
pinPost: isAdmin,
|
||||
unpinPost: isAdmin,
|
||||
UpdateDonations: isAdmin,
|
||||
GenerateInviteCode: isAuthenticated,
|
||||
},
|
||||
User: {
|
||||
email: or(isMyOwn, isAdmin),
|
||||
|
||||
@ -2,6 +2,7 @@ import slugify from 'slug'
|
||||
export default async function uniqueSlug(string, isUnique) {
|
||||
const slug = slugify(string || 'anonymous', {
|
||||
lower: true,
|
||||
multicharmap: { Ä: 'AE', ä: 'ae', Ö: 'OE', ö: 'oe', Ü: 'UE', ü: 'ue', ß: 'ss' },
|
||||
})
|
||||
if (await isUnique(slug)) return slug
|
||||
|
||||
|
||||
@ -18,4 +18,16 @@ describe('uniqueSlug', () => {
|
||||
const isUnique = jest.fn().mockResolvedValue(true)
|
||||
expect(uniqueSlug(string, isUnique)).resolves.toEqual('anonymous')
|
||||
})
|
||||
|
||||
it('Converts umlaut to a two letter equivalent', async () => {
|
||||
const umlaut = 'ÄÖÜäöüß'
|
||||
const isUnique = jest.fn().mockResolvedValue(true)
|
||||
await expect(uniqueSlug(umlaut, isUnique)).resolves.toEqual('aeoeueaeoeuess')
|
||||
})
|
||||
|
||||
it('Removes Spanish enya and diacritics', async () => {
|
||||
const diacritics = 'áàéèíìóòúùñçÁÀÉÈÍÌÓÒÚÙÑÇ'
|
||||
const isUnique = jest.fn().mockResolvedValue(true)
|
||||
await expect(uniqueSlug(diacritics, isUnique)).resolves.toEqual('aaeeiioouuncaaeeiioouunc')
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,8 +2,6 @@ import { UserInputError } from 'apollo-server'
|
||||
|
||||
const COMMENT_MIN_LENGTH = 1
|
||||
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
|
||||
const NO_CATEGORIES_ERR_MESSAGE =
|
||||
'You cannot save a post without at least one category or more than three'
|
||||
const USERNAME_MIN_LENGTH = 3
|
||||
const validateCreateComment = async (resolve, root, args, context, info) => {
|
||||
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
@ -46,20 +44,6 @@ const validateUpdateComment = async (resolve, root, args, context, info) => {
|
||||
return resolve(root, args, context, info)
|
||||
}
|
||||
|
||||
const validatePost = async (resolve, root, args, context, info) => {
|
||||
const { categoryIds } = args
|
||||
if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) {
|
||||
throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE)
|
||||
}
|
||||
return resolve(root, args, context, info)
|
||||
}
|
||||
|
||||
const validateUpdatePost = async (resolve, root, args, context, info) => {
|
||||
const { categoryIds } = args
|
||||
if (typeof categoryIds === 'undefined') return resolve(root, args, context, info)
|
||||
return validatePost(resolve, root, args, context, info)
|
||||
}
|
||||
|
||||
const validateReport = async (resolve, root, args, context, info) => {
|
||||
const { resourceId } = args
|
||||
const { user } = context
|
||||
@ -138,8 +122,6 @@ export default {
|
||||
Mutation: {
|
||||
CreateComment: validateCreateComment,
|
||||
UpdateComment: validateUpdateComment,
|
||||
CreatePost: validatePost,
|
||||
UpdatePost: validateUpdatePost,
|
||||
UpdateUser: validateUpdateUser,
|
||||
fileReport: validateReport,
|
||||
review: validateReview,
|
||||
|
||||
@ -30,27 +30,7 @@ const updateCommentMutation = gql`
|
||||
}
|
||||
}
|
||||
`
|
||||
const createPostMutation = gql`
|
||||
mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) {
|
||||
CreatePost(
|
||||
id: $id
|
||||
title: $title
|
||||
content: $content
|
||||
language: $language
|
||||
categoryIds: $categoryIds
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const updatePostMutation = gql`
|
||||
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) {
|
||||
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const reportMutation = gql`
|
||||
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
|
||||
fileReport(
|
||||
@ -227,104 +207,6 @@ describe('validateCreateComment', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePost', () => {
|
||||
let createPostVariables
|
||||
beforeEach(async () => {
|
||||
createPostVariables = {
|
||||
title: 'I am a title',
|
||||
content: 'Some content',
|
||||
}
|
||||
authenticatedUser = await commentingUser.toJson()
|
||||
})
|
||||
|
||||
describe('categories', () => {
|
||||
describe('null', () => {
|
||||
it('throws UserInputError', async () => {
|
||||
createPostVariables = { ...createPostVariables, categoryIds: null }
|
||||
await expect(
|
||||
mutate({ mutation: createPostMutation, variables: createPostVariables }),
|
||||
).resolves.toMatchObject({
|
||||
data: { CreatePost: null },
|
||||
errors: [
|
||||
{
|
||||
message: 'You cannot save a post without at least one category or more than three',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty', () => {
|
||||
it('throws UserInputError', async () => {
|
||||
createPostVariables = { ...createPostVariables, categoryIds: [] }
|
||||
await expect(
|
||||
mutate({ mutation: createPostMutation, variables: createPostVariables }),
|
||||
).resolves.toMatchObject({
|
||||
data: { CreatePost: null },
|
||||
errors: [
|
||||
{
|
||||
message: 'You cannot save a post without at least one category or more than three',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('more than 3 categoryIds', () => {
|
||||
it('throws UserInputError', async () => {
|
||||
createPostVariables = {
|
||||
...createPostVariables,
|
||||
categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'],
|
||||
}
|
||||
await expect(
|
||||
mutate({ mutation: createPostMutation, variables: createPostVariables }),
|
||||
).resolves.toMatchObject({
|
||||
data: { CreatePost: null },
|
||||
errors: [
|
||||
{
|
||||
message: 'You cannot save a post without at least one category or more than three',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUpdatePost', () => {
|
||||
describe('post created without categories somehow', () => {
|
||||
let owner, updatePostVariables
|
||||
beforeEach(async () => {
|
||||
const postSomehowCreated = await neode.create('Post', {
|
||||
id: 'how-was-this-created',
|
||||
})
|
||||
owner = await neode.create('User', {
|
||||
id: 'author-of-post-without-category',
|
||||
slug: 'hacker',
|
||||
})
|
||||
await postSomehowCreated.relateTo(owner, 'author')
|
||||
authenticatedUser = await owner.toJson()
|
||||
updatePostVariables = {
|
||||
id: 'how-was-this-created',
|
||||
title: 'I am a title',
|
||||
content: 'Some content',
|
||||
categoryIds: [],
|
||||
}
|
||||
})
|
||||
|
||||
it('requires at least one category for successful update', async () => {
|
||||
await expect(
|
||||
mutate({ mutation: updatePostMutation, variables: updatePostVariables }),
|
||||
).resolves.toMatchObject({
|
||||
data: { UpdatePost: null },
|
||||
errors: [
|
||||
{ message: 'You cannot save a post without at least one category or more than three' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateReport', () => {
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
export default {
|
||||
code: { type: 'string', primary: true },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
token: { type: 'string', primary: true, token: true },
|
||||
generatedBy: {
|
||||
expiresAt: { type: 'string', isoDate: true, default: null },
|
||||
generated: {
|
||||
type: 'relationship',
|
||||
relationship: 'GENERATED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
activated: {
|
||||
redeemed: {
|
||||
type: 'relationship',
|
||||
relationship: 'ACTIVATED',
|
||||
target: 'EmailAddress',
|
||||
direction: 'out',
|
||||
relationship: 'REDEEMED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
}
|
||||
@ -100,6 +100,18 @@ export default {
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
inviteCodes: {
|
||||
type: 'relationship',
|
||||
relationship: 'GENERATED',
|
||||
target: 'InviteCode',
|
||||
direction: 'out',
|
||||
},
|
||||
redeemedInviteCode: {
|
||||
type: 'relationship',
|
||||
relationship: 'REDEEMED',
|
||||
target: 'InviteCode',
|
||||
direction: 'out',
|
||||
},
|
||||
termsAndConditionsAgreedVersion: {
|
||||
type: 'string',
|
||||
allow: [null],
|
||||
|
||||
@ -15,4 +15,5 @@ export default {
|
||||
Donations: require('./Donations.js').default,
|
||||
Report: require('./Report.js').default,
|
||||
Migration: require('./Migration.js').default,
|
||||
InviteCode: require('./InviteCode.js').default,
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
export default function generateInviteCode() {
|
||||
// 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z])
|
||||
return Array.from({ length: 6 }, (n = Math.floor(Math.random() * 36)) => {
|
||||
// n > 9: it is a letter (ASCII 65 is A) -> 10 + 55 = 65
|
||||
// else: it is a number (ASCII 48 is 0) -> 0 + 48 = 48
|
||||
return String.fromCharCode(n > 9 ? n + 55 : n + 48)
|
||||
}).join('')
|
||||
}
|
||||
109
backend/src/schema/resolvers/inviteCodes.js
Normal file
109
backend/src/schema/resolvers/inviteCodes.js
Normal file
@ -0,0 +1,109 @@
|
||||
import generateInviteCode from './helpers/generateInviteCode'
|
||||
import Resolver from './helpers/Resolver'
|
||||
|
||||
const uniqueInviteCode = async (session, code) => {
|
||||
return session.readTransaction(async (txc) => {
|
||||
const result = await txc.run(`MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`, {
|
||||
code,
|
||||
})
|
||||
return parseInt(String(result.records[0].get('count'))) === 0
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
MyInviteCodes: async (_parent, args, context, _resolveInfo) => {
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = context
|
||||
const session = context.driver.session()
|
||||
const readTxResultPromise = session.readTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (user:User {id: $userId})-[:GENERATED]->(ic:InviteCode)
|
||||
RETURN properties(ic) AS inviteCodes`,
|
||||
{
|
||||
userId,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('inviteCodes'))
|
||||
})
|
||||
try {
|
||||
const txResult = await readTxResultPromise
|
||||
return txResult
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
isValidInviteCode: async (_parent, args, context, _resolveInfo) => {
|
||||
const { code } = args
|
||||
if (!code) return false
|
||||
const session = context.driver.session()
|
||||
const readTxResultPromise = session.readTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (ic:InviteCode { code: toUpper($code) })
|
||||
RETURN
|
||||
CASE
|
||||
WHEN ic.expiresAt IS NULL THEN true
|
||||
WHEN datetime(ic.expiresAt) >= datetime() THEN true
|
||||
ELSE false END AS result`,
|
||||
{
|
||||
code,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('result'))
|
||||
})
|
||||
try {
|
||||
const txResult = await readTxResultPromise
|
||||
return !!txResult[0]
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
GenerateInviteCode: async (_parent, args, context, _resolveInfo) => {
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = context
|
||||
const session = context.driver.session()
|
||||
let code = generateInviteCode()
|
||||
while (!(await uniqueInviteCode(session, code))) {
|
||||
code = generateInviteCode()
|
||||
}
|
||||
const writeTxResultPromise = session.writeTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (user:User {id: $userId})
|
||||
MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code })
|
||||
ON CREATE SET
|
||||
ic.createdAt = toString(datetime()),
|
||||
ic.expiresAt = $expiresAt
|
||||
RETURN ic AS inviteCode`,
|
||||
{
|
||||
userId,
|
||||
code,
|
||||
expiresAt: args.expiresAt,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('inviteCode').properties)
|
||||
})
|
||||
try {
|
||||
const txResult = await writeTxResultPromise
|
||||
return txResult[0]
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
InviteCode: {
|
||||
...Resolver('InviteCode', {
|
||||
idAttribute: 'code',
|
||||
undefinedToNull: ['expiresAt'],
|
||||
hasOne: {
|
||||
generatedBy: '<-[:GENERATED]-(related:User)',
|
||||
},
|
||||
hasMany: {
|
||||
redeemedBy: '<-[:REDEEMED]-(related:User)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
200
backend/src/schema/resolvers/inviteCodes.spec.js
Normal file
200
backend/src/schema/resolvers/inviteCodes.spec.js
Normal file
@ -0,0 +1,200 @@
|
||||
import Factory, { cleanDatabase } from '../../db/factories'
|
||||
import { getDriver } from '../../db/neo4j'
|
||||
import { gql } from '../../helpers/jest'
|
||||
import createServer from '../../server'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
|
||||
let user
|
||||
let query
|
||||
let mutate
|
||||
|
||||
const driver = getDriver()
|
||||
|
||||
const generateInviteCodeMutation = gql`
|
||||
mutation($expiresAt: String = null) {
|
||||
GenerateInviteCode(expiresAt: $expiresAt) {
|
||||
code
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const myInviteCodesQuery = gql`
|
||||
query {
|
||||
MyInviteCodes {
|
||||
code
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const isValidInviteCodeQuery = gql`
|
||||
query($code: ID!) {
|
||||
isValidInviteCode(code: $code)
|
||||
}
|
||||
`
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanDatabase()
|
||||
const { server } = createServer({
|
||||
context: () => {
|
||||
return {
|
||||
driver,
|
||||
user,
|
||||
}
|
||||
},
|
||||
})
|
||||
query = createTestClient(server).query
|
||||
mutate = createTestClient(server).mutate
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
describe('inviteCodes', () => {
|
||||
describe('as unauthenticated user', () => {
|
||||
it('cannot generate invite codes', async () => {
|
||||
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
extensions: { code: 'INTERNAL_SERVER_ERROR' },
|
||||
}),
|
||||
]),
|
||||
data: {
|
||||
GenerateInviteCode: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('cannot query invite codes', async () => {
|
||||
await expect(query({ query: myInviteCodesQuery })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
extensions: { code: 'INTERNAL_SERVER_ERROR' },
|
||||
}),
|
||||
]),
|
||||
data: {
|
||||
MyInviteCodes: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('as authenticated user', () => {
|
||||
beforeAll(async () => {
|
||||
const authenticatedUser = await Factory.build(
|
||||
'user',
|
||||
{
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
email: 'user@example.org',
|
||||
password: '1234',
|
||||
},
|
||||
)
|
||||
user = await authenticatedUser.toJson()
|
||||
})
|
||||
|
||||
it('generates an invite code without expiresAt', async () => {
|
||||
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: undefined,
|
||||
data: {
|
||||
GenerateInviteCode: {
|
||||
code: expect.stringMatching(/^[0-9A-Z]{6,6}$/),
|
||||
expiresAt: null,
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('generates an invite code with expiresAt', async () => {
|
||||
const nextWeek = new Date()
|
||||
nextWeek.setDate(nextWeek.getDate() + 7)
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: generateInviteCodeMutation,
|
||||
variables: { expiresAt: nextWeek.toISOString() },
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: undefined,
|
||||
data: {
|
||||
GenerateInviteCode: {
|
||||
code: expect.stringMatching(/^[0-9A-Z]{6,6}$/),
|
||||
expiresAt: nextWeek.toISOString(),
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
let inviteCodes
|
||||
|
||||
it('returns the created invite codes when queried', async () => {
|
||||
const response = await query({ query: myInviteCodesQuery })
|
||||
inviteCodes = response.data.MyInviteCodes
|
||||
expect(inviteCodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not return the created invite codes of other users when queried', async () => {
|
||||
await Factory.build('inviteCode')
|
||||
const response = await query({ query: myInviteCodesQuery })
|
||||
inviteCodes = response.data.MyInviteCodes
|
||||
expect(inviteCodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('validates an invite code without expiresAt', async () => {
|
||||
const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: unExpiringInviteCode },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('validates an invite code in lower case', async () => {
|
||||
const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: unExpiringInviteCode.toLowerCase() },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('validates an invite code with expiresAt in the future', async () => {
|
||||
const expiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt !== null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: expiringInviteCode },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not validate an invite code which expired in the past', async () => {
|
||||
const lastWeek = new Date()
|
||||
lastWeek.setDate(lastWeek.getDate() - 7)
|
||||
const inviteCode = await Factory.build('inviteCode', {
|
||||
expiresAt: lastWeek.toISOString(),
|
||||
})
|
||||
const code = inviteCode.get('code')
|
||||
const result = await query({ query: isValidInviteCodeQuery, variables: { code } })
|
||||
expect(result.data.isValidInviteCode).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not validate an invite code which does not exits', async () => {
|
||||
const result = await query({ query: isValidInviteCodeQuery, variables: { code: 'AAA' } })
|
||||
expect(result.data.isValidInviteCode).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -76,7 +76,6 @@ export default {
|
||||
},
|
||||
Mutation: {
|
||||
CreatePost: async (_parent, params, context, _resolveInfo) => {
|
||||
const { categoryIds } = params
|
||||
const { image: imageInput } = params
|
||||
delete params.categoryIds
|
||||
delete params.image
|
||||
@ -92,13 +91,9 @@ export default {
|
||||
WITH post
|
||||
MATCH (author:User {id: $userId})
|
||||
MERGE (post)<-[:WROTE]-(author)
|
||||
WITH post
|
||||
UNWIND $categoryIds AS categoryId
|
||||
MATCH (category:Category {id: categoryId})
|
||||
MERGE (post)-[:CATEGORIZED]->(category)
|
||||
RETURN post {.*}
|
||||
`,
|
||||
{ userId: context.user.id, categoryIds, params },
|
||||
{ userId: context.user.id, params },
|
||||
)
|
||||
const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
|
||||
if (imageInput) {
|
||||
|
||||
@ -317,19 +317,6 @@ describe('CreatePost', () => {
|
||||
expected,
|
||||
)
|
||||
})
|
||||
|
||||
describe('language', () => {
|
||||
beforeEach(() => {
|
||||
variables = { ...variables, language: 'es' }
|
||||
})
|
||||
|
||||
it('allows a user to set the language of the post', async () => {
|
||||
const expected = { data: { CreatePost: { language: 'es' } } }
|
||||
await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -293,6 +293,7 @@ export default {
|
||||
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
|
||||
invitedBy: '<-[:INVITED]-(related:User)',
|
||||
location: '-[:IS_IN]->(related:Location)',
|
||||
redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)',
|
||||
},
|
||||
hasMany: {
|
||||
followedBy: '<-[:FOLLOWS]-(related:User)',
|
||||
@ -304,6 +305,7 @@ export default {
|
||||
shouted: '-[:SHOUTED]->(related:Post)',
|
||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||
badges: '<-[:REWARDED]-(related:Badge)',
|
||||
inviteCodes: '-[:GENERATED]->(related:InviteCode)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
17
backend/src/schema/types/type/InviteCode.gql
Normal file
17
backend/src/schema/types/type/InviteCode.gql
Normal file
@ -0,0 +1,17 @@
|
||||
type InviteCode {
|
||||
code: ID!
|
||||
createdAt: String!
|
||||
generatedBy: User @relation(name: "GENERATED", direction: "IN")
|
||||
redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN")
|
||||
expiresAt: String
|
||||
}
|
||||
|
||||
|
||||
type Mutation {
|
||||
GenerateInviteCode(expiresAt: String = null): InviteCode
|
||||
}
|
||||
|
||||
type Query {
|
||||
MyInviteCodes: [InviteCode]
|
||||
isValidInviteCode(code: ID!): Boolean
|
||||
}
|
||||
@ -136,7 +136,7 @@ type Post {
|
||||
"""
|
||||
)
|
||||
tags: [Tag]! @relation(name: "TAGGED", direction: "OUT")
|
||||
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
|
||||
comments: [Comment]! @relation(name: "COMMENTS", direction: "IN")
|
||||
commentsCount: Int!
|
||||
|
||||
@ -56,6 +56,9 @@ type User {
|
||||
followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN")
|
||||
followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)")
|
||||
|
||||
inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT")
|
||||
redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
|
||||
|
||||
# Is the currently logged in user following that user?
|
||||
followedByCurrentUser: Boolean! @cypher(
|
||||
statement: """
|
||||
@ -83,7 +86,7 @@ type User {
|
||||
RETURN COUNT(user) >= 1
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# contributions: [WrittenPost]!
|
||||
# contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]!
|
||||
# @cypher(
|
||||
@ -104,7 +107,7 @@ type User {
|
||||
shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT")
|
||||
shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")
|
||||
|
||||
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
|
||||
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
|
||||
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
|
||||
|
||||
@ -6302,6 +6302,11 @@ knuth-shuffle-seeded@^1.0.6:
|
||||
dependencies:
|
||||
seed-random "~2.2.0"
|
||||
|
||||
languagedetect@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/languagedetect/-/languagedetect-2.0.0.tgz#4b8fa2b7593b2a3a02fb1100891041c53238936c"
|
||||
integrity sha512-AZb/liiQ+6ZoTj4f1J0aE6OkzhCo8fyH+tuSaPfSo8YHCWLFJrdSixhtO2TYdIkjcDQNaR4RmGaV2A5FJklDMQ==
|
||||
|
||||
latest-version@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { config, mount } from '@vue/test-utils'
|
||||
import ContributionForm from './ContributionForm.vue'
|
||||
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import PostMutations from '~/graphql/PostMutations.js'
|
||||
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
|
||||
|
||||
import ImageUploader from '~/components/ImageUploader/ImageUploader'
|
||||
import MutationObserver from 'mutation-observer'
|
||||
@ -17,45 +15,8 @@ config.stubs['client-only'] = '<span><slot /></span>'
|
||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'cat3',
|
||||
slug: 'health-wellbeing',
|
||||
icon: 'medkit',
|
||||
},
|
||||
{
|
||||
id: 'cat12',
|
||||
slug: 'it-internet-data-privacy',
|
||||
icon: 'mouse-pointer',
|
||||
},
|
||||
{
|
||||
id: 'cat9',
|
||||
slug: 'democracy-politics',
|
||||
icon: 'university',
|
||||
},
|
||||
{
|
||||
id: 'cat15',
|
||||
slug: 'consumption-sustainability',
|
||||
icon: 'shopping-cart',
|
||||
},
|
||||
{
|
||||
id: 'cat4',
|
||||
slug: 'environment-nature',
|
||||
icon: 'tree',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ContributionForm.vue', () => {
|
||||
let wrapper,
|
||||
postTitleInput,
|
||||
expectedParams,
|
||||
cancelBtn,
|
||||
mocks,
|
||||
propsData,
|
||||
categoryIds,
|
||||
englishLanguage,
|
||||
deutschLanguage,
|
||||
dataPrivacyButton
|
||||
let wrapper, postTitleInput, expectedParams, cancelBtn, mocks, propsData
|
||||
const postTitle = 'this is a title for a post'
|
||||
const postTitleTooShort = 'xx'
|
||||
let postTitleTooLong = ''
|
||||
@ -82,8 +43,6 @@ describe('ContributionForm.vue', () => {
|
||||
slug: 'this-is-a-title-for-a-post',
|
||||
content: postContent,
|
||||
contentExcerpt: postContent,
|
||||
language: 'en',
|
||||
categoryIds,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@ -136,18 +95,9 @@ describe('ContributionForm.vue', () => {
|
||||
describe('CreatePost', () => {
|
||||
describe('invalid form submission', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper.find(CategoriesSelect).setData({ categories })
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
englishLanguage = wrapper
|
||||
.findAll('li')
|
||||
.filter((language) => language.text() === 'English')
|
||||
englishLanguage.trigger('click')
|
||||
dataPrivacyButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat12"]')
|
||||
dataPrivacyButton.trigger('click')
|
||||
})
|
||||
|
||||
it('title cannot be empty', async () => {
|
||||
@ -173,22 +123,6 @@ describe('ContributionForm.vue', () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has at least one category', async () => {
|
||||
dataPrivacyButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat12"]')
|
||||
dataPrivacyButton.trigger('click')
|
||||
wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has no more than three categories', async () => {
|
||||
wrapper.vm.formData.categoryIds = ['cat4', 'cat9', 'cat15', 'cat27']
|
||||
await Vue.nextTick()
|
||||
wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('valid form submission', () => {
|
||||
@ -198,43 +132,20 @@ describe('ContributionForm.vue', () => {
|
||||
variables: {
|
||||
title: postTitle,
|
||||
content: postContent,
|
||||
language: 'en',
|
||||
id: null,
|
||||
categoryIds: ['cat12'],
|
||||
image: null,
|
||||
},
|
||||
}
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
wrapper.find(CategoriesSelect).setData({ categories })
|
||||
englishLanguage = wrapper
|
||||
.findAll('li')
|
||||
.filter((language) => language.text() === 'English')
|
||||
englishLanguage.trigger('click')
|
||||
await Vue.nextTick()
|
||||
dataPrivacyButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat12"]')
|
||||
dataPrivacyButton.trigger('click')
|
||||
await Vue.nextTick()
|
||||
})
|
||||
|
||||
it('creates a post with valid title, content, and at least one category', async () => {
|
||||
it('creates a post with valid title and content', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports changing the language', async () => {
|
||||
expectedParams.variables.language = 'de'
|
||||
deutschLanguage = wrapper
|
||||
.findAll('li')
|
||||
.filter((language) => language.text() === 'Deutsch')
|
||||
deutschLanguage.trigger('click')
|
||||
wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports adding a teaser image', async () => {
|
||||
expectedParams.variables.image = {
|
||||
aspectRatio: null,
|
||||
@ -292,18 +203,6 @@ describe('ContributionForm.vue', () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
categoryIds = ['cat12']
|
||||
wrapper.find(CategoriesSelect).setData({ categories })
|
||||
englishLanguage = wrapper
|
||||
.findAll('li')
|
||||
.filter((language) => language.text() === 'English')
|
||||
englishLanguage.trigger('click')
|
||||
await Vue.nextTick()
|
||||
dataPrivacyButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat12"]')
|
||||
dataPrivacyButton.trigger('click')
|
||||
await Vue.nextTick()
|
||||
})
|
||||
|
||||
it('shows an error toaster when apollo mutation rejects', async () => {
|
||||
@ -322,14 +221,7 @@ describe('ContributionForm.vue', () => {
|
||||
slug: 'dies-ist-ein-post',
|
||||
title: 'dies ist ein Post',
|
||||
content: 'auf Deutsch geschrieben',
|
||||
language: 'de',
|
||||
image,
|
||||
categories: [
|
||||
{
|
||||
id: 'cat12',
|
||||
name: 'Democracy & Politics',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
@ -352,8 +244,6 @@ describe('ContributionForm.vue', () => {
|
||||
slug: 'this-is-a-title-for-a-post',
|
||||
content: postContent,
|
||||
contentExcerpt: postContent,
|
||||
language: 'en',
|
||||
categoryIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -363,9 +253,7 @@ describe('ContributionForm.vue', () => {
|
||||
variables: {
|
||||
title: propsData.contribution.title,
|
||||
content: propsData.contribution.content,
|
||||
language: propsData.contribution.language,
|
||||
id: propsData.contribution.id,
|
||||
categoryIds: ['cat12'],
|
||||
image: {
|
||||
sensitive: false,
|
||||
},
|
||||
@ -380,18 +268,6 @@ describe('ContributionForm.vue', () => {
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports updating categories', async () => {
|
||||
expectedParams.variables.categoryIds.push('cat3')
|
||||
wrapper.find(CategoriesSelect).setData({ categories })
|
||||
await Vue.nextTick()
|
||||
const healthWellbeingButton = await wrapper
|
||||
.find(CategoriesSelect)
|
||||
.find('[data-test="category-buttons-cat3"]')
|
||||
healthWellbeingButton.trigger('click')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports deleting a teaser image', async () => {
|
||||
expectedParams.variables.image = null
|
||||
propsData.contribution.image = { url: '/uploads/someimage.png' }
|
||||
|
||||
@ -50,22 +50,6 @@
|
||||
{{ contentLength }}
|
||||
<base-icon v-if="errors && errors.content" name="warning" />
|
||||
</ds-chip>
|
||||
<categories-select model="categoryIds" :existingCategoryIds="formData.categoryIds" />
|
||||
<ds-chip size="base" :color="errors && errors.categoryIds && 'danger'">
|
||||
{{ formData.categoryIds.length }} / 3
|
||||
<base-icon v-if="errors && errors.categoryIds" name="warning" />
|
||||
</ds-chip>
|
||||
<ds-select
|
||||
model="language"
|
||||
icon="globe"
|
||||
class="select-field"
|
||||
:options="languageOptions"
|
||||
:placeholder="$t('contribution.languageSelectText')"
|
||||
:label="$t('contribution.languageSelectLabel')"
|
||||
/>
|
||||
<ds-chip v-if="errors && errors.language" size="base" color="danger">
|
||||
<base-icon name="warning" />
|
||||
</ds-chip>
|
||||
<div class="buttons">
|
||||
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()" danger>
|
||||
{{ $t('actions.cancel') }}
|
||||
@ -81,19 +65,15 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import { mapGetters } from 'vuex'
|
||||
import HcEditor from '~/components/Editor/Editor'
|
||||
import locales from '~/locales'
|
||||
import PostMutations from '~/graphql/PostMutations.js'
|
||||
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
|
||||
import ImageUploader from '~/components/ImageUploader/ImageUploader'
|
||||
import links from '~/constants/links.js'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HcEditor,
|
||||
CategoriesSelect,
|
||||
ImageUploader,
|
||||
},
|
||||
props: {
|
||||
@ -103,11 +83,7 @@ export default {
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const { title, content, image, language, categories } = this.contribution
|
||||
|
||||
const languageOptions = orderBy(locales, 'name').map((locale) => {
|
||||
return { label: locale.name, value: locale.code }
|
||||
})
|
||||
const { title, content, image } = this.contribution
|
||||
const { sensitive: imageBlurred = false, aspectRatio: imageAspectRatio = null } = image || {}
|
||||
|
||||
return {
|
||||
@ -118,26 +94,12 @@ export default {
|
||||
image: image || null,
|
||||
imageAspectRatio,
|
||||
imageBlurred,
|
||||
language: languageOptions.find((option) => option.value === language) || null,
|
||||
categoryIds: categories ? categories.map((category) => category.id) : [],
|
||||
},
|
||||
formSchema: {
|
||||
title: { required: true, min: 3, max: 100 },
|
||||
content: { required: true },
|
||||
categoryIds: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
validator: (_, value = []) => {
|
||||
if (value.length === 0 || value.length > 3) {
|
||||
return [new Error(this.$t('common.validations.categories'))]
|
||||
}
|
||||
return []
|
||||
},
|
||||
},
|
||||
language: { required: true },
|
||||
imageBlurred: { required: false },
|
||||
},
|
||||
languageOptions,
|
||||
loading: false,
|
||||
users: [],
|
||||
hashtags: [],
|
||||
@ -155,7 +117,7 @@ export default {
|
||||
methods: {
|
||||
submit() {
|
||||
let image = null
|
||||
const { title, content, categoryIds } = this.formData
|
||||
const { title, content } = this.formData
|
||||
if (this.formData.image) {
|
||||
image = {
|
||||
sensitive: this.formData.imageBlurred,
|
||||
@ -172,9 +134,7 @@ export default {
|
||||
variables: {
|
||||
title,
|
||||
content,
|
||||
categoryIds,
|
||||
id: this.contribution.id || null,
|
||||
language: this.formData.language.value,
|
||||
image,
|
||||
},
|
||||
})
|
||||
|
||||
@ -3,20 +3,8 @@ import gql from 'graphql-tag'
|
||||
export default () => {
|
||||
return {
|
||||
CreatePost: gql`
|
||||
mutation(
|
||||
$title: String!
|
||||
$content: String!
|
||||
$language: String
|
||||
$categoryIds: [ID]
|
||||
$image: ImageInput
|
||||
) {
|
||||
CreatePost(
|
||||
title: $title
|
||||
content: $content
|
||||
language: $language
|
||||
categoryIds: $categoryIds
|
||||
image: $image
|
||||
) {
|
||||
mutation($title: String!, $content: String!, $categoryIds: [ID], $image: ImageInput) {
|
||||
CreatePost(title: $title, content: $content, categoryIds: $categoryIds, image: $image) {
|
||||
title
|
||||
slug
|
||||
content
|
||||
@ -34,7 +22,6 @@ export default () => {
|
||||
$id: ID!
|
||||
$title: String!
|
||||
$content: String!
|
||||
$language: String
|
||||
$image: ImageInput
|
||||
$categoryIds: [ID]
|
||||
) {
|
||||
@ -42,7 +29,6 @@ export default () => {
|
||||
id: $id
|
||||
title: $title
|
||||
content: $content
|
||||
language: $language
|
||||
image: $image
|
||||
categoryIds: $categoryIds
|
||||
) {
|
||||
|
||||
@ -46,21 +46,6 @@
|
||||
<content-viewer class="content hyphenate-text" :content="post.content" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<ds-space margin="xx-large" />
|
||||
<!-- Categories -->
|
||||
<div class="categories">
|
||||
<ds-space margin="xx-small" />
|
||||
<hc-category
|
||||
v-for="category in post.categories"
|
||||
:key="category.id"
|
||||
:icon="category.icon"
|
||||
:name="$t(`contribution.category.name.${category.slug}`)"
|
||||
/>
|
||||
<!-- Post language -->
|
||||
<ds-tag v-if="post.language" class="category-tag language">
|
||||
<base-icon name="globe" />
|
||||
{{ post.language.toUpperCase() }}
|
||||
</ds-tag>
|
||||
</div>
|
||||
<ds-space margin-bottom="small" />
|
||||
<!-- Tags -->
|
||||
<div v-if="post.tags && post.tags.length" class="tags">
|
||||
@ -110,7 +95,6 @@
|
||||
|
||||
<script>
|
||||
import ContentViewer from '~/components/Editor/ContentViewer'
|
||||
import HcCategory from '~/components/Category'
|
||||
import HcHashtag from '~/components/Hashtag/Hashtag'
|
||||
import ContentMenu from '~/components/ContentMenu/ContentMenu'
|
||||
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
||||
@ -134,7 +118,6 @@ export default {
|
||||
mode: 'out-in',
|
||||
},
|
||||
components: {
|
||||
HcCategory,
|
||||
HcHashtag,
|
||||
UserTeaser,
|
||||
HcShoutButton,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user