Merge branch 'master' of https://github.com/Human-Connection/Human-Connection into 1017-send-out-notifications-on-create-omment

# Conflicts:
#	backend/src/schema/resolvers/notifications.spec.js
This commit is contained in:
Wolfgang Huß 2019-08-05 10:15:20 +02:00
commit 22b9bf77fa
108 changed files with 20690 additions and 1763 deletions

View File

@ -26,8 +26,8 @@ script:
# Backend
- docker-compose exec backend yarn run lint
- docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage
- docker-compose exec backend yarn run db:reset
- docker-compose exec backend yarn run db:seed
- docker-compose exec backend yarn run db:reset
# ActivityPub cucumber testing temporarily disabled because it's too buggy
# - docker-compose exec backend yarn run test:cucumber --tags "not @wip"
# - docker-compose exec backend yarn run db:reset

View File

@ -28,6 +28,7 @@
* [HTTPS](deployment/digital-ocean/https/README.md)
* [Human Connection](deployment/human-connection/README.md)
* [Mailserver](deployment/human-connection/mailserver/README.md)
* [Maintenance](deployment/human-connection/maintenance/README.md)
* [Volumes](deployment/volumes/README.md)
* [Neo4J Offline-Backups](deployment/volumes/neo4j-offline-backup/README.md)
* [Volume Snapshots](deployment/volumes/volume-snapshots/README.md)

View File

@ -1,4 +1,4 @@
FROM node:12.6-alpine as base
FROM node:12.7-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

@ -9,6 +9,7 @@
"dev": "nodemon --exec babel-node src/ -e js,gql",
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql",
"lint": "eslint src --config .eslintrc.js",
"jest": "jest --forceExit --detectOpenHandles --runInBand",
"test": "run-s test:jest test:cucumber",
"test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null",
"test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null",
@ -47,13 +48,14 @@
"apollo-client": "~2.6.3",
"apollo-link-context": "~1.0.18",
"apollo-link-http": "~1.5.15",
"apollo-server": "~2.6.9",
"apollo-server-express": "^2.6.9",
"apollo-server": "~2.8.1",
"apollo-server-express": "^2.8.1",
"babel-plugin-transform-runtime": "^6.23.0",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~5.2.0",
"date-fns": "2.0.0-beta.1",
"date-fns": "2.0.0-beta.3",
"debug": "~4.1.1",
"dotenv": "~8.0.0",
"express": "^4.17.1",
@ -61,17 +63,33 @@
"graphql": "~14.4.2",
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.2",
"graphql-shield": "~6.0.3",
"graphql-middleware": "~3.0.3",
"graphql-shield": "~6.0.4",
"graphql-tag": "~2.10.1",
"helmet": "~3.18.0",
"helmet": "~3.20.0",
"jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8",
"lodash": "~4.17.14",
"merge-graphql-schemas": "^1.5.8",
"neo4j-driver": "~1.7.4",
"merge-graphql-schemas": "^1.7.0",
"metascraper": "^4.10.3",
"metascraper-audio": "^5.6.5",
"metascraper-author": "^5.6.5",
"metascraper-clearbit-logo": "^5.3.0",
"metascraper-date": "^5.6.5",
"metascraper-description": "^5.6.5",
"metascraper-image": "^5.6.5",
"metascraper-lang": "^5.6.3",
"metascraper-lang-detector": "^4.8.5",
"metascraper-logo": "^5.6.5",
"metascraper-publisher": "^5.6.5",
"metascraper-soundcloud": "^5.6.5",
"metascraper-title": "^5.6.5",
"metascraper-url": "^5.6.5",
"metascraper-video": "^5.6.5",
"metascraper-youtube": "^5.6.3",
"neo4j-driver": "~1.7.5",
"neo4j-graphql-js": "^2.6.3",
"neode": "^0.2.16",
"neode": "^0.3.0",
"node-fetch": "~2.6.0",
"nodemailer": "^6.3.0",
"npm-run-all": "~4.1.5",
@ -83,23 +101,23 @@
"wait-on": "~3.3.0"
},
"devDependencies": {
"@babel/cli": "~7.5.0",
"@babel/core": "~7.5.4",
"@babel/node": "~7.5.0",
"@babel/cli": "~7.5.5",
"@babel/core": "~7.5.5",
"@babel/node": "~7.5.5",
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.5.4",
"@babel/register": "~7.4.4",
"apollo-server-testing": "~2.6.9",
"@babel/preset-env": "~7.5.5",
"@babel/register": "~7.5.5",
"apollo-server-testing": "~2.8.1",
"babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.2",
"babel-jest": "~24.8.0",
"chai": "~4.2.0",
"cucumber": "~5.1.0",
"eslint": "~6.0.1",
"eslint": "~6.1.0",
"eslint-config-prettier": "~6.0.0",
"eslint-config-standard": "~12.0.0",
"eslint-plugin-import": "~2.18.0",
"eslint-plugin-jest": "~22.8.0",
"eslint-config-standard": "~13.0.1",
"eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.14.1",
"eslint-plugin-node": "~9.1.0",
"eslint-plugin-prettier": "~3.1.0",
"eslint-plugin-promise": "~4.2.1",

View File

@ -35,8 +35,8 @@ export default class ActivityPub {
handleFollowActivity(activity) {
debug(`inside FOLLOW ${activity.actor}`)
let toActorName = extractNameFromId(activity.object)
let fromDomain = extractDomainFromUrl(activity.actor)
const toActorName = extractNameFromId(activity.object)
const fromDomain = extractDomainFromUrl(activity.actor)
const dataSource = this.dataSource
return new Promise((resolve, reject) => {
@ -53,7 +53,7 @@ export default class ActivityPub {
toActorObject = JSON.parse(toActorObject)
await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox)
let followersCollectionPage = await this.dataSource.getFollowersCollectionPage(
const followersCollectionPage = await this.dataSource.getFollowersCollectionPage(
activity.object,
)
@ -222,6 +222,7 @@ export default class ActivityPub {
})
}
}
async trySend(activity, fromName, host, url, tries = 5) {
try {
return await signAndSend(activity, fromName, host, url)

View File

@ -2,6 +2,7 @@ export default class Collections {
constructor(dataSource) {
this.dataSource = dataSource
}
getFollowersCollection(actorId) {
return this.dataSource.getFollowersCollection(actorId)
}

View File

@ -303,6 +303,7 @@ export default class NitroDataSource {
}),
)
}
async saveFollowingCollectionPage(followingCollection, onlyNewestItem = true) {
debug('inside saveFollowers')
let orderedItems = followingCollection.orderedItems
@ -470,6 +471,7 @@ export default class NitroDataSource {
throwErrorIfApolloErrorOccurred(result)
return result.data.SharedInboxEnpoint
}
async addSharedInboxEndpoint(uri) {
try {
const result = await this.client.mutate({

View File

@ -97,7 +97,7 @@ export function verifySignature(url, headers) {
// private: signing
function constructSigningString(url, headers) {
const urlObj = new URL(url)
let signingString = `(request-target): post ${urlObj.pathname}${
const signingString = `(request-target): post ${urlObj.pathname}${
urlObj.search !== '' ? urlObj.search : ''
}`
return Object.keys(headers).reduce((result, key) => {

View File

@ -40,70 +40,72 @@ export function signAndSend(activity, fromName, targetDomain, url) {
// fix for development: replace with http
url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url
debug(`passhprase = ${CONFIG.PRIVATE_KEY_PASSPHRASE}`)
return new Promise(async (resolve, reject) => {
return new Promise((resolve, reject) => {
debug('inside signAndSend')
// get the private key
const result = await activityPub.dataSource.client.query({
query: gql`
activityPub.dataSource.client
.query({
query: gql`
query {
User(slug: "${fromName}") {
privateKey
}
}
`,
})
})
.then(result => {
if (result.error) {
reject(result.error)
} else {
// add security context
const parsedActivity = JSON.parse(activity)
if (Array.isArray(parsedActivity['@context'])) {
parsedActivity['@context'].push('https://w3id.org/security/v1')
} else {
const context = [parsedActivity['@context']]
context.push('https://w3id.org/security/v1')
parsedActivity['@context'] = context
}
if (result.error) {
reject(result.error)
} else {
// add security context
const parsedActivity = JSON.parse(activity)
if (Array.isArray(parsedActivity['@context'])) {
parsedActivity['@context'].push('https://w3id.org/security/v1')
} else {
const context = [parsedActivity['@context']]
context.push('https://w3id.org/security/v1')
parsedActivity['@context'] = context
}
// deduplicate context strings
parsedActivity['@context'] = [...new Set(parsedActivity['@context'])]
const privateKey = result.data.User[0].privateKey
const date = new Date().toUTCString()
// deduplicate context strings
parsedActivity['@context'] = [...new Set(parsedActivity['@context'])]
const privateKey = result.data.User[0].privateKey
const date = new Date().toUTCString()
debug(`url = ${url}`)
request(
{
url: url,
headers: {
Host: targetDomain,
Date: date,
Signature: createSignature({
privateKey,
keyId: `${activityPub.endpoint}/activitypub/users/${fromName}#main-key`,
url,
debug(`url = ${url}`)
request(
{
url: url,
headers: {
Host: targetDomain,
Date: date,
Signature: createSignature({
privateKey,
keyId: `${activityPub.endpoint}/activitypub/users/${fromName}#main-key`,
url,
headers: {
Host: targetDomain,
Date: date,
'Content-Type': 'application/activity+json',
},
}),
'Content-Type': 'application/activity+json',
},
}),
'Content-Type': 'application/activity+json',
},
method: 'POST',
body: JSON.stringify(parsedActivity),
},
(error, response) => {
if (error) {
debug(`Error = ${JSON.stringify(error, null, 2)}`)
reject(error)
} else {
debug('Response Headers:', JSON.stringify(response.headers, null, 2))
debug('Response Body:', JSON.stringify(response.body, null, 2))
resolve()
}
},
)
}
method: 'POST',
body: JSON.stringify(parsedActivity),
},
(error, response) => {
if (error) {
debug(`Error = ${JSON.stringify(error, null, 2)}`)
reject(error)
} else {
debug('Response Headers:', JSON.stringify(response.headers, null, 2))
debug('Response Body:', JSON.stringify(response.body, null, 2))
resolve()
}
},
)
}
})
})
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -52,10 +52,12 @@ export default schema => {
if (CONFIG.DISABLED_MIDDLEWARES) {
const disabledMiddlewares = CONFIG.DISABLED_MIDDLEWARES.split(',')
order = order.filter(key => {
if (disabledMiddlewares.includes(key)) {
/* eslint-disable-next-line no-console */
console.log(`Warning: Disabled "${disabledMiddlewares}" middleware.`)
}
return !disabledMiddlewares.includes(key)
})
/* eslint-disable-next-line no-console */
console.log(`Warning: "${disabledMiddlewares}" middlewares have been disabled.`)
}
const appliedMiddlewares = order.map(key => middlewares[key])

View File

@ -1,4 +1,7 @@
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
import { neode } from '../bootstrap/neo4j'
const instance = neode()
/*
* TODO: implement
@ -7,7 +10,7 @@ import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
const isAuthenticated = rule({
cache: 'contextual',
})(async (_parent, _args, ctx, _info) => {
return ctx.user !== null
return ctx.user != null
})
const isModerator = rule()(async (parent, args, { user }, info) => {
@ -30,6 +33,14 @@ const isMyOwn = rule({
return context.user.id === parent.id
})
const isMySocialMedia = rule({
cache: 'no_cache',
})(async (_, args, { user }) => {
let socialMedia = await instance.find('SocialMedia', args.id)
socialMedia = await socialMedia.toJson()
return socialMedia.ownedBy.node.id === user.id
})
const belongsToMe = rule({
cache: 'no_cache',
})(async (_, args, context) => {
@ -86,8 +97,6 @@ const invitationLimitReached = rule({
return record.get('limitReached')
})
return limitReached
} catch (e) {
throw e
} finally {
session.close()
}
@ -136,6 +145,7 @@ const permissions = shield(
Query: {
'*': deny,
findPosts: allow,
embed: allow,
Category: allow,
Tag: allow,
Report: isModerator,
@ -162,7 +172,8 @@ const permissions = shield(
DeletePost: isAuthor,
report: isAuthenticated,
CreateSocialMedia: isAuthenticated,
DeleteSocialMedia: isAuthenticated,
UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia,
// AddBadgeRewarded: isAdmin,
// RemoveBadgeRewarded: isAdmin,
reward: isAdmin,
@ -176,6 +187,7 @@ const permissions = shield(
enable: isModerator,
disable: isModerator,
CreateComment: isAuthenticated,
UpdateComment: isAuthor,
DeleteComment: isAuthor,
DeleteUser: isDeletingOwnAccount,
requestPasswordReset: allow,

View File

@ -1,6 +1,6 @@
import slugify from 'slug'
export default async function uniqueSlug(string, isUnique) {
let slug = slugify(string || 'anonymous', {
const slug = slugify(string || 'anonymous', {
lower: true,
})
if (await isUnique(slug)) return slug

View File

@ -1,18 +0,0 @@
import { UserInputError } from 'apollo-server'
const validateUrl = async (resolve, root, args, context, info) => {
const { url } = args
const isValid = url.match(/^(?:https?:\/\/)(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
if (isValid) {
/* eslint-disable-next-line no-return-await */
return await resolve(root, args, context, info)
} else {
throw new UserInputError('Input is not a URL')
}
}
export default {
Mutation: {
CreateSocialMedia: validateUrl,
},
}

View File

@ -1,23 +1,8 @@
import { UserInputError } from 'apollo-server'
import Joi from '@hapi/joi'
const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const validate = schema => {
return async (resolve, root, args, context, info) => {
const validation = schema.validate(args)
if (validation.error) throw new UserInputError(validation.error)
return resolve(root, args, context, info)
}
}
const socialMediaSchema = Joi.object().keys({
url: Joi.string()
.uri()
.required(),
})
const validateCommentCreation = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args
@ -45,9 +30,19 @@ const validateCommentCreation = async (resolve, root, args, context, info) => {
}
}
const validateUpdateComment = async (resolve, root, args, context, info) => {
const COMMENT_MIN_LENGTH = 1
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
if (!args.content || content.length < COMMENT_MIN_LENGTH) {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
}
return resolve(root, args, context, info)
}
export default {
Mutation: {
CreateSocialMedia: validate(socialMediaSchema),
CreateComment: validateCommentCreation,
UpdateComment: validateUpdateComment,
},
}

View File

@ -8,8 +8,8 @@ import linkifyHtml from 'linkifyjs/html'
const embedToAnchor = content => {
const $ = cheerio.load(content)
$('div[data-url-embed]').each((i, el) => {
let url = el.attribs['data-url-embed']
let aTag = $(`<a href="${url}" target="_blank" data-url-embed="">${url}</a>`)
const url = el.attribs['data-url-embed']
const aTag = $(`<a href="${url}" target="_blank" data-url-embed="">${url}</a>`)
$(el).replaceWith(aTag)
})
return $('body').html()
@ -87,7 +87,7 @@ function clean(dirty) {
b: 'strong',
s: 'strike',
img: function(tagName, attribs) {
let src = attribs.src
const src = attribs.src
if (!src) {
// remove broken images

View File

@ -8,5 +8,6 @@ module.exports = {
relationship: 'BELONGS_TO',
target: 'User',
direction: 'out',
eager: true,
},
}

View File

@ -0,0 +1,15 @@
import uuid from 'uuid/v4'
module.exports = {
id: { type: 'string', primary: true, default: uuid },
url: { type: 'string', uri: true, required: true },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
ownedBy: {
type: 'relationship',
relationship: 'OWNED_BY',
target: 'User',
direction: 'in',
eager: true,
cascade: 'detach',
},
}

View File

@ -3,8 +3,7 @@ import uuid from 'uuid/v4'
module.exports = {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
actorId: { type: 'string', allow: [null] },
name: { type: 'string', min: 3 },
email: { type: 'string', lowercase: true, email: true },
name: { type: 'string', disallow: [null], min: 3 },
slug: 'string',
encryptedPassword: 'string',
avatar: { type: 'string', allow: [null] },

View File

@ -5,4 +5,5 @@ export default {
User: require('./User.js'),
InvitationCode: require('./InvitationCode.js'),
EmailAddress: require('./EmailAddress.js'),
SocialMedia: require('./SocialMedia.js'),
}

View File

@ -0,0 +1,9 @@
export const undefinedToNull = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}

View File

@ -19,6 +19,7 @@ export default applyScalars(
'Notfication',
'Statistics',
'LoggedInUser',
'SocialMedia',
],
// add 'User' here as soon as possible
},
@ -30,6 +31,7 @@ export default applyScalars(
'Notfication',
'Statistics',
'LoggedInUser',
'SocialMedia',
],
// add 'User' here as soon as possible
},

View File

@ -18,7 +18,7 @@ export default {
false,
)
let transactionRes = await session.run(
const transactionRes = await session.run(
`
MATCH (post:Post {id: $postId}), (comment:Comment {id: $commentId}), (author:User {id: $userId})
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)

View File

@ -5,11 +5,31 @@ import { host, login, gql } from '../../jest/helpers'
const factory = Factory()
let client
let createCommentVariables
let createPostVariables
let createCommentVariablesSansPostId
let createCommentVariablesWithNonExistentPost
let userParams
let authorParams
let headers
const createPostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
`
const createCommentMutation = gql`
mutation($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
content
}
}
`
const createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me',
}
beforeEach(async () => {
userParams = {
@ -25,21 +45,6 @@ afterEach(async () => {
})
describe('CreateComment', () => {
const createCommentMutation = gql`
mutation($postId: ID!, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
`
const createPostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
createCommentVariables = {
@ -54,7 +59,6 @@ describe('CreateComment', () => {
})
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login(userParams)
client = new GraphQLClient(host, {
@ -64,11 +68,6 @@ describe('CreateComment', () => {
postId: 'p1',
content: "I'm authorised to comment",
}
createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me',
}
await client.request(createPostMutation, createPostVariables)
})
@ -187,19 +186,8 @@ describe('CreateComment', () => {
})
})
describe('DeleteComment', () => {
const deleteCommentMutation = gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
let deleteCommentVariables = {
id: 'c1',
}
describe('ManageComments', () => {
let authorParams
beforeEach(async () => {
authorParams = {
email: 'author@example.org',
@ -213,51 +201,178 @@ describe('DeleteComment', () => {
content: 'Post to be commented',
})
await asAuthor.create('Comment', {
id: 'c1',
id: 'c456',
postId: 'p1',
content: 'Comment to be deleted',
})
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
let headers
headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
it('throws authorization error', async () => {
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
let headers
headers = await login(authorParams)
client = new GraphQLClient(host, { headers })
})
it('deletes the comment', async () => {
const expected = {
DeleteComment: {
id: 'c1',
},
describe('UpdateComment', () => {
const updateCommentMutation = gql`
mutation($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
content
}
}
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).resolves.toEqual(
expected,
)
`
let updateCommentVariables = {
id: 'c456',
content: 'The comment is updated',
}
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
it('throws authorization error', async () => {
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
headers = await login(authorParams)
client = new GraphQLClient(host, {
headers,
})
})
it('updates the comment', async () => {
const expected = {
UpdateComment: {
id: 'c456',
content: 'The comment is updated',
},
}
await expect(
client.request(updateCommentMutation, updateCommentVariables),
).resolves.toEqual(expected)
})
it('throw an error if an empty string is sent from the editor as content', async () => {
updateCommentVariables = {
id: 'c456',
content: '<p></p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Comment must be at least 1 character long!',
)
})
it('throws an error if a comment sent from the editor does not contain a single letter character', async () => {
updateCommentVariables = {
id: 'c456',
content: '<p> </p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Comment must be at least 1 character long!',
)
})
it('throws an error if commentId is sent as an empty string', async () => {
updateCommentVariables = {
id: '',
content: '<p>Hello</p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised!',
)
})
it('throws an error if the comment does not exist in the database', async () => {
updateCommentVariables = {
id: 'c1000',
content: '<p>Hello</p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised!',
)
})
})
})
describe('DeleteComment', () => {
const deleteCommentMutation = gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
const deleteCommentVariables = {
id: 'c456',
}
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
it('throws authorization error', async () => {
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
headers = await login(authorParams)
client = new GraphQLClient(host, {
headers,
})
})
it('deletes the comment', async () => {
const expected = {
DeleteComment: {
id: 'c456',
},
}
await expect(
client.request(deleteCommentMutation, deleteCommentVariables),
).resolves.toEqual(expected)
})
})
})
})

View File

@ -0,0 +1,29 @@
import scrape from './embeds/scraper.js'
import { undefinedToNullResolver } from './helpers/Resolver'
export default {
Query: {
embed: async (object, { url }, context, resolveInfo) => {
return scrape(url)
},
},
Embed: {
...undefinedToNullResolver([
'type',
'title',
'author',
'publisher',
'date',
'description',
'url',
'image',
'audio',
'video',
'lang',
'html',
]),
sources: async (parent, params, context, resolveInfo) => {
return typeof parent.sources === 'undefined' ? [] : parent.sources
},
},
}

View File

@ -0,0 +1,216 @@
import fetch from 'node-fetch'
import fs from 'fs'
import path from 'path'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
import { gql } from '../../jest/helpers'
jest.mock('node-fetch')
const { Response } = jest.requireActual('node-fetch')
afterEach(() => {
fetch.mockRestore()
})
let variables = {}
const HumanConnectionOrg = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/HumanConnectionOrg.html'),
'utf8',
)
const pr960 = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/pr960.html'),
'utf8',
)
const babyLovesCat = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/babyLovesCat.html'),
'utf8',
)
const babyLovesCatEmbedResponse = new Response(
JSON.stringify({
height: 270,
provider_name: 'YouTube',
title: 'Baby Loves Cat',
type: 'video',
width: 480,
thumbnail_height: 360,
provider_url: 'https://www.youtube.com/',
thumbnail_width: 480,
html:
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?start=18&feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
thumbnail_url: 'https://i.ytimg.com/vi/qkdXAtO40Fo/hqdefault.jpg',
version: '1.0',
author_name: 'Merkley Family',
author_url: 'https://www.youtube.com/channel/UC5P8yei950tif7UmdPpkJLQ',
}),
)
describe('Query', () => {
describe('embed', () => {
let embedAction
beforeEach(() => {
embedAction = async variables => {
const { server } = createServer({
context: () => {},
})
const { query } = createTestClient(server)
const embed = gql`
query($url: String!) {
embed(url: $url) {
type
title
author
publisher
date
description
url
image
audio
video
lang
sources
html
}
}
`
return query({ query: embed, variables })
}
})
describe('given a video link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response('')))
.mockReturnValueOnce(Promise.resolve(JSON.stringify({})))
variables = { url: 'https://www.w3schools.com/html/mov_bbb.mp4' }
})
it('shows some default data', async () => {
const expected = expect.objectContaining({
data: {
embed: {
audio: null,
author: null,
date: null,
description: null,
html: null,
image: null,
lang: null,
publisher: 'YouTube',
sources: ['resource'],
title: null,
type: 'link',
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
video: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a Facebook link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(HumanConnectionOrg)))
.mockReturnValueOnce(Promise.resolve('invalid json'))
variables = { url: 'https://www.facebook.com/HumanConnectionOrg/' }
})
it('does not crash if embed provider returns invalid JSON', async () => {
const expected = expect.objectContaining({
data: {
embed: {
audio: null,
author: null,
date: expect.any(String),
description:
'Human Connection, Weilheim an der Teck. Gefällt 24.407 Mal. An upcoming non-profit social network focused on local and global positive change. Twitter accounts : @hc_world (EN), @hc_deutschland (GE),...',
html: null,
image:
'https://scontent.ftxl3-1.fna.fbcdn.net/v/t1.0-1/c5.0.200.200a/p200x200/12108307_997373093648222_70057205881020137_n.jpg?_nc_cat=110&_nc_oc=AQnPPYQlR0dU556gOfl4xkXr7IPZdRIAUfQeXl3fpUv4DAsFN8T4PfgOjPwuq85GPKGZ5S5E5mWQ8IVV1UiRBAIZ&_nc_ht=scontent.ftxl3-1.fna&oh=90309adddaab38839782f16e7d4b7bcf&oe=5DEEDFE5',
lang: 'de',
publisher: 'Facebook',
sources: ['resource'],
title: 'Human Connection',
type: 'link',
url: 'https://www.facebook.com/HumanConnectionOrg/',
video: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a Github link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(pr960)))
.mockReturnValueOnce(Promise.resolve(JSON.stringify({})))
variables = { url: 'https://github.com/Human-Connection/Human-Connection/pull/960' }
})
it('returns meta data even if no embed html can be retrieved', async () => {
const expected = expect.objectContaining({
data: {
embed: {
type: 'link',
title:
'Editor embeds merge in nitro embed by mattwr18 · Pull Request #960 · Human-Connection/Human-Connection',
author: 'Human-Connection',
publisher: 'GitHub',
date: expect.any(String),
description: '🍰 Pullrequest Issues fixes #256',
url: 'https://github.com/Human-Connection/Human-Connection/pull/960',
image:
'https://repository-images.githubusercontent.com/112590397/52c9a000-7e11-11e9-899d-aaa55f3a3d72',
audio: null,
video: null,
lang: 'en',
sources: ['resource'],
html: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a youtube link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(babyLovesCat)))
.mockReturnValueOnce(Promise.resolve(babyLovesCatEmbedResponse))
variables = { url: 'https://www.youtube.com/watch?v=qkdXAtO40Fo&t=18s' }
})
it('returns meta data plus youtube iframe html', async () => {
const expected = expect.objectContaining({
data: {
embed: {
type: 'video',
title: 'Baby Loves Cat',
author: 'Merkley Family',
publisher: 'YouTube',
date: expect.any(String),
description:
'Shes incapable of controlling her limbs when her kitty is around. The obsession grows every day. Ps. Thats a sleep sack shes in. Not a starfish outfit. Al...',
url: 'https://www.youtube.com/watch?v=qkdXAtO40Fo',
image: 'https://i.ytimg.com/vi/qkdXAtO40Fo/maxresdefault.jpg',
audio: null,
video: null,
lang: 'de',
sources: ['resource', 'oembed'],
html:
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?start=18&feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,102 @@
import Metascraper from 'metascraper'
import fetch from 'node-fetch'
import fs from 'fs'
import path from 'path'
import { ApolloError } from 'apollo-server'
import isEmpty from 'lodash/isEmpty'
import isArray from 'lodash/isArray'
import mergeWith from 'lodash/mergeWith'
const error = require('debug')('embed:error')
const metascraper = Metascraper([
require('metascraper-author')(),
require('metascraper-date')(),
require('metascraper-description')(),
require('metascraper-image')(),
require('metascraper-lang')(),
require('metascraper-lang-detector')(),
require('metascraper-logo')(),
// require('metascraper-clearbit-logo')(),
require('metascraper-publisher')(),
require('metascraper-title')(),
require('metascraper-url')(),
require('metascraper-audio')(),
require('metascraper-soundcloud')(),
require('metascraper-video')(),
require('metascraper-youtube')(),
// require('./rules/metascraper-embed')()
])
let oEmbedProvidersFile = fs.readFileSync(path.join(__dirname, './providers.json'), 'utf8')
// some providers allow a format parameter
// we need JSON
oEmbedProvidersFile = oEmbedProvidersFile.replace('{format}', 'json')
const oEmbedProviders = JSON.parse(oEmbedProvidersFile)
const fetchEmbed = async url => {
const provider = oEmbedProviders.find(provider => {
return provider.provider_url.includes(url.hostname)
})
if (!provider) return {}
const {
endpoints: [endpoint],
} = provider
const endpointUrl = new URL(endpoint.url)
endpointUrl.searchParams.append('url', url.href)
endpointUrl.searchParams.append('format', 'json')
let json
try {
const response = await fetch(endpointUrl)
json = await response.json()
} catch (err) {
error(`Error fetching embed data: ${err.message}`)
return {}
}
return {
type: json.type,
html: json.html,
author: json.author_name,
date: json.upload_date,
sources: ['oembed'],
}
}
const fetchResource = async url => {
const response = await fetch(url)
const html = await response.text()
const resource = await metascraper({ html, url: url.href })
return {
sources: ['resource'],
...resource,
}
}
export default async function scrape(url) {
url = new URL(url)
if (url.hostname === 'youtu.be') {
// replace youtu.be to get proper results
url.hostname = 'youtube.com'
}
const [meta, embed] = await Promise.all([fetchResource(url), fetchEmbed(url)])
const output = mergeWith(meta, embed, (objValue, srcValue) => {
if (isArray(objValue)) {
return objValue.concat(srcValue)
}
})
if (isEmpty(output)) {
throw new ApolloError('Not found', 'NOT_FOUND')
}
return {
type: 'link',
...output,
}
}

View File

@ -4,7 +4,7 @@ export default {
const { id, type } = params
const session = context.driver.session()
let transactionRes = await session.run(
const transactionRes = await session.run(
`MATCH (node {id: $id}), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT $id = $userId
MERGE (user)-[relation:FOLLOWS]->(node)
@ -29,7 +29,7 @@ export default {
const { id, type } = params
const session = context.driver.session()
let transactionRes = await session.run(
const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[relation:FOLLOWS]->(node {id: $id})
WHERE $type IN labels(node)
DELETE relation

View File

@ -41,8 +41,7 @@ describe('follow', () => {
describe('follow user', () => {
describe('unauthenticated follow', () => {
it('throws authorization error', async () => {
let client
client = new GraphQLClient(host)
const client = new GraphQLClient(host)
await expect(client.request(mutationFollowUser('u2'))).rejects.toThrow('Not Authorised')
})
})
@ -93,8 +92,7 @@ describe('follow', () => {
// follow
await clientUser1.request(mutationFollowUser('u2'))
// unfollow
let client
client = new GraphQLClient(host)
const client = new GraphQLClient(host)
await expect(client.request(mutationUnfollowUser('u2'))).rejects.toThrow('Not Authorised')
})
})

View File

@ -0,0 +1,75 @@
import { neode } from '../../../bootstrap/neo4j'
export const undefinedToNullResolver = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}
export default function Resolver(type, options = {}) {
const instance = neode()
const {
idAttribute = 'id',
undefinedToNull = [],
count = {},
hasOne = {},
hasMany = {},
} = options
const _hasResolver = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `MATCH(:${type} {${idAttribute}: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const countResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `
MATCH(u:${type} {${idAttribute}: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
const hasManyResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
const hasOneResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
const result = {
...undefinedToNullResolver(undefinedToNull),
...countResolver(count),
...hasOneResolver(hasOne),
...hasManyResolver(hasMany),
}
return result
}

View File

@ -9,7 +9,7 @@ import {
const factory = Factory()
let client
let userParams = {
const userParams = {
id: 'you',
email: 'test@example.org',
password: '1234',
@ -39,7 +39,7 @@ describe('Notification', () => {
})
describe('currentUser { notifications }', () => {
let variables = {}
const variables = {}
describe('authenticated', () => {
let headers
@ -116,9 +116,7 @@ describe('currentUser { notifications }', () => {
}
}
}`
let variables = {
read: false
}
const variables = { read: false }
it('returns only unread notifications of current user', async () => {
const expected = {
currentUser: {

View File

@ -5,7 +5,7 @@ export async function createPasswordReset(options) {
const { driver, code, email, issuedAt = new Date() } = options
const session = driver.session()
const cypher = `
MATCH (u:User) WHERE u.email = $email
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (u)-[:REQUESTED]->(pr)
RETURN u
@ -35,13 +35,13 @@ export default {
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
const cypher = `
MATCH (pr:PasswordReset {code: $code})
MATCH (u:User {email: $email})-[:REQUESTED]->(pr)
MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
SET pr.usedAt = datetime()
SET u.encryptedPassword = $encryptedNewPassword
RETURN pr
`
let transactionRes = await session.run(cypher, {
const transactionRes = await session.run(cypher, {
stillValid,
email,
code,

View File

@ -10,7 +10,7 @@ const driver = getDriver()
const getAllPasswordResets = async () => {
const session = driver.session()
let transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r')
const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r')
const resets = transactionRes.records.map(record => record.get('r'))
session.close()
return resets
@ -84,9 +84,9 @@ describe('passwordReset', () => {
}
const mutation = `mutation($code: String!, $email: String!, $newPassword: String!) { resetPassword(code: $code, email: $email, newPassword: $newPassword) }`
let email = 'user@example.org'
let code = 'abcdef'
let newPassword = 'supersecret'
const email = 'user@example.org'
const code = 'abcdef'
const newPassword = 'supersecret'
let variables
describe('invalid email', () => {

View File

@ -337,7 +337,7 @@ describe('DeletePost', () => {
}
`
let variables = {
const variables = {
id: 'p1',
}

View File

@ -12,8 +12,8 @@ const instance = neode()
*/
const checkEmailDoesNotExist = async ({ email }) => {
email = email.toLowerCase()
const users = await instance.all('User', { email })
if (users.length > 0) throw new UserInputError('User account with this email already exists.')
const emails = await instance.all('EmailAddress', { email })
if (emails.length > 0) throw new UserInputError('User account with this email already exists.')
}
export default {

View File

@ -166,11 +166,12 @@ describe('SignupByInvitation', () => {
await expect(action()).rejects.toThrow('"email" must be a valid email')
})
it('creates no EmailAddress node', async done => {
it('creates no additional EmailAddress node', async done => {
try {
await action()
} catch (e) {
const emailAddresses = await instance.all('EmailAddress')
let emailAddresses = await instance.all('EmailAddress')
emailAddresses = await emailAddresses.toJson
expect(emailAddresses).toHaveLength(0)
done()
}
@ -191,16 +192,16 @@ describe('SignupByInvitation', () => {
describe('creates a EmailAddress node', () => {
it('with a `createdAt` attribute', async () => {
await action()
const emailAddresses = await instance.all('EmailAddress')
const emailAddress = await emailAddresses.first().toJson()
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
emailAddress = await emailAddress.toJson()
expect(emailAddress.createdAt).toBeTruthy()
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
})
it('with a cryptographic `nonce`', async () => {
await action()
const emailAddresses = await instance.all('EmailAddress')
const emailAddress = await emailAddresses.first().toJson()
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
emailAddress = await emailAddress.toJson()
expect(emailAddress.nonce).toEqual(expect.any(String))
})
@ -220,6 +221,7 @@ describe('SignupByInvitation', () => {
it('rejects because codes can be used only once', async done => {
await action()
try {
variables.email = 'yetanotheremail@example.org'
await action()
} catch (e) {
expect(e.message).toMatch(/Invitation code already used/)
@ -282,8 +284,8 @@ describe('Signup', () => {
it('creates a Signup with a cryptographic `nonce`', async () => {
await action()
const emailAddresses = await instance.all('EmailAddress')
const emailAddress = await emailAddresses.first().toJson()
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
emailAddress = await emailAddress.toJson()
expect(emailAddress.nonce).toEqual(expect.any(String))
})
})
@ -298,7 +300,7 @@ describe('SignupVerification', () => {
}
`
describe('given valid password and email', () => {
let variables = {
const variables = {
nonce: '123456',
name: 'John Doe',
password: '123',

View File

@ -60,7 +60,7 @@ export default {
if (!dbResponse) return null
const { report, submitter, resource, type } = dbResponse
let response = {
const response = {
...report.properties,
post: null,
comment: null,

View File

@ -4,7 +4,7 @@ import { UserInputError } from 'apollo-server'
const instance = neode()
const getUserAndBadge = async ({ badgeKey, userId }) => {
let user = await instance.first('User', 'id', userId)
const user = await instance.first('User', 'id', userId)
const badge = await instance.first('Badge', 'id', badgeKey)
if (!user) throw new UserInputError("Couldn't find a user with that id")
if (!badge) throw new UserInputError("Couldn't find a badge with that id")
@ -36,8 +36,6 @@ export default {
userId,
},
)
} catch (err) {
throw err
} finally {
session.close()
}

View File

@ -4,7 +4,7 @@ export default {
const { id, type } = params
const session = context.driver.session()
let transactionRes = await session.run(
const transactionRes = await session.run(
`MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT userWritten.id = $userId
MERGE (user)-[relation:SHOUTED]->(node)
@ -29,7 +29,7 @@ export default {
const { id, type } = params
const session = context.driver.session()
let transactionRes = await session.run(
const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id})
WHERE $type IN labels(node)
DELETE relation

View File

@ -60,8 +60,7 @@ describe('shout', () => {
describe('shout foreign post', () => {
describe('unauthenticated shout', () => {
it('throws authorization error', async () => {
let client
client = new GraphQLClient(host)
const client = new GraphQLClient(host)
await expect(client.request(mutationShoutPost('p1'))).rejects.toThrow('Not Authorised')
})
})
@ -109,8 +108,7 @@ describe('shout', () => {
// shout
await clientUser1.request(mutationShoutPost('p2'))
// unshout
let client
client = new GraphQLClient(host)
const client = new GraphQLClient(host)
await expect(client.request(mutationUnshoutPost('p2'))).rejects.toThrow('Not Authorised')
})
})

View File

@ -1,30 +1,38 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import { neode } from '../../bootstrap/neo4j'
import Resolver from './helpers/Resolver'
const instance = neode()
export default {
Mutation: {
CreateSocialMedia: async (object, params, context, resolveInfo) => {
/**
* TODO?: Creates double Nodes!
*/
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
const session = context.driver.session()
await session.run(
`MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId})
MERGE (socialMedia)<-[:OWNED]-(owner)
RETURN owner`,
{
userId: context.user.id,
socialMediaId: socialMedia.id,
},
)
session.close()
const [user, socialMedia] = await Promise.all([
instance.find('User', context.user.id),
instance.create('SocialMedia', params),
])
await socialMedia.relateTo(user, 'ownedBy')
const response = await socialMedia.toJson()
return socialMedia
return response
},
DeleteSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
UpdateSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await instance.find('SocialMedia', params.id)
await socialMedia.update({ url: params.url })
const response = await socialMedia.toJson()
return socialMedia
return response
},
DeleteSocialMedia: async (object, { id }, context, resolveInfo) => {
const socialMedia = await instance.find('SocialMedia', id)
if (!socialMedia) return null
await socialMedia.delete()
return socialMedia.toJson()
},
},
SocialMedia: Resolver('SocialMedia', {
idAttribute: 'url',
hasOne: {
ownedBy: '<-[:OWNED_BY]-(related:User)',
},
}),
}

View File

@ -1,115 +1,274 @@
import { GraphQLClient } from 'graphql-request'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
import Factory from '../../seed/factories'
import { host, login, gql } from '../../jest/helpers'
import { gql } from '../../jest/helpers'
import { neode, getDriver } from '../../bootstrap/neo4j'
const driver = getDriver()
const factory = Factory()
const instance = neode()
describe('SocialMedia', () => {
let client
let headers
const mutationC = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
const mutationD = gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`
let socialMediaAction, someUser, ownerNode, owner
const ownerParams = {
email: 'pippi@example.com',
password: '1234',
name: 'Pippi Langstrumpf',
}
const userParams = {
email: 'kalle@example.com',
password: 'abcd',
name: 'Kalle Blomqvist',
}
const url = 'https://twitter.com/pippi-langstrumpf'
const newUrl = 'https://twitter.com/bullerby'
const setUpSocialMedia = async () => {
const socialMediaNode = await instance.create('SocialMedia', { url })
await socialMediaNode.relateTo(ownerNode, 'ownedBy')
return socialMediaNode.toJson()
}
beforeEach(async () => {
await factory.create('User', {
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg',
id: 'acb2d923-f3af-479e-9f00-61b12e864666',
name: 'Matilde Hermiston',
slug: 'matilde-hermiston',
role: 'user',
email: 'test@example.org',
password: '1234',
})
const someUserNode = await instance.create('User', userParams)
someUser = await someUserNode.toJson()
ownerNode = await instance.create('User', ownerParams)
owner = await ownerNode.toJson()
socialMediaAction = async (user, mutation, variables) => {
const { server } = createServer({
context: () => {
return {
user,
driver,
}
},
})
const { mutate } = createTestClient(server)
return mutate({
mutation,
variables,
})
}
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
const variables = {
url: 'http://nsosp.org',
}
await expect(client.request(mutationC, variables)).rejects.toThrow('Not Authorised')
describe('create social media', () => {
let mutation, variables
beforeEach(() => {
mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
variables = { url }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated', () => {
let user
beforeEach(() => {
user = owner
})
it('creates social media with the given url', async () => {
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: {
id: expect.any(String),
url,
},
},
}),
)
})
it('rejects an empty string as url', async () => {
variables = { url: '' }
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" is not allowed to be empty'),
)
})
it('rejects invalid urls', async () => {
variables = { url: 'not-a-url' }
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" must be a valid uri'),
)
})
})
describe('ownedBy', () => {
beforeEach(() => {
mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
url
ownedBy {
name
}
}
}
`
})
it('resolves', async () => {
const user = someUser
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: { url, ownedBy: { name: 'Kalle Blomqvist' } },
},
}),
)
})
})
})
describe('authenticated', () => {
describe('update social media', () => {
let mutation, variables
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
const socialMedia = await setUpSocialMedia()
mutation = gql`
mutation($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
variables = { url: newUrl, id: socialMedia.id }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
it('creates social media with correct URL', async () => {
const variables = {
url: 'http://nsosp.org',
}
await expect(client.request(mutationC, variables)).resolves.toEqual(
expect.objectContaining({
CreateSocialMedia: {
id: expect.any(String),
url: 'http://nsosp.org',
describe('authenticated as other user', () => {
it('throws authorization error', async () => {
const user = someUser
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated as owner', () => {
let user
beforeEach(() => {
user = owner
})
it('updates social media with the given id', async () => {
const expected = {
data: {
UpdateSocialMedia: { ...variables },
},
}),
)
}
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining(expected),
)
})
it('does not update if the the given id does not exist', async () => {
variables.id = 'some-id'
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
})
describe('delete social media', () => {
let mutation, variables
beforeEach(async () => {
const socialMedia = await setUpSocialMedia()
mutation = gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`
variables = { url: newUrl, id: socialMedia.id }
})
it('deletes social media', async () => {
const creationVariables = {
url: 'http://nsosp.org',
}
const { CreateSocialMedia } = await client.request(mutationC, creationVariables)
const { id } = CreateSocialMedia
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, mutation, variables)
const deletionVariables = {
id,
}
const expected = {
DeleteSocialMedia: {
id: id,
url: 'http://nsosp.org',
},
}
await expect(client.request(mutationD, deletionVariables)).resolves.toEqual(expected)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
it('rejects empty string', async () => {
const variables = {
url: '',
}
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" is not allowed to be empty',
)
describe('authenticated as other user', () => {
it('throws authorization error', async () => {
const user = someUser
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
it('validates URLs', async () => {
const variables = {
url: 'not-a-url',
}
describe('authenticated as owner', () => {
let user
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" must be a valid uri',
)
beforeEach(async () => {
user = owner
})
it('deletes social media with the given id', async () => {
const expected = {
data: {
DeleteSocialMedia: {
id: variables.id,
url,
},
},
}
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining(expected),
)
})
})
})
})

View File

@ -1,9 +1,9 @@
export const query = (cypher, session) => {
return new Promise((resolve, reject) => {
let data = []
const data = []
session.run(cypher).subscribe({
onNext: function(record) {
let item = {}
const item = {}
record.keys.forEach(key => {
item[key] = record.get(key)
})
@ -34,7 +34,7 @@ const queryOne = (cypher, session) => {
export default {
Query: {
statistics: async (parent, args, { driver, user }) => {
return new Promise(async resolve => {
return new Promise(resolve => {
const session = driver.session()
const queries = {
countUsers:
@ -54,18 +54,24 @@ export default {
countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows',
countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts',
}
let data = {
countUsers: (await queryOne(queries.countUsers, session)).countUsers.low,
countPosts: (await queryOne(queries.countPosts, session)).countPosts.low,
countComments: (await queryOne(queries.countComments, session)).countComments.low,
countNotifications: (await queryOne(queries.countNotifications, session))
.countNotifications.low,
countOrganizations: (await queryOne(queries.countOrganizations, session))
.countOrganizations.low,
countProjects: (await queryOne(queries.countProjects, session)).countProjects.low,
countInvites: (await queryOne(queries.countInvites, session)).countInvites.low,
countFollows: (await queryOne(queries.countFollows, session)).countFollows.low,
countShouts: (await queryOne(queries.countShouts, session)).countShouts.low,
const data = {
countUsers: queryOne(queries.countUsers, session).then(res => res.countUsers.low),
countPosts: queryOne(queries.countPosts, session).then(res => res.countPosts.low),
countComments: queryOne(queries.countComments, session).then(
res => res.countComments.low,
),
countNotifications: queryOne(queries.countNotifications, session).then(
res => res.countNotifications.low,
),
countOrganizations: queryOne(queries.countOrganizations, session).then(
res => res.countOrganizations.low,
),
countProjects: queryOne(queries.countProjects, session).then(
res => res.countProjects.low,
),
countInvites: queryOne(queries.countInvites, session).then(res => res.countInvites.low),
countFollows: queryOne(queries.countFollows, session).then(res => res.countFollows.low),
countShouts: queryOne(queries.countShouts, session).then(res => res.countShouts.low),
}
resolve(data)
})

View File

@ -2,6 +2,9 @@ import encode from '../../jwt/encode'
import bcrypt from 'bcryptjs'
import { AuthenticationError } from 'apollo-server'
import { neo4jgraphql } from 'neo4j-graphql-js'
import { neode } from '../../bootstrap/neo4j'
const instance = neode()
export default {
Query: {
@ -21,8 +24,8 @@ export default {
// }
const session = driver.session()
const result = await session.run(
'MATCH (user:User {email: $userEmail}) ' +
'RETURN user {.id, .slug, .name, .avatar, .email, .encryptedPassword, .role, .disabled} as user LIMIT 1',
'MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})' +
'RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1',
{
userEmail: email,
},
@ -46,41 +49,24 @@ export default {
}
},
changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => {
const session = driver.session()
let result = await session.run(
`MATCH (user:User {email: $userEmail})
RETURN user {.id, .email, .encryptedPassword}`,
{
userEmail: user.email,
},
)
const currentUser = await instance.find('User', user.id)
const [currentUser] = result.records.map(function(record) {
return record.get('user')
})
if (!(await bcrypt.compareSync(oldPassword, currentUser.encryptedPassword))) {
const encryptedPassword = currentUser.get('encryptedPassword')
if (!(await bcrypt.compareSync(oldPassword, encryptedPassword))) {
throw new AuthenticationError('Old password is not correct')
}
if (await bcrypt.compareSync(newPassword, currentUser.encryptedPassword)) {
if (await bcrypt.compareSync(newPassword, encryptedPassword)) {
throw new AuthenticationError('Old password and new password should be different')
} else {
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
session.run(
`MATCH (user:User {email: $userEmail})
SET user.encryptedPassword = $newEncryptedPassword
RETURN user
`,
{
userEmail: user.email,
newEncryptedPassword,
},
)
session.close()
return encode(currentUser)
}
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
await currentUser.update({
encryptedPassword: newEncryptedPassword,
updatedAt: new Date().toISOString(),
})
return encode(await currentUser.toJson())
},
},
}

View File

@ -296,7 +296,7 @@ describe('change password', () => {
describe('correct password', () => {
it('changes the password if given correct credentials "', async () => {
let response = await client.request(
const response = await client.request(
mutation({
oldPassword: '1234',
newPassword: '12345',

View File

@ -2,69 +2,20 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload'
import { neode } from '../../bootstrap/neo4j'
import { UserInputError } from 'apollo-server'
import Resolver from './helpers/Resolver'
const instance = neode()
const _has = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `MATCH(u:User {id: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const count = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `
MATCH(u:User {id: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
const undefinedToNull = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}
export const hasMany = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
export const hasOne = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
export default {
Query: {
User: async (object, args, context, resolveInfo) => {
const { email } = args
if (email) {
const e = await instance.first('EmailAddress', { email })
let user = e.get('belongsTo')
user = await user.toJson()
return [user.node]
}
return neo4jgraphql(object, args, context, resolveInfo, false)
},
},
@ -72,7 +23,7 @@ export default {
UpdateUser: async (object, args, context, resolveInfo) => {
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' })
try {
let user = await instance.find('User', args.id)
const user = await instance.find('User', args.id)
if (!user) return null
await user.update(args)
return user.toJson()
@ -104,42 +55,52 @@ export default {
},
},
User: {
...undefinedToNull([
'actorId',
'avatar',
'coverImg',
'deleted',
'disabled',
'locationName',
'about',
]),
...count({
contributionsCount: '-[:WROTE]->(related:Post)',
friendsCount: '<-[:FRIENDS]->(related:User)',
followingCount: '-[:FOLLOWS]->(related:User)',
followedByCount: '<-[:FOLLOWS]-(related:User)',
commentsCount: '-[:WROTE]->(r:Comment)',
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
shoutedCount: '-[:SHOUTED]->(related:Post)',
badgesCount: '<-[:REWARDED]-(related:Badge)',
}),
...hasOne({
invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
}),
...hasMany({
followedBy: '<-[:FOLLOWS]-(related:User)',
following: '-[:FOLLOWS]->(related:User)',
friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED]->(related:SocialMedia)',
contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
email: async (parent, params, context, resolveInfo) => {
if (typeof parent.email !== 'undefined') return parent.email
const { id } = parent
const statement = `MATCH(u:User {id: {id}})-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e`
const result = await instance.cypher(statement, { id })
const [{ email }] = result.records.map(r => r.get('e').properties)
return email
},
...Resolver('User', {
undefinedToNull: [
'actorId',
'avatar',
'coverImg',
'deleted',
'disabled',
'locationName',
'about',
],
count: {
contributionsCount: '-[:WROTE]->(related:Post)',
friendsCount: '<-[:FRIENDS]->(related:User)',
followingCount: '-[:FOLLOWS]->(related:User)',
followedByCount: '<-[:FOLLOWS]-(related:User)',
commentsCount: '-[:WROTE]->(r:Comment)',
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
shoutedCount: '-[:SHOUTED]->(related:Post)',
badgesCount: '<-[:REWARDED]-(related:Badge)',
},
hasOne: {
invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
},
hasMany: {
followedBy: '<-[:FOLLOWS]-(related:User)',
following: '-[:FOLLOWS]->(related:User)',
friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED_BY]->(related:SocialMedia',
contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
},
}),
},
}

View File

@ -0,0 +1,19 @@
type Embed {
type: String
title: String
author: String
publisher: String
date: String
description: String
url: String
image: String
audio: String
video: String
lang: String
html: String
sources: [String]
}
type Query {
embed(url: String!): Embed
}

View File

@ -21,7 +21,7 @@ const findGqlFiles = dir => {
return results
}
let typeDefs = []
const typeDefs = []
findGqlFiles(__dirname).forEach(file => {
typeDefs.push(fs.readFileSync(file).toString('utf-8'))

View File

@ -132,8 +132,3 @@ type SharedInboxEndpoint {
uri: String
}
type SocialMedia {
id: ID!
url: String
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
}

View File

@ -24,7 +24,7 @@ type Mutation {
): Comment
UpdateComment(
id: ID!
content: String
content: String!
contentExcerpt: String
deleted: Boolean
disabled: Boolean

View File

@ -0,0 +1,11 @@
type SocialMedia {
id: ID!
url: String
ownedBy: User! @relation(name: "OWNED_BY", direction: "IN")
}
type Mutation {
CreateSocialMedia(id: ID, url: String!): SocialMedia
UpdateSocialMedia(id: ID!, url: String!): SocialMedia
DeleteSocialMedia(id: ID!): SocialMedia
}

View File

@ -2,7 +2,7 @@ type User {
id: ID!
actorId: String
name: String
email: String!
email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
slug: String!
avatar: String
coverImg: String
@ -17,7 +17,7 @@ type User {
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
locationName: String
about: String
socialMedia: [SocialMedia]! @relation(name: "OWNED", direction: "OUT")
socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "OUT")
#createdAt: DateTime
#updatedAt: DateTime

View File

@ -40,15 +40,13 @@ export const cleanDatabase = async (options = {}) => {
const cypher = 'MATCH (n) DETACH DELETE n'
try {
return await session.run(cypher)
} catch (error) {
throw error
} finally {
session.close()
}
}
export default function Factory(options = {}) {
let {
const {
seedServerHost = 'http://127.0.0.1:4001',
neo4jDriver = getDriver(),
neodeInstance = neode(),

View File

@ -21,7 +21,11 @@ export default function create() {
...args,
}
args = await encryptPassword(args)
return neodeInstance.create('User', args)
const user = await neodeInstance.create('User', args)
const email = await neodeInstance.create('EmailAddress', { email: args.email })
await user.relateTo(email, 'primaryEmail')
await email.relateTo(user, 'belongsTo')
return user
},
}
}

View File

@ -37,17 +37,17 @@ const difficulties = ['easy', 'medium', 'hard']
export default {
randomItem: (items, filter) => {
let ids = filter
const ids = filter
? Object.keys(items).filter(id => {
return filter(items[id])
})
: _.keys(items)
let randomIds = _.shuffle(ids)
const randomIds = _.shuffle(ids)
return items[randomIds.pop()]
},
randomItems: (items, key = 'id', min = 1, max = 1) => {
let randomIds = _.shuffle(_.keys(items))
let res = []
const randomIds = _.shuffle(_.keys(items))
const res = []
const count = _.random(min, max)
@ -86,8 +86,8 @@ export default {
if (allowEmpty === false && count === 0) {
count = 1
}
let categorieIds = _.shuffle(_.keys(seederstore.categories))
let ids = []
const categorieIds = _.shuffle(_.keys(seederstore.categories))
const ids = []
for (let i = 0; i < count; i++) {
ids.push(categorieIds.pop())
}
@ -95,7 +95,7 @@ export default {
},
randomAddresses: () => {
const count = Math.round(Math.random() * 3)
let addresses = []
const addresses = []
for (let i = 0; i < count; i++) {
addresses.push({
city: faker.address.city(),
@ -116,7 +116,7 @@ export default {
* @param key the field key that is represented in the values (slug, name, etc.)
*/
mapIdsByKey: (items, values, key) => {
let res = []
const res = []
values.forEach(value => {
res.push(_.find(items, [key, value]).id.toString())
})

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +1,70 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
When('I search for {string}', value => {
cy.get('#nav-search')
import { When, Then } from "cypress-cucumber-preprocessor/steps";
When("I search for {string}", value => {
cy.get("#nav-search")
.focus()
.type(value)
})
.type(value);
});
Then('I should have one post in the select dropdown', () => {
cy.get('.ds-select-dropdown').should($li => {
expect($li).to.have.length(1)
})
})
Then("I should have one post in the select dropdown", () => {
cy.get(".input .ds-select-dropdown").should($li => {
expect($li).to.have.length(1);
});
});
Then('I should see the following posts in the select dropdown:', table => {
Then("I should see the following posts in the select dropdown:", table => {
table.hashes().forEach(({ title }) => {
cy.get('.ds-select-dropdown').should('contain', title)
})
})
cy.get(".ds-select-dropdown").should("contain", title);
});
});
When('I type {string} and press Enter', value => {
cy.get('#nav-search')
When("I type {string} and press Enter", value => {
cy.get("#nav-search")
.focus()
.type(value)
.type('{enter}', { force: true })
})
.type("{enter}", { force: true });
});
When('I type {string} and press escape', value => {
cy.get('#nav-search')
When("I type {string} and press escape", value => {
cy.get("#nav-search")
.focus()
.type(value)
.type('{esc}')
})
.type("{esc}");
});
Then('the search field should clear', () => {
cy.get('#nav-search').should('have.text', '')
})
Then("the search field should clear", () => {
cy.get("#nav-search").should("have.text", "");
});
When('I select an entry', () => {
cy.get('.ds-select-dropdown ul li')
When("I select an entry", () => {
cy.get(".input .ds-select-dropdown ul li")
.first()
.trigger('click')
})
.trigger("click");
});
Then("I should be on the post's page", () => {
cy.location('pathname').should(
'contain',
'/post/'
)
cy.location('pathname').should(
'eq',
'/post/p1/101-essays-that-will-change-the-way-you-think'
)
})
cy.location("pathname").should("contain", "/post/");
cy.location("pathname").should(
"eq",
"/post/p1/101-essays-that-will-change-the-way-you-think"
);
});
Then(
'I should see posts with the searched-for term in the select dropdown',
"I should see posts with the searched-for term in the select dropdown",
() => {
cy.get('.ds-select-dropdown').should(
'contain',
'101 Essays that will change the way you think'
)
cy.get(".ds-select-dropdown").should(
"contain",
"101 Essays that will change the way you think"
);
}
)
);
Then(
'I should not see posts without the searched-for term in the select dropdown',
"I should not see posts without the searched-for term in the select dropdown",
() => {
cy.get('.ds-select-dropdown').should(
'not.contain',
'No searched for content'
)
cy.get(".ds-select-dropdown").should(
"not.contain",
"No searched for content"
);
}
)
);

View File

@ -79,7 +79,7 @@ Then('I should be on the {string} page', page => {
})
When('I add a social media link', () => {
cy.get("input[name='social-media']")
cy.get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Add link')
@ -98,7 +98,7 @@ Then('the new social media link shows up on the page', () => {
Given('I have added a social media link', () => {
cy.openPage('/settings/my-social-media')
.get("input[name='social-media']")
.get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Add link')
@ -121,3 +121,34 @@ Then('it gets deleted successfully', () => {
cy.get('.iziToast-message')
.should('contain', 'Deleted social media')
})
When('I start editing a social media link', () => {
cy.get("a[name='edit']")
.click()
})
Then('I can cancel editing', () => {
cy.get('button#cancel')
.click()
.get('input#editSocialMedia')
.should('have.length', 0)
})
When('I edit and save the link', () => {
cy.get('input#editSocialMedia')
.clear()
.type('https://freeradical.zone/tinkerbell')
.get('button')
.contains('Save')
.click()
})
Then('the new url is displayed', () => {
cy.get("a[href='https://freeradical.zone/tinkerbell']")
.should('have.length', 1)
})
Then('the old url is not displayed', () => {
cy.get("a[href='https://freeradical.zone/peter-pan']")
.should('have.length', 0)
})

View File

@ -15,7 +15,7 @@ Feature: List Social Media Accounts
Then it gets saved successfully
And the new social media link shows up on the page
Scenario: Other user's viewing my Social Media
Scenario: Other users viewing my Social Media
Given I have added a social media link
When people visit my profile page
Then they should be able to see my social media links
@ -27,3 +27,16 @@ Feature: List Social Media Accounts
Given I have added a social media link
When I delete a social media link
Then it gets deleted successfully
Scenario: Editing Social Media
Given I am on the "settings" page
And I click on the "Social media" link
Then I should be on the "/settings/my-social-media" page
Given I have added a social media link
When I start editing a social media link
Then I can cancel editing
When I start editing a social media link
And I edit and save the link
Then it gets saved successfully
And the new url is displayed
But the old url is not displayed

View File

@ -0,0 +1,3 @@
FROM nginx:alpine
COPY ./onourjourney.svg /usr/share/nginx/html/
COPY ./maintenance.html /usr/share/nginx/html/index.html

View File

@ -0,0 +1,43 @@
# Maintenance mode
> Despite our best efforts, systems sometimes require downtime for a variety of reasons.
Quote from [here](https://www.nrmitchi.com/2017/11/easy-maintenance-mode-in-kubernetes/)
We use our maintenance mode for manual database backup and restore. Also we
bring the database into maintenance mode for manual database migrations.
## Deploy the service
We prepared sample configuration, so you can simply run:
```sh
# in folder deployment/
kubectl apply -f human-connection/maintenance
```
This will fire up a maintenance service.
## Bring application into maintenance mode
Now if you want to have a controlled downtime and you want to bring your
application into maintenance mode, you can edit your global ingress server.
E.g. in file `deployment/digital-ocean/https/ingress.yaml` change the following:
```yaml
...
- host: nitro-staging.human-connection.org
http:
paths:
- path: /
backend:
# serviceName: nitro-web
serviceName: maintenance
# servicePort: 3000
servicePort: 80
```
Then run ` kubectl apply -f deployment/digital-ocean/https/ingress.yaml`. If you
want to deactivate the maintenance server, just undo the edit and apply the
configuration again.

View File

@ -0,0 +1,27 @@
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: maintenance
namespace: human-connection
spec:
selector:
matchLabels:
human-connection.org/selector: deployment-human-connection-maintenance
template:
metadata:
labels:
human-connection.org/commit: COMMIT
human-connection.org/selector: deployment-human-connection-maintenance
name: maintenance
spec:
containers:
- name: web
env:
- name: HOST
value: 0.0.0.0
image: humanconnection/maintenance:latest
ports:
- containerPort: 80
imagePullPolicy: Always
restartPolicy: Always
terminationGracePeriodSeconds: 30

View File

@ -0,0 +1,61 @@
<head>
<style>
.body {
background-color: #f5f4f6;
}
.left-header,
.right-header {
font-family: "LatoWeb", sans-serif;
line-height: 1.3;
}
@media only screen and (min-width: 960px) {
.outer-div {
margin: 3em;
vertical-align: middle;
text-align: center;
}
.left-header {
float: left;
width: 25%;
margin-right: 1em;
}
.image {
width: 35em;
}
.right-header {
float: right;
width: 25%;
margin: 12em 1em 5em 2em;
}
.email-link {
margin-left: -5em;
}
}
@media only screen and (max-width: 960px) {
.outer-div {
margin: 3em;
vertical-align: middle;
text-align: center;
}
.image {
margin: 2em 0em;
}
}
</style>
</head>
<body class="body">
<div class="outer-div">
<h1 class="left-header">
At the moment we are doing some scheduled maintenance, please try again
later.
</h1>
<img src="./onourjourney.svg" alt="Maintenance mode image" class="image" />
<h1 class="right-header">
Any Questions or concerns, send an email to <br />
<a href="mailto:info@human-connection.org" class="email-link"
>info@human-connection.org</a
>
</h1>
</div>
</body>

View File

@ -0,0 +1,360 @@
<svg xmlns="http://www.w3.org/2000/svg" id="Back" viewBox="0 0 600 570">
<defs>
<style>
.cls-1,.cls-12{fill:#fff;}.cls-2,.cls-4{fill:#dfeff4;}.cls-3{fill:#fddb00;}.cls-12,.cls-13,.cls-14,.cls-15,.cls-16,.cls-18,.cls-19,.cls-20,.cls-21,.cls-23,.cls-24,.cls-26,.cls-27,.cls-28,.cls-29,.cls-30,.cls-32,.cls-34,.cls-35,.cls-36,.cls-37,.cls-38,.cls-39,.cls-4,.cls-40,.cls-41,.cls-42,.cls-44,.cls-45,.cls-46,.cls-47,.cls-48,.cls-50,.cls-51,.cls-52,.cls-53,.cls-55,.cls-56,.cls-57,.cls-58,.cls-59,.cls-64,.cls-67,.cls-68,.cls-9,.cls-96,.cls-97{fill-rule:evenodd;}.cls-5{mask:url(#mask);}.cls-6,.cls-7{fill:#9dca00;}.cls-6{opacity:0.5;}.cls-14,.cls-8{fill:#471e04;}.cls-9{fill:#70a30a;}.cls-10,.cls-16{fill:#f5b800;}.cls-11,.cls-21{fill:#ff9;}.cls-13{fill:#5b8408;}.cls-15{fill:#fc0;}.cls-17,.cls-48{fill:#f1502d;}.cls-18{fill:#f6aa32;}.cls-19{fill:#cc3;}.cls-20,.cls-63{fill:#f5e68a;}.cls-22,.cls-58{fill:#9cc;}.cls-23,.cls-62{fill:#a44624;}.cls-24,.cls-43{fill:#333;}.cls-25,.cls-45{fill:#d4a15e;}.cls-26{fill:#c4d621;}.cls-27{fill:#4e2111;}.cls-28{fill:#78331a;}.cls-30{fill:#058e7f;}.cls-31{fill:#fcda9a;}.cls-32{fill:#07b09d;}.cls-33,.cls-40{fill:#d63d3d;}.cls-34{fill:#3b696a;}.cls-35{fill:#a6d0d1;}.cls-36{fill:#d48439;}.cls-37{fill:#9e5a29;}.cls-38{fill:#e44b4b;}.cls-39{fill:#b0642e;}.cls-41{fill:#f66;}.cls-42,.cls-61{fill:#f2d291;}.cls-44{fill:#fcc;}.cls-46{fill:#edba68;}.cls-47{fill:#366;}.cls-49,.cls-50{fill:#1f0f05;}.cls-51{fill:#804921;}.cls-52,.cls-65{fill:#ff6;}.cls-53{fill:#f69883;}.cls-54,.cls-55{fill:#ff1f1f;}.cls-56{fill:#aac905;}.cls-57{fill:#f5e97d;}.cls-59,.cls-60{fill:#bf3611;}.cls-64{fill:#eccf7c;}.cls-66{fill:#ff441f;}.cls-67{fill:#88b02c;}.cls-68{fill:#500d00;}.cls-69{fill:#216194;}.cls-70{fill:#017f9f;}.cls-71{fill:#76b629;}.cls-72{fill:#a53589;}.cls-73{fill:#024f9d;}.cls-74{fill:#50568c;}.cls-75{fill:#c6d200;}.cls-76{fill:#0369b2;}.cls-77{fill:#007d75;}.cls-78{fill:#008d6d;}.cls-79{fill:#008051;}.cls-80{fill:#46942b;}.cls-81{fill:#d5146e;}.cls-82{fill:#15a338;}.cls-83{fill:#0080c7;}.cls-84{fill:#0089d0;}.cls-85{fill:#e9bb00;}.cls-86{fill:#007632;}.cls-87{fill:#c51553;}.cls-88{fill:#593872;}.cls-89{fill:#f19000;}.cls-90{fill:#f9b800;}.cls-91{fill:#e43311;}.cls-92{fill:#b7c200;}.cls-93{fill:#e64e10;}.cls-94{fill:#079de1;}.cls-95{fill:#93bf1f;}.cls-96{fill:#8fdbf3;}.cls-97{fill:#c20212;}.cls-98{fill:#152e56;}
</style>
<mask id="mask" width="1087.71" height="713.35" x="-161.4" y="91.4" maskUnits="userSpaceOnUse">
<circle cx="301.72" cy="330.39" r="238.99" class="cls-1"/>
</mask>
</defs>
<circle cx="300" cy="332.01" r="238.99" class="cls-2"/>
<circle cx="438.86" cy="295.31" r="80.84" class="cls-3"/>
<path d="M538.13 168.86a24.22 24.22 0 0 0-19.13 9.39 19.58 19.58 0 0 0-19 16.1 11.59 11.59 0 1 0-1.77 23c28.22 0 62.52-1.53 93.54-1.53a8.94 8.94 0 1 0-1.8-17.7 18.13 18.13 0 0 0-29.2-13.77 24.27 24.27 0 0 0-22.64-15.49zM82.63 147.25a30.27 30.27 0 0 1 24 11.73 24.46 24.46 0 0 1 23.7 20.12 14.48 14.48 0 1 1 2.21 28.79c-35.26 0-78.12-1.92-116.88-1.92a11.17 11.17 0 1 1 2.25-22.11 22.66 22.66 0 0 1 36.48-17.21 30.33 30.33 0 0 1 28.24-19.4zM89.91 50.71a10.35 10.35 0 0 0-8.19 4 8.36 8.36 0 0 0-8.1 6.88 5 5 0 0 0-.75-.06 5 5 0 1 0 0 9.9c12.06 0 26.71-.65 40-.65a3.82 3.82 0 1 0 0-7.64 3.87 3.87 0 0 0-.77.08 7.75 7.75 0 0 0-12.47-5.88 10.37 10.37 0 0 0-9.72-6.63z" class="cls-4"/>
<g class="cls-5">
<ellipse cx="189.6" cy="640.52" class="cls-6" rx="350.99" ry="164.22"/>
<ellipse cx="254.61" cy="625.53" class="cls-7" rx="349.38" ry="127.57"/>
<ellipse cx="575.32" cy="640.52" class="cls-6" rx="350.99" ry="164.22"/>
<ellipse cx="370.48" cy="648.66" class="cls-7" rx="272.66" ry="127.57"/>
</g>
<path d="M152.94 477.67h4.13v25.81h-4.13z" class="cls-8"/>
<path d="M135.69 472a5.9 5.9 0 0 1 .8-2.44 6.45 6.45 0 0 1-2.81-5.92 5.8 5.8 0 0 1 3-4.56 6.22 6.22 0 0 1-.37-2.81 5.81 5.81 0 0 1 5.14-5.24 6.2 6.2 0 0 1-.18-2.22 5.85 5.85 0 0 1 6.74-5.22 6.39 6.39 0 0 1 2.22.75c2.55-3.23 9.66-4.25 12.46.19a6 6 0 0 1 1.52-.51 6.48 6.48 0 0 1 7.41 4.92 6.14 6.14 0 0 1 0 2.6 6.55 6.55 0 0 1 4.54 4.78 6 6 0 0 1-1.33 5.35 6.45 6.45 0 0 1 1.26 2.58 5.89 5.89 0 0 1-3.1 6.69 6.43 6.43 0 0 1 .89 2.08 5.86 5.86 0 0 1-4.6 7.19 6.15 6.15 0 0 1-2.31 0c-3.64 7-11.92 3.56-12.59.39a5.87 5.87 0 0 1-6.2 3.26 6.54 6.54 0 0 1-5.38-4.86 6.18 6.18 0 0 1-1.63 0 6.49 6.49 0 0 1-5.48-7z" class="cls-9"/>
<circle cx="152.62" cy="452.42" r="2.69" class="cls-10"/>
<circle cx="163.65" cy="456.62" r="2.69" class="cls-10"/>
<circle cx="147.37" cy="471.06" r="2.69" class="cls-10"/>
<circle cx="159.98" cy="472.64" r="2.69" class="cls-10"/>
<circle cx="154.46" cy="463.45" r="2.69" class="cls-10"/>
<circle cx="167.46" cy="466.07" r="2.69" class="cls-10"/>
<circle cx="144.88" cy="460.16" r="2.69" class="cls-10"/>
<path d="M396.3 510.23h3.08v19.27h-3.08z" class="cls-8"/>
<path d="M383.42 506a4.4 4.4 0 0 1 .6-1.82 4.82 4.82 0 0 1-2.1-4.42 4.33 4.33 0 0 1 2.24-3.41 4.64 4.64 0 0 1-.28-2.1 4.34 4.34 0 0 1 3.84-3.91 4.63 4.63 0 0 1-.14-1.66 4.37 4.37 0 0 1 5-3.89 4.77 4.77 0 0 1 1.65.56c1.9-2.41 7.21-3.17 9.31.14a4.44 4.44 0 0 1 1.14-.38 4.84 4.84 0 0 1 5.53 3.67 4.58 4.58 0 0 1 0 1.94 4.89 4.89 0 0 1 3.39 3.57 4.45 4.45 0 0 1-1 4 4.82 4.82 0 0 1 .94 1.93 4.4 4.4 0 0 1-2.31 5 4.8 4.8 0 0 1 .66 1.55 4.38 4.38 0 0 1-3.43 5.37 4.59 4.59 0 0 1-1.72 0c-2.72 5.24-8.9 2.66-9.4.29a4.38 4.38 0 0 1-4.63 2.43 4.88 4.88 0 0 1-4-3.63 4.62 4.62 0 0 1-1.22 0 4.85 4.85 0 0 1-4.07-5.23z" class="cls-9"/>
<circle cx="396.06" cy="491.37" r="2.01" class="cls-11"/>
<circle cx="404.3" cy="494.51" r="2.01" class="cls-11"/>
<circle cx="392.14" cy="505.3" r="2.01" class="cls-11"/>
<circle cx="401.56" cy="506.47" r="2.01" class="cls-11"/>
<circle cx="397.44" cy="499.61" r="2.01" class="cls-11"/>
<circle cx="407.15" cy="501.57" r="2.01" class="cls-11"/>
<circle cx="390.28" cy="497.16" r="2.01" class="cls-11"/>
<path d="M446.95 488.05h1.66v10.4h-1.66z" class="cls-8"/>
<path d="M440 485.76a2.38 2.38 0 0 1 .32-1 2.6 2.6 0 0 1-1.13-2.38 2.34 2.34 0 0 1 1.21-1.84 2.51 2.51 0 0 1-.15-1.13 2.34 2.34 0 0 1 2.07-2.11 2.5 2.5 0 0 1-.07-.9 2.36 2.36 0 0 1 2.72-2.1 2.58 2.58 0 0 1 .89.3c1-1.3 3.89-1.71 5 .08a2.4 2.4 0 0 1 .61-.2 2.61 2.61 0 0 1 3 2 2.47 2.47 0 0 1 0 1 2.64 2.64 0 0 1 1.83 1.93 2.4 2.4 0 0 1-.53 2.16 2.6 2.6 0 0 1 .51 1 2.37 2.37 0 0 1-1.25 2.7 2.59 2.59 0 0 1 .36.84 2.36 2.36 0 0 1-1.85 2.9 2.48 2.48 0 0 1-.93 0c-1.47 2.83-4.8 1.43-5.08.16a2.37 2.37 0 0 1-2.5 1.31 2.64 2.64 0 0 1-2.17-2 2.49 2.49 0 0 1-.66 0 2.62 2.62 0 0 1-2.2-2.72z" class="cls-9"/>
<circle cx="446.83" cy="477.87" r="1.09" class="cls-11"/>
<circle cx="451.27" cy="479.56" r="1.09" class="cls-11"/>
<circle cx="444.71" cy="485.39" r="1.09" class="cls-11"/>
<circle cx="449.79" cy="486.02" r="1.09" class="cls-11"/>
<circle cx="447.57" cy="482.32" r="1.09" class="cls-11"/>
<circle cx="452.81" cy="483.38" r="1.09" class="cls-11"/>
<circle cx="443.7" cy="480.99" r="1.09" class="cls-11"/>
<path d="M193.42 486.85h2.94v18.37h-2.94z" class="cls-8"/>
<path d="M181.15 482.81a4.2 4.2 0 0 1 .57-1.74 4.59 4.59 0 0 1-2-4.21 4.13 4.13 0 0 1 2.14-3.25 4.43 4.43 0 0 1-.27-2 4.14 4.14 0 0 1 3.66-3.73 4.41 4.41 0 0 1-.13-1.58 4.16 4.16 0 0 1 4.8-3.71 4.55 4.55 0 0 1 1.58.53c1.81-2.3 6.88-3 8.87.13a4.23 4.23 0 0 1 1.08-.36 4.61 4.61 0 0 1 5.27 3.5 4.37 4.37 0 0 1 0 1.85 4.66 4.66 0 0 1 3.23 3.4 4.24 4.24 0 0 1-.94 3.81 4.59 4.59 0 0 1 .9 1.84 4.19 4.19 0 0 1-2.21 4.77 4.58 4.58 0 0 1 .63 1.48 4.17 4.17 0 0 1-3.27 5.12 4.38 4.38 0 0 1-1.64 0c-2.59 5-8.48 2.53-9 .28a4.18 4.18 0 0 1-4.41 2.32 4.66 4.66 0 0 1-3.83-3.46 4.4 4.4 0 0 1-1.16 0 4.62 4.62 0 0 1-3.87-4.99z" class="cls-9"/>
<circle cx="193.2" cy="468.87" r="1.92" class="cls-10"/>
<circle cx="201.05" cy="471.86" r="1.92" class="cls-10"/>
<circle cx="189.46" cy="482.14" r="1.92" class="cls-10"/>
<circle cx="198.43" cy="483.27" r="1.92" class="cls-10"/>
<circle cx="194.51" cy="476.72" r="1.92" class="cls-10"/>
<circle cx="203.76" cy="478.59" r="1.92" class="cls-10"/>
<circle cx="187.68" cy="474.38" r="1.92" class="cls-10"/>
<path d="M532.92 91.83a17.85 17.85 0 0 0-14.13 6.92 14.43 14.43 0 0 0-14 11.87 8.67 8.67 0 0 0-1.3-.1 8.54 8.54 0 1 0 0 17.08c20.8 0 46.08-1.13 68.95-1.13a6.59 6.59 0 1 0 0-13.18 6.68 6.68 0 0 0-1.32.13 13.36 13.36 0 0 0-21.52-10.15 17.89 17.89 0 0 0-16.68-11.44z" class="cls-4"/>
<path d="M449.81 370.21a17.85 17.85 0 0 0-14.13 6.92 14.43 14.43 0 0 0-14 11.87 8.67 8.67 0 0 0-1.3-.1 8.54 8.54 0 1 0 0 17.08c20.8 0 46.08-1.13 68.95-1.13a6.59 6.59 0 1 0 0-13.18 6.68 6.68 0 0 0-1.32.13 13.36 13.36 0 0 0-21.52-10.15 17.89 17.89 0 0 0-16.68-11.44zM153 369.27a15.23 15.23 0 0 0-12.05 5.9A12.31 12.31 0 0 0 129 385.3a7.4 7.4 0 0 0-1.11-.08 7.29 7.29 0 1 0 0 14.57c17.74 0 39.31-1 58.82-1a5.62 5.62 0 1 0 0-11.24 5.7 5.7 0 0 0-1.13.11 11.4 11.4 0 0 0-18.34-8.66 15.26 15.26 0 0 0-14.24-9.73zM215.67 220.63a23.91 23.91 0 0 0-18.92 9.26A19.32 19.32 0 0 0 178 245.78a11.61 11.61 0 0 0-1.74-.13 11.44 11.44 0 1 0 0 22.87c27.85 0 61.7-1.51 92.32-1.51a8.82 8.82 0 1 0 0-17.65 8.94 8.94 0 0 0-1.77.18A17.9 17.9 0 0 0 238 235.95a24 24 0 0 0-22.33-15.32z" class="cls-12"/>
<path d="M196.6 523.67h8.37l.04-8.41-8.41 8.41z" class="cls-13"/>
<path d="M199.32 529.07H205a.81.81 0 1 1 0 1.61h-5.65v2.37h-1.61v-12.59a.81.81 0 1 1 1.61 0v5.38H205a.81.81 0 0 1 0 1.61h-5.65v1.61z" class="cls-14"/>
<path d="M204.97 533.1v-19.36l19.36-19.36 25.81 25.82v12.9h-45.17z" class="cls-15"/>
<path d="M250.14 520.2v12.9h35.85v-19.36h-23.64l6.04 6.46h-18.25z" class="cls-16"/>
<path d="M270 494.38h-45.67l25.81 25.82h18.25l-6.04-6.46h27.01L270 494.38z" class="cls-13"/>
<path d="M250.34 485.34h10.67v17.28h-10.67z" class="cls-10"/>
<path d="M247.38 480.93h16.6v4.71h-16.6z" class="cls-10"/>
<path d="M210.84 514h5.77v13.22h-5.77zM221.14 514h5.77v13.22h-5.77z" class="cls-1"/>
<path d="M233.13 517.09h5.77v16.01h-5.77z" class="cls-17"/>
<path d="M256.24 523.79h5.77v5.17h-5.77zM272.49 518.03h9.61v10.14h-9.61z" class="cls-1"/>
<path d="M272.49 522.58a.81.81 0 0 1 0-1.61h9.61a.81.81 0 0 1 0 1.61h-9.61z" class="cls-18"/>
<path d="M223.61 493.63a1.07 1.07 0 0 1 1.52 0L251 519.54a1.07 1.07 0 0 1-1.52 1.52l-25.1-25.16-28.28 28.28a1.07 1.07 0 1 1-1.52-1.52z" class="cls-19"/>
<path d="M250.14 521.27a1.07 1.07 0 0 1 0-2.15h18.25a1.07 1.07 0 0 1 0 2.15h-18.25zM262.35 514.82a1.07 1.07 0 0 1 0-2.15h27a1.07 1.07 0 0 1 0 2.15h-27z" class="cls-19"/>
<path d="M356.8 421.12c-4.28 4.22-9.41 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M356.8 421.12c-4.28 4.22-9.41 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
<path d="M239.15 407.15c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M239.15 407.15c-4.28 4.22-9.4 9.69-10.45 15a9.76 9.76 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
<path d="M332.887 408.086l44.848-153.565 3.388.99-44.847 153.565zM269.147 242.175l3.526-.168 7.648 159.797-3.525.168zM356.93 408.03l59.335-148.57 3.278 1.31-59.334 148.57zM217.413 236.783l3.494-.505 22.9 158.333-3.493.506z" class="cls-22"/>
<path d="M314.19 383.78a12 12 0 0 0 4.1 3c2.34-5.06 5.21-14.71 8-23.87l-5.25-.62a150.41 150.41 0 0 1-6.85 21.49z" class="cls-23"/>
<path d="M322.53 385.92l22.27 2.64 3.2-27.26a5 5 0 0 0-4.35-5.53l-12.4-1.47a5 5 0 0 0-5.53 4.35z" class="cls-16"/>
<path d="M320 411.45l8.78 1a19 19 0 0 1-.15 6.56l-15.36-1.82c.42-3.49 6.52-3.58 6.73-5.74z" class="cls-24"/>
<path d="M313.073 417.213l.152-1.28 15.78 1.873-.153 1.28z" class="cls-25"/>
<path d="M341.25 414l-8.78-1a19 19 0 0 0-1.39 6.42l15.36 1.82c.47-3.6-5.44-5.11-5.19-7.24z" class="cls-24"/>
<path d="M330.913 419.318l.152-1.28 15.78 1.872-.153 1.28z" class="cls-25"/>
<path d="M345.19 385.57l-22.33-2.65-3.38 28.51 10.07 1.19 2.12-24.41 3.72.44-3.65 24.23 10.07 1.19z" class="cls-13"/>
<path d="M326.46 383.6l1.13 1.25-4.54 4.37-1.13-1.25 4.54-4.37zM341.52 385.39l-1.39.95 3.39 5.31 1.39-.95-3.39-5.31z" class="cls-26"/>
<path d="M343.56 355.76v.34l-.08.2a6.28 6.28 0 0 1-12-1.43v-.55l2.88.34a3.42 3.42 0 0 0 1 1.72 3.42 3.42 0 0 0 4.09.49 3.42 3.42 0 0 0 1.34-1.45z" class="cls-12"/>
<path d="M333.27 351.79l9.07 1.08c0 .11 0 .23.06.34l-.3 2.56a4.85 4.85 0 0 1-9.28-1.1l.3-2.56z" class="cls-23"/>
<path d="M333.31 352.53l9 .34c0 .11 0 .23.06.34l-.3 2.56a4.85 4.85 0 0 1-3.91 3z" class="cls-27"/>
<path d="M320.14 363.94l5.38 1.68 3.44-11c-4.05-1.2-6.42 2.49-7.05 4.29z" class="cls-16"/>
<path d="M341.24 323.41c-3-.29-12.18-1.09-13.6 9.52s.39 14.77 1.64 16.53a11 11 0 0 0 5.42 4.54 4.44 4.44 0 0 0 2.73 1.57z" class="cls-23"/>
<path d="M341.24 323.41c3 .43 12.1 1.8 11 12.44s-3.84 14.27-5.46 15.69a11 11 0 0 1-6.32 3.09 4.44 4.44 0 0 1-3 .89z" class="cls-28"/>
<path d="M328.65 338.55a1.89 1.89 0 0 0-1.32-1.56c-.86-.29-2.5 0-2.79 2.46s1.53 4.87 2.88 4.73 1.27-4.95 1.23-5.63z" class="cls-23"/>
<path d="M328 339.3a1.47 1.47 0 0 0-.92-1c-.52-.18-1.32 0-1.5 1.51s.73 2.95 1.55 2.86a1 1 0 0 0 .57-.84 1.15 1.15 0 0 1-.53-1.4 1.26 1.26 0 0 1 .82-.88c.01-.15.01-.25.01-.25zM349.74 341.07a2.42 2.42 0 0 1 1.85-1.2c.9-.08 2.44.56 2.14 3s-2.63 4.38-3.9 3.93-.09-5.73-.09-5.73z" class="cls-28"/>
<path d="M350.42 342a1.47 1.47 0 0 1 1.13-.73c.55 0 1.29.31 1.11 1.82s-1.4 2.7-2.18 2.42a1 1 0 0 1-.36-1 1.15 1.15 0 0 0 .84-1.24 1.26 1.26 0 0 0-.59-1z" class="cls-27"/>
<path d="M343.81 338.4l1.85.22a.66.66 0 0 0 .73-.58.66.66 0 0 0-.58-.73l-1.85-.22a.66.66 0 0 0-.73.58.66.66 0 0 0 .58.73z" class="cls-29"/>
<path d="M343.89 346.63c-.24 2-2.83 3.42-5.77 3.07s-5.14-2.29-4.89-4.33z" class="cls-12"/>
<path d="M333.23 337.15l1.85.22a.66.66 0 0 0 .73-.58.66.66 0 0 0-.58-.73l-1.85-.22a.66.66 0 0 0-.73.58.66.66 0 0 0 .58.73z" class="cls-29"/>
<path d="M327.54 335.9c-1.74-11.29 6.39-15.55 14-14.65s14.47 6.94 10.14 17.51c.22-2.3.42-5.11-.49-6.73-1.51-2.75-5.69-4.92-10.3-5.29-4.56-.72-9.14.41-11.25 2.73-1.3 1.37-1.77 4.14-2.1 6.43z" class="cls-29"/>
<circle cx="344.9" cy="341.69" r="1.03" transform="rotate(-83.23 344.905 341.69)"/>
<path d="M346.31 341.73a.32.32 0 1 0 .45-.45 2.48 2.48 0 0 0-3.65-.37.32.32 0 0 0 .35.53 1.93 1.93 0 0 1 2.85.29z" class="cls-29"/>
<circle cx="333.4" cy="340.32" r="1.03" transform="rotate(-83.23 333.4 340.322)"/>
<path d="M332 340a.32.32 0 1 1-.33-.54 2.48 2.48 0 0 1 3.63.5.32.32 0 1 1-.47.43 1.93 1.93 0 0 0-2.84-.39z" class="cls-29"/>
<path d="M353.64 388.46a12 12 0 0 1-4.69 1.95c-1.09-5.47-1.62-15.52-2.21-25.09l5.25.62a150.41 150.41 0 0 0 1.65 22.52z" class="cls-23"/>
<path d="M352.5 367.78l-5.62.37-.76-11.53c4.22-.22 5.66 3.93 5.85 5.83z" class="cls-16"/>
<path d="M306.22 407.49l-5.71-.68a12.06 12.06 0 0 0-1.61 5.37l11.87 1.41c.39-2.97-3.84-4.91-4.55-6.1z" class="cls-30"/>
<path d="M298.71 412.18l.087-.724 12.214 1.45-.085.725z" class="cls-31"/>
<path d="M308.6 409.89a.43.43 0 0 0 .29-.81s-1.68-.64-4.53 1.61a.43.43 0 1 0 .53.68c2.47-1.95 3.7-1.48 3.71-1.48zM307.54 408.74a.43.43 0 0 0 .29-.81s-1.68-.64-4.53 1.61a.43.43 0 1 0 .53.68c2.48-1.95 3.7-1.48 3.71-1.48z" class="cls-32"/>
<path d="M290.69 405.65l5.71.68a12.06 12.06 0 0 1 .31 5.6l-11.87-1.41c.31-2.99 4.88-3.88 5.85-4.87z" class="cls-30"/>
<path d="M284.68 410.527l.086-.725 12.214 1.45-.086.725z" class="cls-8"/>
<path d="M287.81 407.43a.43.43 0 1 1-.1-.85s1.78-.22 4 2.63a.43.43 0 0 1-.68.53c-1.91-2.48-3.22-2.31-3.22-2.31zM289.11 406.55a.43.43 0 0 1-.09-.85s1.78-.23 4 2.63a.43.43 0 1 1-.68.53c-1.92-2.48-3.23-2.31-3.23-2.31z" class="cls-32"/>
<path d="M299.47 407.756l3.483-29.324 9.602 1.14-3.48 29.324zM287.693 406.35l3.48-29.325 9.603 1.14-3.48 29.324z" class="cls-33"/>
<path d="M312.26 399l-25.38-3 6.77-44a5.06 5.06 0 0 1 5.54-4.36l12.43 1.48a5.06 5.06 0 0 1 4.36 5.54z" class="cls-34"/>
<path d="M321.51 381.33a12 12 0 0 1-4.69 1.95c-1.41-7.09-1.88-21.87-2.77-33.33 4.19-.22 5.75 3.89 5.81 5.78a156.51 156.51 0 0 0 1.65 25.6zM282.27 376.68a12 12 0 0 0 4.1 3c3-6.56 6.95-20.82 10.5-31.75-4-1.19-6.5 2.44-7 4.26a156.47 156.47 0 0 1-7.6 24.49z" class="cls-35"/>
<path d="M300.47 342.12l11.05 1.31-1.18 10c-.87 7.31-11.92 6-11.05-1.31z" class="cls-36"/>
<path d="M309.58 355.74l-9.34-11.68.23-1.94 11.05 1.31-1.18 10a5.94 5.94 0 0 1-.76 2.31z" class="cls-37"/>
<path d="M320.12 323.59c-1.53 2.93-15.8 14-22.78 20-1.36 1.17-4.8 2.13-5.57 5.17-1.29 5.07 7.07 8.61 11.35 9.08 7.38.8 14.37 1.86 17.42-4.44.54-1.12.26-4.56-2.47-5.31 2.93-6.73 2.76-19.86 2.05-24.5z" class="cls-38"/>
<path d="M309.28 315.14c3 .42 12 1.79 10.93 12.37s-3.82 14.19-5.43 15.6a11 11 0 0 1-6.29 3.08 4 4 0 0 1-5.71-.68 11 11 0 0 1-5.39-4.46c-1.24-1.75-3-5.89-1.63-16.44s10.5-9.76 13.52-9.47z" class="cls-36"/>
<path d="M309.28 315.14c3 .42 12 1.79 10.93 12.37s-3.82 14.19-5.43 15.6a11 11 0 0 1-6.29 3.08 4.41 4.41 0 0 1-3 .88z" class="cls-39"/>
<path d="M317.4 339.38a11.66 11.66 0 0 1-8.91 6.8 4 4 0 0 1-5.71-.68 11.66 11.66 0 0 1-7.07-8.7c0 3.54 4.74 11.7 6.88 16.24 9.82 1.22 13.25-8.25 14.82-13.66z" class="cls-40"/>
<path d="M296.76 320.82c-2.3 15.88 1.36 32.08 21.32 27.3-4.51 8.89-12.34 7.84-14 7.48a12.33 12.33 0 0 1-9.61-11c-1.05-8.32-.78-16.28 2.29-23.78z" class="cls-41"/>
<path d="M296.52 332.37c1.95-7.11 6.09-8.85 11.72-8.44l.84-7.1c-9.08-1.03-13.46 8.84-12.56 15.54z" class="cls-12"/>
<path d="M295.7 336.81c1-12.27 5.88-16.18 12.86-15.67l.71-6c-3-.29-12.11-1.08-13.52 9.47-.05.39-1.92 7.39-.05 12.2z" class="cls-41"/>
<path d="M317.65 334.87c-.23-7.37-3.85-10-9.42-11l.84-7.1c8.92 1.12 11 12.06 8.76 18.42-.04.17-.14-.04-.18-.32z" class="cls-12"/>
<path d="M317.4 339.38c1.86-12.17-1.93-17.11-8.84-18.25l.71-6c3 .42 12 1.79 10.93 12.37-.04.44.14 7.66-2.8 11.88z" class="cls-38"/>
<path d="M309.72 339.69a3.41 3.41 0 0 1-6.62-.79z" class="cls-12"/>
<path d="M314.19 334.39a.32.32 0 0 0 .45-.45 2.49 2.49 0 0 0-3.67-.37.32.32 0 1 0 .36.53 2 2 0 0 1 2.86.29z" class="cls-29"/>
<circle cx="312.68" cy="334.19" r=".96" transform="rotate(-83.23 312.683 334.193)"/>
<path d="M314.38 333.24a.19.19 0 1 0-.32-.23l-.42.58a.19.19 0 1 0 .32.23z" class="cls-29"/>
<path d="M314.81 333.46a.19.19 0 1 0-.25-.29l-.65.56a.19.19 0 1 0 .26.29zM312.1 330.14a.46.46 0 1 0-.07.91 8.33 8.33 0 0 1 2.07.72.46.46 0 0 0 .41-.82 6.64 6.64 0 0 0-2.41-.81zM300 332.7a.32.32 0 0 1-.33-.55 2.49 2.49 0 0 1 3.65.5.32.32 0 0 1-.47.43 2 2 0 0 0-2.85-.39z" class="cls-29"/>
<circle cx="301.51" cy="332.86" r=".96" transform="rotate(-83.23 301.517 332.864)"/>
<path d="M300.08 331.54a.19.19 0 1 1 .36-.15l.27.66a.19.19 0 1 1-.36.15z" class="cls-29"/>
<path d="M299.61 331.66a.19.19 0 1 1 .32-.23l.5.7a.19.19 0 1 1-.32.23zM303 329.07a.46.46 0 1 1-.14.9 8.34 8.34 0 0 0-2.19.22.46.46 0 0 1-.21-.89 6.65 6.65 0 0 1 2.54-.23z" class="cls-29"/>
<path d="M258.49 354.506l3.575-30.118 23.892 2.836-3.575 30.118z"/>
<path d="M269.64 377.64l5.58.66-3.97 30.02-5.08-.79 1.44-12.76 2.03-17.13zM265.25 377.12l-5.58-.67-3.17 30.12 5.12.42 1.59-12.74 2.04-17.13z" class="cls-42"/>
<path d="M256.45 405.19l5.61.67a11.85 11.85 0 0 1 .31 5.51L250.69 410c.31-3 4.81-3.83 5.76-4.81z" class="cls-34"/>
<path d="M250.546 409.986l.085-.715 12.007 1.426-.085.715z" class="cls-43"/>
<path d="M271.72 407l-5.61-.67a11.85 11.85 0 0 0-1.59 5.28l11.67 1.39c.39-2.92-3.77-4.83-4.47-6z" class="cls-34"/>
<path d="M264.33 411.615l.086-.715 12.005 1.426-.084.715z" class="cls-43"/>
<path d="M256.39 376.72l22 2.61 2.84-23.91a4.92 4.92 0 0 0-4.29-5.45l-12.22-1.45a4.92 4.92 0 0 0-5.45 4.29z" class="cls-44"/>
<path d="M259 357.59s-3.76 14.65-5.65 21.58h-1.67l-2.27-2.6c2-7.5 5.22-20 5.23-20z" class="cls-42"/>
<path d="M253.89 357.56l5.25 1.64 3.36-10.77c-4-1.17-6.27 2.43-6.88 4.19z" class="cls-44"/>
<path d="M252.55 382.3l-.69-2.88a.92.92 0 0 1 .68-1.1.92.92 0 0 1 1.1.68l.69 2.88a.92.92 0 0 1-.68 1.1.92.92 0 0 1-1.1-.68z" class="cls-42"/>
<path d="M250.61 379.13l-.46 5.69a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-1 .85z" class="cls-42"/>
<path d="M249.65 377.65l-.46 5.69a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-1 .85z" class="cls-42"/>
<path d="M249.34 376.33l-.47 5.67a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-.99.87z" class="cls-42"/>
<path d="M279.06 350.4v.64a6.47 6.47 0 0 1-3.12 4.69 9.62 9.62 0 0 1-11.61-1.38 6.47 6.47 0 0 1-1.93-5.29c0-.15 0-.33.09-.54v-.08a12.09 12.09 0 0 1 3.36 0l2.52.3-.13 1.1-1.1-.13-1.42-.17c-.35 0-.7-.07-1-.09h-.09a4.32 4.32 0 0 0 1.33 3.39 7.44 7.44 0 0 0 8.83 1 4.32 4.32 0 0 0 2.08-3h-.09c-.34-.07-.69-.12-1-.16l-1.42-.17-1.1-.13.13-1.1 2.52.3a12.06 12.06 0 0 1 3.15.82z" class="cls-12"/>
<path d="M277.94 350a4.82 4.82 0 0 1 0 .89c-.38 3.23-3.93 5.47-7.92 5s-6.92-3.48-6.53-6.71a4.82 4.82 0 0 1 .19-.87 13.87 13.87 0 0 1 2.18.1l1.42.17.47-3.94.11-.27 7 .83v.29l-.47 3.94 1.42.17a13.87 13.87 0 0 1 2.13.4z" class="cls-42"/>
<path d="M275.3 354.8l-7.64-9.73v-.22h1.59l.11-.27 5.41.64v.29l-.47 3.94 1.42.17a13.87 13.87 0 0 1 2.14.42 4.82 4.82 0 0 1 0 .89 5.39 5.39 0 0 1-2.56 3.87z" class="cls-45"/>
<path d="M274.5 317.74c-2.89-.28-11.62-1-13 9.08s.37 14.09 1.56 15.77a10.54 10.54 0 0 0 5.17 4.28 3.81 3.81 0 0 0 5.48.65 10.54 10.54 0 0 0 6-3c1.55-1.35 4.16-4.81 5.21-15s-7.54-11.38-10.42-11.78z" class="cls-42"/>
<path d="M274.5 317.74c2.88.41 11.54 1.71 10.49 11.87s-3.66 13.61-5.21 15a10.54 10.54 0 0 1-6 2.95 4.23 4.23 0 0 1-2.88.85z" class="cls-46"/>
<path d="M262.49 332.17a1.8 1.8 0 0 0-1.26-1.49c-.82-.28-2.39 0-2.67 2.34s1.46 4.65 2.74 4.51 1.24-4.71 1.19-5.36z" class="cls-42"/>
<path d="M280.12 324.23c.87 2.49.33 6.23 4.55 9.19 4.3-10.68-2.83-16.71-10.27-17.43-7.87-.76-14.2 3.76-13.24 14.87 2.84-3.13 17.39-5.26 18.96-6.63z" class="cls-29"/>
<path d="M275.74 341.17a4 4 0 0 1-7.92-.94z" class="cls-12"/>
<path d="M276.66 379l-18.57-2.2-1.39 10.84 18.79 2.23z" class="cls-47"/>
<path d="M272.74 331.5a49.29 49.29 0 0 1-11.06-2.89l1.08-6.1c3.38-7.4 11.74-5.82 11.74-5.82s8.49.42 10.05 8.41l-.37 6.18c-.75.12-2.1.15-2.92.22-.48 0 .23-2.51-1.1-5.63.38 3.13-.2 5.79-.78 5.79-1.98.04-4.75.01-6.64-.16z" class="cls-29"/>
<path d="M266.7 361.4a8 8 0 0 0-2.17-2.13c-.62-.31-.7 3-.55 3.87-.08 1-.72 2.26.56 4.56 1.2 2.15 2.74 3.16 3.71 2.76.85.62 2.59 0 4.26-1.81s1.45-3.29 1.61-4.3c.35-.83 1.05-4.05.37-3.89a8 8 0 0 0-2.6 1.56 5.79 5.79 0 0 0-5.19-.62z" class="cls-12"/>
<path d="M267.47 367.78a.28.28 0 1 1-.53.16 2.34 2.34 0 0 0-1.87-1.46.28.28 0 1 1 .11-.54 2.91 2.91 0 0 1 2.29 1.84zM269.9 368.3a.28.28 0 0 1-.48-.28 2.91 2.91 0 0 1 2.66-1.26.28.28 0 1 1 0 .55 2.35 2.35 0 0 0-2.18.99zM268.3 370.14a2.81 2.81 0 0 1-.66-.72c0-.51 1.56-.33 1.46.17a2.8 2.8 0 0 1-.8.55z" class="cls-44"/>
<path d="M249.73 374.84l4.29 1.33a.89.89 0 0 1 .59 1.12.89.89 0 0 1-1.12.59l-4.29-1.33a.89.89 0 0 1-.59-1.12.89.89 0 0 1 1.12-.59z" class="cls-48"/>
<path d="M261.86 332.85a1.41 1.41 0 0 0-.88-.93c-.5-.17-1.26 0-1.43 1.44s.7 2.81 1.48 2.73a1 1 0 0 0 .54-.81 1.09 1.09 0 0 1-.5-1.34 1.19 1.19 0 0 1 .78-.84c.01-.15.01-.25.01-.25z" class="cls-46"/>
<path d="M278.11 334.62a1 1 0 0 1 .26.07l.23-.33a.19.19 0 0 1 .31.21l-.23.33.34-.2a.19.19 0 0 1 .19.32l-.32.19h.41a.19.19 0 0 1 0 .37h-.38a.92.92 0 0 1 0 .1 1 1 0 1 1-.81-1.06zM277.76 332.55a.5.5 0 1 1 .08-1 5.43 5.43 0 0 1 1.83.53.5.5 0 0 1-.45.89 4.31 4.31 0 0 0-1.46-.42zM267.06 333.31a1 1 0 0 0-.27 0l-.15-.38a.19.19 0 0 0-.35.13l.15.38-.28-.27a.19.19 0 0 0-.26.27l.27.26-.38-.14a.19.19 0 0 0-.13.35l.36.13a.92.92 0 0 0 0 .1 1 1 0 1 0 1.04-.83zM268 330.39a.5.5 0 1 1-.16 1 4.31 4.31 0 0 0-1.52.08.5.5 0 0 1-.23-1 5.43 5.43 0 0 1 1.91-.08z" class="cls-29"/>
<path d="M279.44 335.78a.32.32 0 1 0 .34-.55 2.52 2.52 0 0 0-3.7.46.32.32 0 0 0 .47.44 2 2 0 0 1 2.89-.36zM265.48 334.12a.32.32 0 1 1-.21-.61 2.52 2.52 0 0 1 3.49 1.32.32.32 0 0 1-.56.32 2 2 0 0 0-2.72-1z" class="cls-29"/>
<path d="M280 360.16a165.74 165.74 0 0 0 4.56 22.84 1.49 1.49 0 0 0 .49.87l1-1.19 2.43-2.49c-1.66-3.06-3.83-19.46-3.66-20.09z" class="cls-42"/>
<path d="M285.33 361.29l-5.48.36-.75-11.25c4.12-.21 5.53 3.83 5.71 5.69z" class="cls-44"/>
<path d="M281.7 326.71l4.24.5-1.7 30.15c-.13 2.35-4.42 5.09-6.75 4.81z" class="cls-29"/>
<path d="M336.33 406.29l2.11-17.75a5 5 0 0 1 5.48-4.31l12.29 1.46a4.94 4.94 0 0 1 4.31 5.48l-2.11 17.75z" class="cls-48"/>
<circle cx="340.18" cy="361.87" r="6.71" class="cls-49" transform="rotate(-83.23 340.185 361.876)"/>
<circle cx="365.07" cy="364.83" r="6.71" class="cls-49" transform="rotate(-83.23 365.073 364.83)"/>
<path d="M355.19 380.64l-9.11-1.08-.74 6.23 3.93 5.85 5.18-4.77.74-6.23z" class="cls-36"/>
<path d="M352.36 389.23l-6.47-8.08.19-1.59 9.11 1.08-.74 6.23-2.09 2.36z" class="cls-37"/>
<path d="M353.91 352.55c-2.94-.28-11.82-1.05-13.2 9.24s.38 14.34 1.59 16a10.73 10.73 0 0 0 5.26 4.35 3.88 3.88 0 0 0 5.58.66 10.72 10.72 0 0 0 6.14-3c1.58-1.38 4.23-4.9 5.3-15.22s-7.75-11.58-10.67-12.03z" class="cls-37"/>
<path d="M353.91 352.55c-2.94-.28-11.82-1.05-13.2 9.24s.38 14.34 1.59 16a10.73 10.73 0 0 0 5.26 4.35 4.31 4.31 0 0 0 2.65 1.53z" class="cls-36"/>
<path d="M341.88 367.27a2.35 2.35 0 0 0-1.47-1.55c-.83-.28-2.43 0-2.71 2.38s1.48 4.73 2.79 4.59 1.39-5.42 1.39-5.42z" class="cls-36"/>
<path d="M341 368a1.43 1.43 0 0 0-.9-.95c-.51-.17-1.28 0-1.46 1.47s.71 2.86 1.51 2.78a1 1 0 0 0 .55-.82 1.11 1.11 0 0 1-.51-1.36 1.21 1.21 0 0 1 .8-.85c.01-.21.01-.27.01-.27z" class="cls-37"/>
<path d="M347.34 364.89a.51.51 0 1 0 .16-1 5.48 5.48 0 0 0-1.94.09.51.51 0 1 0 .24 1 4.39 4.39 0 0 1 1.54-.09zM357.48 365.07a.51.51 0 1 0-.08 1 4.39 4.39 0 0 1 1.48.44.51.51 0 1 0 .46-.91 5.48 5.48 0 0 0-1.86-.53z" class="cls-50"/>
<path d="M346.81 357.63c-1.45 2.25-1.81 6.08-6.69 8-1.71-11.59 6.78-15.85 14.31-14.79 8 1.12 14.53 7.37 10.93 18.12-5.22-1.76-7.74-4.79-9.94-8.09.55 2.65 2.07 4.13 1.64 4-5.01-1.87-6.82-2.37-10.25-7.24z" class="cls-50"/>
<path d="M362.35 369.68a1.84 1.84 0 0 1 1.6-1.17c.87-.08 2.37.54 2.08 3s-2.55 4.25-3.79 3.81-.09-5.01.11-5.64z" class="cls-37"/>
<path d="M362.82 370.55a1.43 1.43 0 0 1 1.09-.71c.53 0 1.25.3 1.07 1.77s-1.36 2.62-2.11 2.35a1 1 0 0 1-.35-.93 1.11 1.11 0 0 0 .82-1.2 1.22 1.22 0 0 0-.57-1z" class="cls-51"/>
<path d="M353.63 375.59a2.45 2.45 0 1 1-4.86-.58z" class="cls-12"/>
<path d="M349.27 391.64l5.21-.88.56-5.21-.43-.05-5.34 6.14zM349.27 391.64l-4.87-2.08.68-5.19.42.05 3.77 7.22z" class="cls-52"/>
<path d="M359.11 370.24a.32.32 0 0 0 .46-.46 2.53 2.53 0 0 0-3.72-.37.32.32 0 1 0 .36.54 2 2 0 0 1 2.91.29z" class="cls-29"/>
<circle cx="357.58" cy="370.03" r=".97" transform="rotate(-83.23 357.584 370.03)"/>
<path d="M359.3 369.07a.2.2 0 1 0-.32-.23l-.43.59a.2.2 0 1 0 .32.23z" class="cls-29"/>
<path d="M359.74 369.3a.2.2 0 0 0-.26-.3l-.66.57a.2.2 0 1 0 .26.3zM344.71 368.53a.32.32 0 1 1-.34-.55 2.53 2.53 0 0 1 3.71.51.32.32 0 1 1-.48.44 2 2 0 0 0-2.89-.39z" class="cls-29"/>
<circle cx="346.24" cy="368.69" r=".97" transform="rotate(-83.23 346.247 368.69)"/>
<path d="M344.79 367.35a.2.2 0 1 1 .37-.15l.27.67a.2.2 0 1 1-.37.15z" class="cls-29"/>
<path d="M344.32 367.47a.2.2 0 0 1 .32-.23l.51.71a.2.2 0 0 1-.32.23z" class="cls-29"/>
<path d="M335.15 406.15c1.38-4.24 2.81-9 4.24-13.62l-5.33-.63c-1.23 5-2.48 9.35-3.94 13.65z" class="cls-36"/>
<path d="M333.12 393.59l5.46 1.7 3.49-11.2c-4.11-1.22-6.52 2.53-7.16 4.36z" class="cls-48"/>
<path d="M333.3 392.9l5.67 1.83-.61 1.81-5.67-1.83.61-1.81z" class="cls-52"/>
<path d="M271.3 398.57l1.54-13a5.08 5.08 0 0 1 5.63-4.44l12.63 1.5a5.08 5.08 0 0 1 4.44 5.63l-1.54 13z" class="cls-53"/>
<path d="M278.19 381.16l.61 1.06 3.89 7.27 1 1.84 1.38-1.55 5.48-6.16.77-.9h-.2l-12.63-1.5z" class="cls-12"/>
<path d="M290.21 376.23l-9.36-1.11-.76 6.41 3.88 7.27 5.48-6.16.76-6.41z" class="cls-23"/>
<path d="M287.3 385.06l-6.65-8.3.2-1.64 9.36 1.11-.76 6.41-2.15 2.42z" class="cls-27"/>
<path d="M289.11 349.37c3 .42 12 1.78 10.91 12.35s-3.81 14.16-5.42 15.57a11 11 0 0 1-6.28 3.07 4 4 0 0 1-5.7-.68 11 11 0 0 1-5.38-4.45c-1.24-1.75-3-5.88-1.63-16.41s10.49-9.75 13.5-9.45z" class="cls-23"/>
<path d="M289.11 349.37c3 .42 12 1.78 10.91 12.35s-3.81 14.16-5.42 15.57a11 11 0 0 1-6.28 3.07 4.4 4.4 0 0 1-3 .88z" class="cls-28"/>
<path d="M289.86 373.27a3.57 3.57 0 0 1-7.08-.84z" class="cls-12"/>
<path d="M292.56 364.16a.52.52 0 1 1 .08-1 5.66 5.66 0 0 1 1.91.56.52.52 0 1 1-.47.93 4.49 4.49 0 0 0-1.52-.49zM282.44 361.91a.52.52 0 1 1-.16 1 4.49 4.49 0 0 0-1.58.08.52.52 0 1 1-.24-1 5.66 5.66 0 0 1 1.98-.08z" class="cls-29"/>
<path d="M283.12 396a3 3 0 0 1 3.12-1.58 2.76 2.76 0 0 1 2.58 2.9 5.66 5.66 0 0 1-1.76 3.13l-8.81-1a5.66 5.66 0 0 1-1-3.46 2.76 2.76 0 0 1 3.19-2.22 3 3 0 0 1 2.68 2.23z" class="cls-12"/>
<path d="M294.3 367.68a.33.33 0 0 0 .46-.46c-.65-.65-1.74-1.73-3.76-.38a.33.33 0 1 0 .36.54 2 2 0 0 1 2.93.3z" class="cls-29"/>
<circle cx="292.75" cy="367.48" r=".98" transform="rotate(-83.23 292.76 367.48)"/>
<path d="M294.49 366.51a.2.2 0 1 0-.32-.23l-.43.6a.2.2 0 0 0 .32.23z" class="cls-29"/>
<path d="M294.93 366.74a.2.2 0 0 0-.26-.3l-.67.56a.2.2 0 1 0 .26.3zM279.77 366a.33.33 0 1 1-.34-.56c.78-.48 2.09-1.28 3.74.51a.33.33 0 1 1-.48.44 2 2 0 0 0-2.92-.4z" class="cls-29"/>
<circle cx="281.32" cy="366.12" r=".98" transform="rotate(-83.23 281.326 366.125)"/>
<path d="M279.85 364.77a.2.2 0 1 1 .37-.15l.28.68a.2.2 0 0 1-.37.15z" class="cls-29"/>
<path d="M279.37 364.89a.2.2 0 0 1 .32-.23l.51.72a.2.2 0 1 1-.32.23zM303.18 353.32a.93.93 0 1 1 .42 1.82 6.3 6.3 0 0 0-3.8 2.71.93.93 0 0 1-1.62-.93 8.27 8.27 0 0 1 5-3.6zM304.89 364.63a.93.93 0 1 1-1.13 1.49 4.08 4.08 0 0 0-3.92-.46.93.93 0 1 1-.72-1.72 6 6 0 0 1 5.77.69z" class="cls-29"/>
<ellipse cx="303.53" cy="364.93" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-83.23 303.537 364.93)"/>
<path d="M304.82 358.93a.93.93 0 0 1-.57 1.78 4.05 4.05 0 0 0-3.81 1 .94.94 0 1 1-1.29-1.35 6 6 0 0 1 5.67-1.43z" class="cls-29"/>
<ellipse cx="303.65" cy="359.69" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-14.05 303.772 359.798)"/>
<ellipse cx="302.49" cy="354.41" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.52 302.444 354.37)"/>
<path d="M299.34 348.84a.94.94 0 0 1 1.33 1.32c-.14.15-.34.32-.56.52a12 12 0 0 0-2.67 3.31.93.93 0 0 1-1.62-.93 13.69 13.69 0 0 1 3-3.77c.18-.15.35-.29.52-.45z" class="cls-29"/>
<ellipse cx="299.35" cy="349.99" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.53 299.335 349.985)"/>
<path d="M294.61 345.85a.93.93 0 0 1 1.66.86c-.11.21-.24.43-.38.66a12 12 0 0 0-1.58 3.95.93.93 0 0 1-1.82-.41 13.69 13.69 0 0 1 1.8-4.49c.12-.2.23-.42.32-.57z" class="cls-29"/>
<ellipse cx="295.01" cy="346.94" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.52 295.067 346.982)"/>
<path d="M273.66 351.58a.93.93 0 0 1 .84-1.67 8.27 8.27 0 0 1 4 4.66.93.93 0 0 1-1.79.52 6.3 6.3 0 0 0-3.05-3.51z" class="cls-29"/>
<path d="M299.58 366.46c4.24-10.54-2.63-16.24-10-17.2-8.66-1.12-16.21 3.3-14.43 14.61 0 0 1.3.58 1.44-.12.47-3.49.78-5.06 2-6.39 1.66-1.87 8.74-.84 14.9-2.11a5 5 0 0 1 3.2 2.37c1.63 3.07 2.57 4.51 2.13 8.76a4.36 4.36 0 0 0 .76.08z" class="cls-29"/>
<path d="M297.1 352.84a.79.79 0 0 1 0 1.11l-2.45 2.36a.79.79 0 0 1-1.11 0 .79.79 0 0 1 0-1.11l2.45-2.36a.79.79 0 0 1 1.11 0zM299 354.27a.79.79 0 0 1 0 1.11l-2.45 2.36a.79.79 0 0 1-1.11 0 .79.79 0 0 1 0-1.11l2.45-2.35a.79.79 0 0 1 1.11-.01z" class="cls-55"/>
<path d="M270.94 362.22a.93.93 0 0 1-.75-1.71 6 6 0 0 1 5.78.68.93.93 0 0 1-1.11 1.5 4.08 4.08 0 0 0-3.92-.47z" class="cls-29"/>
<ellipse cx="271.44" cy="361.12" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-83.23 271.442 361.12)"/>
<path d="M271.72 356.85a.93.93 0 0 1-.13-1.86 6 6 0 0 1 5.18 2.69.94.94 0 1 1-1.57 1 4.05 4.05 0 0 0-3.48-1.83z" class="cls-29"/>
<ellipse cx="272.55" cy="356" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-62.4 272.587 356.01)"/>
<ellipse cx="274.91" cy="351.14" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.95 274.927 351.15)"/>
<path d="M277.68 347.43a.94.94 0 0 1 1.6-1c.12.2.24.37.36.55a13.67 13.67 0 0 1 2.07 4.38.93.93 0 0 1-1.79.52 12 12 0 0 0-1.82-3.84c-.17-.22-.32-.44-.42-.61z" class="cls-29"/>
<ellipse cx="279" cy="347.58" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.93 279.006 347.586)"/>
<path d="M282.76 345.1a.93.93 0 1 1 1.81-.45c.05.2.11.4.18.63a13.69 13.69 0 0 1 .7 4.79.93.93 0 0 1-1.87 0 12 12 0 0 0-.61-4.21c-.08-.28-.15-.53-.21-.76z" class="cls-29"/>
<ellipse cx="283.93" cy="345.62" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.94 283.966 345.642)"/>
<path d="M288.19 344.47a.94.94 0 1 1 1.87 0v1.13c0 .86.12 2 .09 4a.93.93 0 0 1-1.87 0c0-1.83 0-3-.09-3.84.02-.52 0-.9 0-1.29z" class="cls-29"/>
<ellipse cx="289.12" cy="345.25" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.93 289.126 345.25)"/>
<path d="M276.61 364.38a1.88 1.88 0 0 0-1.31-1.55c-.85-.29-2.48 0-2.77 2.44s1.52 4.84 2.85 4.7 1.28-4.91 1.23-5.59z" class="cls-23"/>
<path d="M275.93 365.13a1.46 1.46 0 0 0-.92-1c-.52-.17-1.31 0-1.49 1.5s.73 2.93 1.54 2.84a1 1 0 0 0 .57-.84 1.14 1.14 0 0 1-.52-1.39 1.24 1.24 0 0 1 .81-.87c.01-.14.01-.24.01-.24zM297.54 366.89a2.4 2.4 0 0 1 1.84-1.19c.89-.08 2.42.55 2.12 3s-2.61 4.35-3.87 3.9-.09-5.71-.09-5.71z" class="cls-28"/>
<path d="M298.22 367.78a1.46 1.46 0 0 1 1.12-.73c.54 0 1.28.31 1.1 1.81s-1.39 2.67-2.16 2.4a1 1 0 0 1-.35-.95 1.14 1.14 0 0 0 .83-1.23 1.24 1.24 0 0 0-.59-1z" class="cls-27"/>
<path d="M272.4 391.4c-.69 2.17-1.53 4.56-2.49 7l-5.16-.61c1.6-3.87 2.82-7.11 2.82-7.39z" class="cls-23"/>
<path d="M267.15 391l5.59 1.74 3.58-11.48c-4.21-1.25-6.68 2.59-7.34 4.47z" class="cls-53"/>
<path d="M294.55 394c.16 2.27.42 4.79.78 7.39l5.16.61c-.65-4.14-1.08-7.57-1-7.85z" class="cls-23"/>
<path d="M299.74 394.87l-5.84.39-.79-12c4.39-.23 5.89 4.08 6.09 6.06z" class="cls-53"/>
<path d="M247 378.52c-.32 0-4 .31-4.35.3-9.81-.32-14.89-7.43-17.46-15.91-.06-.27-.12-.56-.18-.87v-.16A4.43 4.43 0 0 0 226 360l.66-2.19a.94.94 0 0 0-.66-1.16 1 1 0 0 0-1.07.46l-.14-.08-.06-4.14a.94.94 0 0 0-1-.93.94.94 0 0 0-.93 1v1.36l-.5-2.32a1 1 0 0 0-1.13-.7.94.94 0 0 0-.7 1.13l1.07 4.54-1.44-4.28a.94.94 0 0 0-1.19-.59.94.94 0 0 0-.59 1.19l1.45 4.3-1.28-2.17a.94.94 0 0 0-1.29-.33.94.94 0 0 0-.33 1.29l1.22 2.07a.44.44 0 0 0 0 .54l1.41 3a2 2 0 0 0 1 1.26c2.26 10.76 8.25 20.26 22.25 21.83z" class="cls-42"/>
<path d="M245.23 377.81c-.32 0-3.94-.27-4.24-.28a17.89 17.89 0 0 1-5-.84l-1.47 7a28.71 28.71 0 0 0 6.36 1.42z" class="cls-56"/>
<path d="M259.86 355c1.06.46 4-5.77 5.06-3.27 5.59 5.39-1.21 19.27 5.27 19.09-2.74 7.69-15.85 10.28-20.88 4.8 0 0 6.36-18.54 10.55-20.62z" class="cls-57"/>
<path d="M251.22 354c-1.14.2-3.12-6.77-4.73-4.58-6.69 3.94-2.76 19.24-9 17.54.87 8.12 13 13.71 19.18 9.55-.03-.02-1.87-19.51-5.45-22.51z" class="cls-57"/>
<path d="M239.4 393.69l1.36-11.45a5.07 5.07 0 0 1 5.62-4.43l12.6 1.5a5.07 5.07 0 0 1 4.43 5.62L262 396.38z" class="cls-56"/>
<path d="M246.1 377.79l.61 1.05 3.88 7.25 1 1.83 1.38-1.55 5.47-6.14.77-.89h-.2l-12.6-1.5z" class="cls-12"/>
<path d="M258.09 372.88l-9.34-1.11-.76 6.39 3.88 7.26 5.46-6.15.76-6.39z" class="cls-42"/>
<path d="M255.18 381.69l-6.62-8.29.19-1.63 9.34 1.11-.76 6.39-2.15 2.42z" class="cls-45"/>
<path d="M256.68 344.85c3 .42 12 1.78 10.88 12.32s-3.8 14.13-5.41 15.53a10.94 10.94 0 0 1-6.26 3.06 4 4 0 0 1-5.69-.68 10.94 10.94 0 0 1-5.37-4.44c-1.23-1.74-3-5.87-1.62-16.37s10.47-9.71 13.47-9.42z" class="cls-42"/>
<path d="M256.68 344.85c3 .42 12 1.78 10.88 12.32s-3.8 14.13-5.41 15.53a10.94 10.94 0 0 1-6.26 3.06 4.39 4.39 0 0 1-3 .88z" class="cls-46"/>
<path d="M244.22 359.82a1.87 1.87 0 0 0-1.31-1.55c-.85-.29-2.48 0-2.77 2.43s1.51 4.82 2.85 4.68 1.27-4.88 1.23-5.56z" class="cls-42"/>
<path d="M243.54 360.57a1.45 1.45 0 0 0-.91-1c-.52-.17-1.31 0-1.49 1.5s.72 2.92 1.53 2.83a1 1 0 0 0 .57-.84 1.13 1.13 0 0 1-.52-1.39 1.24 1.24 0 0 1 .81-.87c.01-.13.01-.23.01-.23zM265.1 362.32a2.4 2.4 0 0 1 1.83-1.19c.89-.08 2.42.55 2.12 3s-2.6 4.34-3.86 3.89-.09-5.7-.09-5.7z" class="cls-46"/>
<path d="M265.77 363.21a1.46 1.46 0 0 1 1.11-.72c.54 0 1.27.3 1.09 1.8s-1.39 2.67-2.16 2.4a1 1 0 0 1-.35-.95 1.13 1.13 0 0 0 .83-1.23 1.24 1.24 0 0 0-.59-1z" class="cls-45"/>
<path d="M262.51 351.59c.9 2.58.35 6.47 4.72 9.54 4.46-11.09-2.93-17.34-10.66-18.09-8.17-.79-16.17 3.84-15.17 15.37 4.71.68 7.15-4.46 7.41-4.49 5.25.61 9.13 1.67 13.7-2.33z" class="cls-21"/>
<path d="M257.43 368.69c-.41 1.22-2 2-3.79 1.79s-3.16-1.35-3.27-2.63z" class="cls-12"/>
<path d="M260.13 359.6a.52.52 0 1 1 .08-1 5.64 5.64 0 0 1 1.9.55.52.52 0 0 1-.47.93 4.45 4.45 0 0 0-1.51-.48zM250 357.36a.52.52 0 0 1-.16 1 4.46 4.46 0 0 0-1.58.08.52.52 0 0 1-.24-1 5.64 5.64 0 0 1 1.98-.08z" class="cls-45"/>
<path d="M262 362.82a.33.33 0 0 0 .47-.47c-.65-.65-1.75-1.75-3.8-.38a.33.33 0 1 0 .37.55 2 2 0 0 1 3 .3z" class="cls-14"/>
<circle cx="260.43" cy="362.61" r=".99" class="cls-8" transform="rotate(-83.23 260.438 362.615)"/>
<path d="M262.19 361.63a.2.2 0 1 0-.33-.23l-.43.6a.2.2 0 0 0 .33.23z" class="cls-14"/>
<path d="M262.63 361.86a.2.2 0 1 0-.26-.3l-.67.58a.2.2 0 0 0 .26.3zM247.32 361.08a.33.33 0 0 1-.34-.56c.79-.48 2.12-1.29 3.78.52a.33.33 0 1 1-.49.45 2 2 0 0 0-2.95-.4z" class="cls-14"/>
<circle cx="248.88" cy="361.24" r=".99" class="cls-8" transform="rotate(-83.23 248.886 361.245)"/>
<path d="M247.4 359.87a.2.2 0 0 1 .37-.15l.28.69a.2.2 0 1 1-.37.15z" class="cls-14"/>
<path d="M246.91 360a.2.2 0 1 1 .33-.23l.52.73a.2.2 0 0 1-.33.23z" class="cls-14"/>
<path d="M246.78 349.88a.78.78 0 0 1 1.05.34l1.77 3.45a.78.78 0 0 1-.34 1.05.79.79 0 0 1-1.05-.34l-1.77-3.45a.79.79 0 0 1 .34-1.05z" class="cls-48"/>
<path d="M263.59 390.63c.13 1.86.33 3.89.59 6l5.12.61a63.72 63.72 0 0 1-.79-6.44z" class="cls-42"/>
<path d="M268.78 391.47l-5.84.39-.79-12c4.39-.23 5.89 4.08 6.09 6.06z" class="cls-56"/>
<path d="M326.77 401l1.5-12.61a5 5 0 0 0-4.4-5.58l-12.52-1.49a5 5 0 0 0-5.58 4.4l-1.5 12.61z" class="cls-58"/>
<path d="M323.75 382.77v.35l-.08.2a6.35 6.35 0 0 1-12.16-1.44v-.56l2.9.34a3.45 3.45 0 0 0 1 1.74 3.45 3.45 0 0 0 4.13.49 3.45 3.45 0 0 0 1.36-1.47z" class="cls-12"/>
<path d="M313.36 378.76l9.16 1.09c0 .11 0 .23.06.34l-.31 2.58a4.9 4.9 0 0 1-9.37-1.11l.31-2.58c.05-.08.09-.21.15-.32z" class="cls-42"/>
<path d="M313.39 379.51l9.13.34c0 .11 0 .23.06.34l-.31 2.58a4.9 4.9 0 0 1-4 3z" class="cls-45"/>
<path d="M321.38 350.33c-3-.29-12.16-1.08-13.58 9.51s.39 14.75 1.64 16.51a11 11 0 0 0 5.41 4.48 4.43 4.43 0 0 0 2.72 1.57z" class="cls-42"/>
<path d="M321.38 350.33c3 .42 12.08 1.79 11 12.42s-3.83 14.25-5.45 15.66a11 11 0 0 1-6.31 3.09 4.43 4.43 0 0 1-3 .89z" class="cls-46"/>
<path d="M328.92 356.45c-.42-1-1-1.93-4-1.13s-8 3-13.34 1.88c-2.24 3.14-2.41 9.39-2.41 9.39l-2.83-2.36s.34-7.09 1.74-9.22c-.89-.26-1.54-1.26-1.2-3.44 1.12.29 1.69 1.27 3.35.24s5.47-4.72 11.5-3.85 8.3 4.44 9 5.82c3.78 4.08 2.91 8.4 1.92 13.55l-3.32 1.67c.45-2.15.73-9.93-.41-12.55z" class="cls-59"/>
<path d="M308.81 365.43a1.89 1.89 0 0 0-1.32-1.56c-.85-.29-2.5 0-2.79 2.45s1.53 4.86 2.87 4.72 1.28-4.93 1.24-5.61z" class="cls-42"/>
<path d="M308.13 366.19a1.47 1.47 0 0 0-.92-1c-.52-.18-1.32 0-1.5 1.51s.73 2.94 1.55 2.86a1 1 0 0 0 .57-.84 1.14 1.14 0 0 1-.53-1.4 1.25 1.25 0 0 1 .82-.88c.01-.15.01-.25.01-.25zM329.86 368a2.41 2.41 0 0 1 1.85-1.2c.9-.08 2.44.56 2.14 3s-2.62 4.37-3.9 3.92-.09-5.72-.09-5.72z" class="cls-46"/>
<path d="M330.55 368.85a1.47 1.47 0 0 1 1.12-.73c.55 0 1.28.31 1.1 1.82s-1.4 2.69-2.17 2.42a1 1 0 0 1-.36-1 1.14 1.14 0 0 0 .84-1.24 1.25 1.25 0 0 0-.59-1z" class="cls-45"/>
<rect width="1.32" height="4.18" x="313.49" y="360.4" class="cls-60" rx=".66" ry=".66" transform="rotate(-83.23 314.154 362.488)"/>
<rect width="1.32" height="4.18" x="324.9" y="361.75" class="cls-60" rx=".66" ry=".66" transform="rotate(-83.23 325.56 363.84)"/>
<path d="M321.2 373.17a2.52 2.52 0 1 1-5-.59z" class="cls-12"/>
<circle cx="325.58" cy="367.79" r="1.11" class="cls-8" transform="rotate(-83.23 325.58 367.785)"/>
<path d="M327.09 367.83a.34.34 0 0 0 .48-.48c-.68-.67-1.81-1.81-3.92-.39a.34.34 0 1 0 .38.57 2.08 2.08 0 0 1 3.06.31z" class="cls-14"/>
<circle cx="313.21" cy="366.32" r="1.11" class="cls-8" transform="rotate(-83.23 313.208 366.32)"/>
<path d="M311.72 366a.34.34 0 0 1-.36-.58c.81-.5 2.19-1.33 3.91.54a.34.34 0 1 1-.5.46 2.08 2.08 0 0 0-3-.42z" class="cls-14"/>
<path d="M303.59 398.22c.8-2.59 1.61-5.25 2.42-7.88l-5.43-.64c-.69 2.79-1.39 5.4-2.12 7.91z" class="cls-46"/>
<path d="M299.62 391.42l5.56 1.73 3.56-11.41c-4.19-1.24-6.64 2.58-7.29 4.44z" class="cls-58"/>
<path d="M299.582 391.507l.487-1.386 5.64 1.984-.487 1.387z" class="cls-1"/>
<path d="M327.5 401.06c-.17-2.71-.34-5.49-.51-8.23l5.43.64c0 2.87.08 5.57.21 8.19z" class="cls-46"/>
<path d="M332.95 395.38l-5.81.38-.79-11.93c4.36-.23 5.86 4.06 6.05 6z" class="cls-58"/>
<path d="M326.872 394.63l5.95-.607.148 1.463-5.95.607z" class="cls-1"/>
<path d="M240.28 390.81l122.07 14.49-13.55 56.4c-1.35 5.67-5.85 14.57-13.9 13.62l-84.33-10c-8-1-10.34-10.66-10.33-16.49z" class="cls-15"/>
<path d="M349.57 458.48l-.77 3.22c-1.35 5.67-5.85 14.57-13.9 13.62l-84.33-10c-8-1-10.34-10.66-10.33-16.49v-3.31c.9 5.48 3.76 11.74 10.27 12.48l86.16 10.23c6.52.77 10.75-4.63 12.9-9.75zM240.28 390.81l122.07 14.49-3.54 14.7-118.54-14.07.01-15.12z" class="cls-16"/>
<path d="M241.83 387.66l119.75 14.21a6.63 6.63 0 0 1 5.78 7.34A6.63 6.63 0 0 1 360 415l-119.73-14.22a6.63 6.63 0 0 1-5.78-7.34 6.63 6.63 0 0 1 7.34-5.78z" class="cls-20"/>
<path d="M241.83 387.66l119.75 14.21a6.63 6.63 0 0 1 5.78 7.34 6.56 6.56 0 0 1-.27 1.25 6.88 6.88 0 0 1-4.94 1.32L239 397.15a6.88 6.88 0 0 1-4.49-2.44 6.59 6.59 0 0 1 0-1.28 6.63 6.63 0 0 1 7.32-5.77z" class="cls-21"/>
<rect width="4.95" height="2.12" x="303.99" y="394.68" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 306.474 395.745)"/>
<rect width="4.95" height="2.12" x="306.1" y="394.93" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 308.58 395.994)"/>
<path d="M311 393.79a1.06 1.06 0 0 1 .93 1.18l-.33 2.81a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.18-.93z" class="cls-42"/>
<path d="M313.08 394a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18zM321.85 395.08a1.06 1.06 0 0 1 .93 1.18l-.33 2.81a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.18-.93zM324 395.33a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-42"/>
<rect width="4.95" height="2.12" x="323.29" y="396.97" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 325.77 398.033)"/>
<path d="M319.74 394.83a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-42"/>
<path d="M272.38 389.21a1.06 1.06 0 0 1 .93 1.18l-.31 2.8a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.16-.92z" class="cls-23"/>
<rect width="4.95" height="2.12" x="271.72" y="390.85" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 274.19 391.91)"/>
<rect width="4.95" height="2.12" x="273.82" y="391.1" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 276.302 392.165)"/>
<rect width="4.95" height="2.12" x="275.93" y="391.35" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 278.407 392.413)"/>
<rect width="4.95" height="2.12" x="281.54" y="392.02" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 284.02 393.08)"/>
<path d="M286.41 390.87a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-23"/>
<rect width="4.95" height="2.12" x="285.75" y="392.52" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 288.23 393.576)"/>
<path d="M290.62 391.37a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-23"/>
<path d="M266.86 409.48s14.79 12.28 30.84 3.68l.83 1.55c-17.09 9.16-32.74-3.83-32.78-3.87zM301.23 413.56s14.79 12.28 30.84 3.68l.83 1.55c-17.09 9.16-32.74-3.83-32.79-3.87zM335.76 417.65s16.1 12.57 23.06 2.3l1.46 1c-8 11.84-25.55-1.85-25.6-1.89z" class="cls-21"/>
<path d="M264.27 412.88c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-16"/>
<path d="M263.6 410.06c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M263.6 410.06c-4.28 4.22-9.4 9.69-10.45 15a9.76 9.76 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
<path d="M298.68 417c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17zM333.34 421.07c-4.28 4.22-9.41 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-16"/>
<path d="M332.35 418.21c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M332.35 418.21c-4.28 4.22-9.4 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
<path d="M298 414.13c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M298 414.13c-4.28 4.22-9.4 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1zM263.57 409.08c-.05 0-18.86 8.36-23.22-3.25l-1.39.7c5 13.39 25.27 4.18 25.33 4.15z" class="cls-21"/>
<circle cx="299.56" cy="412.87" r="2.18" class="cls-63" transform="rotate(-83.23 299.563 412.878)"/>
<circle cx="265.19" cy="408.79" r="2.18" class="cls-63" transform="rotate(-83.23 265.19 408.8)"/>
<circle cx="333.93" cy="416.95" r="2.18" class="cls-63" transform="rotate(-83.23 333.935 416.956)"/>
<path d="M299.81 410.71a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91zM265.44 406.63a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91zM334.18 414.79a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91z" class="cls-64"/>
<path d="M359.32 417.89a2.18 2.18 0 0 1-1 4.15zM240.27 408a2.18 2.18 0 0 1 0-4.27V408z" class="cls-20"/>
<path d="M369.24 397.41l2.07 1.24c.59.35.93-.12 1.83-.68.69-.43 1.27-.29 2.63-.31a4.64 4.64 0 0 0 1.76-.67.89.89 0 0 0 .69-.8.78.78 0 0 0 .54-.87 1 1 0 0 1-.32-1.2.78.78 0 0 0-1-.45l-.14.06a.73.73 0 0 0-.8-.32 10.73 10.73 0 0 1-2.6.51.77.77 0 0 0-.13-.71.77.77 0 0 0-1.09-.11l-1.07.94a2.06 2.06 0 0 0-1.33.5 4.51 4.51 0 0 0-1.04 2.87z" class="cls-36"/>
<path d="M364 395.85c.1 1.88.85 6.82.86 6.82s1.51-2.23 4.88-6.84l2.36 2.78c-5.41 9-7.48 9.71-9.78 8-.88-.94-1.29-3.33-1.77-5.64a25.48 25.48 0 0 0-.91-3.54z" class="cls-36"/>
<path d="M364.57 397.44l-5.52.37-.75-11.32c4.14-.21 5.56 3.85 5.74 5.72z" class="cls-48"/>
<path d="M359.32 397.504l5.62-.49.165 1.903-5.62.49z" class="cls-65"/>
<circle cx="375.66" cy="391.02" r="4.54" class="cls-66" transform="rotate(-83.23 375.666 391.018)"/>
<path d="M373.7 386a2.64 2.64 0 1 0-2.32-.68 2.63 2.63 0 0 0 2.32.68z" class="cls-67"/>
<path d="M374.06 387.82a.23.23 0 1 1-.46.09 7.62 7.62 0 0 1 .67-3.77.23.23 0 1 1 .44.16 7.18 7.18 0 0 0-.65 3.52z" class="cls-68"/>
<circle cx="332.39" cy="150.75" r="145.02" class="cls-2" transform="rotate(-83.23 332.39 150.748)"/>
<path d="M405.16 72.93A203.65 203.65 0 0 0 377.3 62.8c.41 4.76.62 9.81.83 14.87 12.07-1.81 18.57-.56 27-4.75" class="cls-69"/>
<path d="M425.64 127.47a30.49 30.49 0 0 1 3.59 1.24c-.18-2.62-.17-5.38-.53-7.86a11.19 11.19 0 0 1-1 2 24 24 0 0 0-2 4.63" class="cls-70"/>
<path d="M310 254.66a25.25 25.25 0 0 1-.23-4.9 1.45 1.45 0 0 0-.07-.82 143.69 143.69 0 0 1-14.1-3c3.31 28.15 10.57 46.38 20.65 47.58a8 8 0 0 0 3.94-.34c-1.13-4.19-2.64-9.24-3.82-13-3.8-13.11-6.09-21.18-6.38-25.6" class="cls-71"/>
<path d="M268.12 55.36a200.3 200.3 0 0 1 45.05-6.18c7.06-19.78 15.52-35 24.35-42.4l-.52-.06c-26.12 2.58-50.68 20.77-68.92 48.64" class="cls-72"/>
<path d="M371 55.56C367.72 27.25 360.45 9.18 350.21 8S328.66 22.61 319 49.39a219.09 219.09 0 0 1 52 6.18" class="cls-73"/>
<path d="M339.25 92.05c7.6-6.57 10.54-5.4 20.9-9.21a129.19 129.19 0 0 1 12.65-4c-.24-6.2-.67-12.09-1.15-17.67a224 224 0 0 0-54.59-6.48c-2.22 6.39-4.16 13.14-6.13 20.21 10.73 2.57 20.94 6.87 28.32 17.16" class="cls-74"/>
<path d="M293.63 209.69c.17 10.9.55 21.33 1.23 30.67 4.73 1.21 9.49 2.1 14.24 3.15a31.34 31.34 0 0 0-2.33-7.74c-1.7-3.45-7.57-15.51-13.14-26.07" class="cls-75"/>
<path d="M363.1 10.14C369 18 373.47 32.15 376 52.25a24.12 24.12 0 0 1 .41 4.76 199.77 199.77 0 0 1 33.89 12.3 34.31 34.31 0 0 0 4.65-5c-11.25-27-29.36-46.67-51.85-54.21" class="cls-76"/>
<path d="M434.69 130.49c19.78 7.06 35 15.52 42.4 24.35l.06-.48c-2-21.5-14.52-41.82-34.6-58.65-2.79 4.38-5.66 9.39-9 15.65.54 6.4 1.08 12.79 1.14 19.13" class="cls-70"/>
<path d="M475.89 167.69c1.2-10.09-14.65-21.54-41.43-31.22a219.09 219.09 0 0 1-6.18 52c28.31-3.3 46.38-10.57 47.6-20.81" class="cls-77"/>
<path d="M394.74 175.75l-9.68 9.08c-.83 1.52-1.86 3.35-2.91 5.34a368.06 368.06 0 0 0 40.74-1.33 224 224 0 0 0 6.48-54.59c-1.7-.69-3.75-1.26-5.77-2-.06.48-.26.78-.31 1.26-4.09 11.2-8.67 23.81-28.55 42.23" class="cls-78"/>
<path d="M427.13 194.2a202.69 202.69 0 0 1-16.55 42.35c31.54-11.35 55-31 63.26-55.64v-.16c-7.86 6-22 10.53-42.15 13.18a26.83 26.83 0 0 0-4.58.27" class="cls-79"/>
<path d="M373 275.12A123.43 123.43 0 0 0 398.26 246c-5.87 1.58-11.84 2.65-18.15 3.85a54.87 54.87 0 0 1-3.77 14c-1.06 3.45-2.28 6.87-3.29 11.3" class="cls-80"/>
<path d="M214.35 81.61C227.3 71 243.51 63 260.2 57.83c12.37-20.77 29.47-39 50.13-48.92A145 145 0 0 0 206 83.38a69.68 69.68 0 0 1 8.33-1.77" class="cls-81"/>
<path d="M363.77 224.67l-.22.46c8.12 4 16.35 11.36 16.87 17.91-.06.48.07.82 0 1.3a168 168 0 0 0 22.2-5.16A191.78 191.78 0 0 0 421 194.94a410.77 410.77 0 0 1-42.18 1.16l-2.94 5.66c-5.18 9.45-10.62 19.68-12.14 22.91" class="cls-82"/>
<path d="M445.38 91c12.56 10.42 23.28 22.73 29.74 36.65a142.73 142.73 0 0 0-19.3-52.13c-1.21 2-2.75 4.06-4.3 6.15-1.64 2.89-3.8 6-6.14 9.34" class="cls-83"/>
<path d="M388.94 18.08c12.56 10.42 22.59 24.43 29.94 40.41A77.24 77.24 0 0 0 425 40a138.12 138.12 0 0 0-36-22" class="cls-84"/>
<path d="M240.31 224c-20.77-12.37-39-29.47-48.92-50.13a144.52 144.52 0 0 0 86.34 109.6c-17.79-14.29-30.1-36.37-37.42-59.48" class="cls-85"/>
<path d="M406.48 243.7c-9.28 15.29-21.21 29.63-34.93 39.85-.41 2.06-.64 4-1.22 6.19a144.16 144.16 0 0 0 95.76-83.28c-14.61 17.75-36.37 30.1-59.62 37.24" class="cls-86"/>
<path d="M255.76 65.09a127 127 0 0 0-29.68 14.82A212.18 212.18 0 0 0 251 75.08c1.54-3.39 3.24-6.76 4.76-10" class="cls-87"/>
<path d="M273.64 69c17.82 3.09 31.71 5.06 31.71 5.06 1.76-6.61 3.68-13.2 5.84-19.11a189.68 189.68 0 0 0-47.32 7.53c-1.86 3.35-3.72 6.7-5.6 10.21 8.48-3.05 7-4.36 15.37-3.69" class="cls-88"/>
<path d="M269.73 169c-6.85-5.2-12.47-11.22-17.8-16.89a132.84 132.84 0 0 0-13.08-12.92 263.93 263.93 0 0 0-1.67 27.72 379.55 379.55 0 0 0 40.74 11 15.6 15.6 0 0 0-2.62-3.88 34.76 34.76 0 0 0-5.57-5" class="cls-89"/>
<path d="M237.84 216.06a200.3 200.3 0 0 1-6.17-45.06c-19.16-6.82-33.8-14.73-41.54-23.44l-.88.55c3.28 25.71 21.19 49.92 48.59 67.94" class="cls-90"/>
<path d="M281.36 184.53A426.21 426.21 0 0 1 237.44 173a189.68 189.68 0 0 0 7.56 47.31 191.78 191.78 0 0 0 44.25 18.4 327.53 327.53 0 0 1-.95-39.89c-2-3.65-3.85-8.57-6.91-14.29" class="cls-3"/>
<path d="M211.14 134.64l-.18.14-15.78 9.82c6.32 6.92 19.28 14 36.7 20.43a201 201 0 0 1 2-29.47 28.08 28.08 0 0 0-10.9-3.89 15.51 15.51 0 0 0-11.88 3" class="cls-91"/>
<path d="M289.81 244.63a202.69 202.69 0 0 1-42.35-16.55c11.35 31.54 31 55 55.64 63.26h.16c-6-7.86-10.53-22-13.18-42.15a16.72 16.72 0 0 0-.27-4.58" class="cls-92"/>
<path d="M223.71 125.9c13.45 1.6 24 12.75 32.75 22.23 5.35 5.51 10.65 11.49 17 16.47a28.42 28.42 0 0 1 6.4 6.28 26.83 26.83 0 0 1 4.38 6.85c1.3 2.75 2.37 4.66 3.17 4.76 1.6.19 5.33-1.15 6.14-3.82 1.93-5.29-2.75-11-6.45-15.38-5-6.11-12.6-16.92-14.47-21.69-15-38.14 30.95-62.4 53.95-38.73 2.85-2.1 5.79-5 8-6.85-11.84-16.5-31.79-15.3-51.47-19.1-2.86-.5-10.79-2.09-13.11-1.72a28.57 28.57 0 0 0-6.6 2.3c-19.45 7.92-44.85 7.18-61 13.7a144.06 144.06 0 0 0-11.87 42.59c-.29 2.4-.57 4.8-.71 7.38L208 130a21 21 0 0 1 15.75-4.14" class="cls-93"/>
<path d="M374.24 84.51c-8 2-14.58 5.25-19.24 6.16-8.85 2-10.25 4.3-17.31 10.45a93 93 0 0 1-7.78 6.71 3.59 3.59 0 0 0 .55 2.18c1.86 3.47 5.2 2.73 8.4.35 33.68-25.22 54.89 2.62 56.14 18 .62 7.05 0 17.86-10.39 34.16 3.83 1.92 6.43 4.66 6.39 9 25.39-23.77 23.77-36.14 31.61-51.61C437.2 90.2 446 80.69 452.87 70.31a142.92 142.92 0 0 0-22.42-25.88C419.61 88 393.11 80.1 374.24 84.51" class="cls-94"/>
<path d="M367.08 274.74c2.36-13 8.36-18.49 7.46-31.42-.26-3.28-7.33-10.77-16-14.08l-2.82-.82 2.86-6.32c1.79-4.17 9.68-18.66 16.52-31.16.81-1.36 9.38-16.09 9.38-18.85-.2-2.46-2.73-4.38-4.65-4.61s-4.26 3.07-6.76 6.34c-3.43 4.3-5.73 8.57-8.66 12.77-3.13 4.5-6.62 9.28-10.31 14.36q-5.56 7.86-12.85 18a9.13 9.13 0 0 1-9.11 4.28c-3.2-.38-6.77-1.78-7.64-2.69s-21.65-31-26.91-37.29a12.8 12.8 0 0 1-8.51 4.67c7 13.81 20.46 39.6 23.07 44.95 3.91 8.09 3.18 12.88 3.81 21.23.26 6 7.51 27 10.81 40.24A143.35 143.35 0 0 0 364 291.1c1-5.56 2.13-11.11 3.08-16.36" class="cls-95"/>
<path d="M163 44.29a25.89 25.89 0 0 0-20.48 10 20.92 20.92 0 0 0-20.27 17.21 12.57 12.57 0 0 0-1.89-.14 12.38 12.38 0 1 0 0 24.77c30.16 0 66.81-1.64 100-1.64a9.55 9.55 0 1 0 0-19.11 9.68 9.68 0 0 0-1.92.19 19.38 19.38 0 0 0-31.2-14.72A25.94 25.94 0 0 0 163 44.29z" class="cls-4"/>
<path d="M167.72 295.05c2.43.86 4.29 2.11 7 1.43s5.42-1.17 6.39-.5 8.28 8 12.53 20.36-1.93 2.83-4 1.35-7.11-10.44-9.28-11.65-8.44.11-14.24-2c-1.7-.63-4.15.45-5.16 3.15s-1.93 6.53-1.93 6.53-5.38 1-10.92-5.66c1.81-1.85 6.19-4.06 7.18-5.23.41-1.12-.83-2.46-2-3.37a64.77 64.77 0 0 1-7.81-6.77c-2.3-2.63-4.87-1.72-7.8-1.58s-8.72 1.25-11.19 1.08-6.23.22-.57-2.3 19.5-7.48 24.5-6.46c3.25.67 2.67 1.71 4.19 3.69s4.68 6.38 6.9 4.7a17.2 17.2 0 0 1 4.86-3c1.49-.52 2.92-.15 2.4 1.56s-1.05 4.67-1.05 4.67z" class="cls-96"/>
<path d="M171.42 288.28a4 4 0 0 0-3.2.9c-.66.75.57.91.82.78a26 26 0 0 1 2.38-1.68z" class="cls-97"/>
<ellipse cx="166.11" cy="289.09" class="cls-98" rx=".48" ry=".38" transform="rotate(-34.42 166.098 289.075)"/>
<path d="M118.86 326c1.88.3 3.4 1 5.29.11s3.78-1.57 4.57-1.21 7.07 4.68 11.8 13.12-1 2.31-2.69 1.5-6.55-6.63-8.28-7.23-6.11 1.2-10.61.42c-1.31-.23-3 .87-3.33 3s-.54 5-.54 5-3.78 1.42-8.68-2.66c1.07-1.58 4-3.77 4.52-4.75.15-.87-.93-1.67-1.87-2.19a47.81 47.81 0 0 1-6.57-3.88c-2-1.61-3.77-.61-5.87-.11s-6.17 2.06-8 2.27-4.5 1-.72-1.6 13.17-8 16.93-7.94c2.45.05 2.17.89 3.54 2.12s4.25 4 5.64 2.5a12.69 12.69 0 0 1 3.13-2.81c1-.57 2.1-.49 1.95.81s-.21 3.53-.21 3.53z" class="cls-96"/>
<path d="M120.65 320.64a2.92 2.92 0 0 0-2.21 1.08c-.38.63.53.59.7.46a19.22 19.22 0 0 1 1.51-1.54z" class="cls-97"/>
<ellipse cx="116.9" cy="321.94" class="cls-98" rx=".35" ry=".28" transform="rotate(-44.77 116.913 321.942)"/>
<path d="M117.91 237.8c1.29-.47 2.69-1.4 3.91-4.27s2-2.28 4.38-3.45a25.09 25.09 0 0 1 8.88-1.92c3-.18-2.8 3.39-4 4s-3.8 1.4-4.09 2.34a18.45 18.45 0 0 1-2.63 4.5c-.76.76-3.16 2.63-2.34 3.68s3.91 2.1 4.27 2.22-1 3.8-4.5 4.33a23.51 23.51 0 0 1-1.76-4c-.29-.65-1.87-1-3.39.47s-4.32 2.1-5.49 2.4-3 3.27-4.55 4.38-3.45 3-4.33 3.27.53-4 1.11-5a63.46 63.46 0 0 1 5.37-7c.88-.7 2.87-.64 4.62-1.75s1.75-1.23 1.69-1.81-1.87-2.16-2.1-2.75.24-.93 1-.87a11.35 11.35 0 0 1 3.95 1.23z" class="cls-96"/>
<path d="M111.55 236.06a15.21 15.21 0 0 1 1.52 1.25c.22.25.6-.38.55-.58a2.87 2.87 0 0 0-2.07-.67z" class="cls-97"/>
<ellipse cx="114.62" cy="236.83" class="cls-98" rx=".27" ry=".21"/>
</svg>

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: maintenance
namespace: human-connection
labels:
human-connection.org/selector: deployment-human-connection-maintenance
spec:
ports:
- name: web
port: 80
targetPort: 80
selector:
human-connection.org/selector: deployment-human-connection-maintenance

View File

@ -1 +1,2 @@
.ssh/
ssh/

View File

@ -111,6 +111,13 @@ u.createdAt = user.createdAt.`$date`,
u.updatedAt = user.updatedAt.`$date`,
u.deleted = user.deletedAt IS NOT NULL,
u.disabled = false
MERGE (e:EmailAddress {
email: user.email,
createdAt: toString(datetime()),
verifiedAt: toString(datetime())
})
MERGE (e)-[:BELONGS_TO]->(u)
MERGE (u)<-[:PRIMARY_EMAIL]-(e)
WITH u, user, user.badgeIds AS badgeIds
UNWIND badgeIds AS badgeId
MATCH (b:Badge {id: badgeId})

View File

@ -9,4 +9,4 @@
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storage: 5Gi

View File

@ -23,11 +23,7 @@ So, all we have to do is edit the kubernetes deployment of our Neo4J database
and set a custom `command` every time we have to carry out tasks like backup,
restore, seed etc.
{% hint style="info" %}
TODO: implement maintenance mode
{% endhint %}
First bring the application into maintenance mode to ensure there are no
First bring the application into [maintenance mode](https://github.com/Human-Connection/Human-Connection/blob/master/deployment/human-connection/maintenance/README.md) to ensure there are no
database connections left and nobody can access the application.
Run the following:

View File

@ -42,6 +42,14 @@ services:
context: neo4j
networks:
- hc-network
maintenance:
image: humanconnection/maintenance:latest
build:
context: deployment/human-connection/maintenance
networks:
- hc-network
ports:
- 80:80
networks:
hc-network:

View File

@ -1,11 +1,11 @@
FROM neo4j:3.5.5
FROM neo4j:3.5.8
LABEL Description="Neo4J database of the Social Network Human-Connection.org with preinstalled database constraints and indices" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)"
ARG BUILD_COMMIT
ENV BUILD_COMMIT=$BUILD_COMMIT
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.1/apoc-3.5.0.1-all.jar -P plugins/
RUN apk add --no-cache --quiet procps
COPY db_setup.sh /usr/local/bin/db_setup
COPY entrypoint.sh /docker-entrypoint-wrapper.sh
RUN apt-get update && apt-get -y install procps wget
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.4/apoc-3.5.0.4-all.jar -P plugins/
ENTRYPOINT ["/docker-entrypoint-wrapper.sh"]

View File

@ -34,6 +34,8 @@ CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE;
CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE;
CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE;
CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE;
CREATE CONSTRAINT ON (e:EmailAddress) ASSERT e.email IS UNIQUE;
' | cypher-shell
echo '

View File

@ -22,15 +22,15 @@
"bcryptjs": "^2.4.3",
"codecov": "^3.5.0",
"cross-env": "^5.2.0",
"cypress": "^3.4.0",
"cypress-cucumber-preprocessor": "^1.12.0",
"cypress-file-upload": "^3.3.1",
"cypress": "^3.4.1",
"cypress-cucumber-preprocessor": "^1.13.0",
"cypress-file-upload": "^3.3.3",
"cypress-plugin-retries": "^1.2.2",
"dotenv": "^8.0.0",
"faker": "Marak/faker.js#master",
"graphql-request": "^1.8.2",
"neo4j-driver": "^1.7.5",
"neode": "^0.2.16",
"neode": "^0.3.0",
"npm-run-all": "^4.1.5",
"slug": "^1.1.0"
}

View File

@ -4,7 +4,9 @@ docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t huma
docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t humanconnection/nitro-web:latest $TRAVIS_BUILD_DIR/webapp
docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT -t humanconnection/neo4j:latest $TRAVIS_BUILD_DIR/neo4j
docker build -t humanconnection/maintenance-worker:latest $TRAVIS_BUILD_DIR/deployment/legacy-migration/maintenance-worker
docker build -t humanconnection/maintenance:latest $TRAVIS_BUILD_DIR/deployment/human-connection/maintenance
docker push humanconnection/nitro-backend:latest
docker push humanconnection/nitro-web:latest
docker push humanconnection/neo4j:latest
docker push humanconnection/maintenance-worker:latest
docker push humanconnection/maintenance-worker:latest
docker push humanconnection/maintenance:latest

View File

@ -1,4 +1,4 @@
FROM node:12.6-alpine as base
FROM node:12.7-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

@ -23,49 +23,61 @@
:modalsData="menuModalsData"
style="float-right"
:is-owner="isAuthor(author.id)"
@showEditCommentMenu="editCommentMenu"
/>
</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;"
>
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
{{ $t('comment.show.more') }}
</a>
<ds-space margin-bottom="small" />
<div v-if="openEditCommentMenu">
<hc-edit-comment-form
:comment="comment"
:post="post"
@showEditCommentMenu="editCommentMenu"
/>
</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 v-show="!openEditCommentMenu">
<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;"
>
<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>
</div>
<ds-space margin-bottom="small" />
</ds-card>
</div>
</template>
<!-- eslint-enable vue/no-v-html -->
<script>
import gql from 'graphql-tag'
import { mapGetters } from 'vuex'
import { mapGetters, mapMutations } from 'vuex'
import HcUser from '~/components/User'
import ContentMenu from '~/components/ContentMenu'
import HcEditCommentForm from '~/components/comments/EditCommentForm/EditCommentForm'
export default {
data: function() {
return {
isCollapsed: true,
openEditCommentMenu: false,
}
},
components: {
HcUser,
ContentMenu,
HcEditCommentForm,
},
props: {
post: { type: Object, default: () => {} },
comment: {
type: Object,
default() {
@ -112,9 +124,16 @@ export default {
},
},
methods: {
...mapMutations({
setEditPending: 'editor/SET_EDIT_PENDING',
}),
isAuthor(id) {
return this.user.id === id
},
editCommentMenu(showMenu) {
this.openEditCommentMenu = showMenu
this.setEditPending(showMenu)
},
async deleteCommentCallback() {
try {
var gqlMutation = gql`

View File

@ -76,14 +76,13 @@ export default {
}
if (this.isOwner && this.resourceType === 'comment') {
// routes.push({
// name: this.$t(`comment.menu.edit`),
// callback: () => {
// /* eslint-disable-next-line no-console */
// console.log('EDIT COMMENT')
// },
// icon: 'edit'
// })
routes.push({
name: this.$t(`comment.menu.edit`),
callback: () => {
this.$emit('showEditCommentMenu', true)
},
icon: 'edit',
})
routes.push({
name: this.$t(`comment.menu.delete`),
callback: () => {

View File

@ -6,6 +6,9 @@ import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import Filters from '~/plugins/vue-filters'
import TeaserImage from '~/components/TeaserImage/TeaserImage'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
const localVue = createLocalVue()
@ -14,6 +17,8 @@ localVue.use(Styleguide)
localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('ContributionForm.vue', () => {
let wrapper
@ -24,7 +29,17 @@ describe('ContributionForm.vue', () => {
let mocks
let propsData
const postTitle = 'this is a title for a post'
const postTitleTooShort = 'xx'
let postTitleTooLong = ''
for (let i = 0; i < 65; i++) {
postTitleTooLong += 'x'
}
const postContent = 'this is a post'
const postContentTooShort = 'xx'
let postContentTooLong = ''
for (let i = 0; i < 2001; i++) {
postContentTooLong += 'x'
}
const imageUpload = {
file: {
filename: 'avataar.svg',
@ -37,22 +52,17 @@ describe('ContributionForm.vue', () => {
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest
.fn()
.mockResolvedValueOnce({
data: {
CreatePost: {
title: postTitle,
slug: 'this-is-a-title-for-a-post',
content: postContent,
contentExcerpt: postContent,
language: 'en',
},
mutate: jest.fn().mockResolvedValueOnce({
data: {
CreatePost: {
title: postTitle,
slug: 'this-is-a-title-for-a-post',
content: postContent,
contentExcerpt: postContent,
language: 'en',
},
})
.mockRejectedValue({
message: 'Not Authorised!',
}),
},
}),
},
$toast: {
error: jest.fn(),
@ -74,6 +84,13 @@ describe('ContributionForm.vue', () => {
'editor/placeholder': () => {
return 'some cool placeholder'
},
'auth/user': () => {
return {
id: '4711',
name: 'You yourself',
slug: 'you-yourself',
}
},
}
const store = new Vuex.Store({
getters,
@ -109,16 +126,53 @@ describe('ContributionForm.vue', () => {
})
describe('invalid form submission', () => {
it('title required for form submission', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.find('form').trigger('submit')
it('title and content should not be empty ', async () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content required for form submission', async () => {
wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit')
it('title should not be empty', async () => {
await wrapper.vm.updateEditorContent(postContent)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('title should not be too long', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitleTooLong)
await wrapper.vm.updateEditorContent(postContent)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('title should not be too short', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitleTooShort)
await wrapper.vm.updateEditorContent(postContent)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be empty', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be too short', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContentTooShort)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be too long', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContentTooLong)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
})
@ -139,15 +193,16 @@ describe('ContributionForm.vue', () => {
}
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit')
await wrapper.vm.updateEditorContent(postContent)
})
it('with title and content', () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
it("sends a fallback language based on a user's locale", () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
@ -155,7 +210,7 @@ describe('ContributionForm.vue', () => {
expectedParams.variables.language = 'de'
deutschOption = wrapper.findAll('li').at(0)
deutschOption.trigger('click')
await wrapper.find('form').trigger('submit')
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
@ -163,22 +218,26 @@ describe('ContributionForm.vue', () => {
const categoryIds = ['cat12', 'cat15', 'cat37']
expectedParams.variables.categoryIds = categoryIds
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
await wrapper.find('form').trigger('submit')
await wrapper.find('.submit-button-for-test').trigger('click')
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')
await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it("pushes the user to the post's page", async () => {
wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate
expect(mocks.$router.push).toHaveBeenCalledTimes(1)
})
it('shows a success toaster', () => {
it('shows a success toaster', async () => {
wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
})
@ -194,18 +253,19 @@ describe('ContributionForm.vue', () => {
describe('handles errors', () => {
beforeEach(async () => {
jest.useFakeTimers()
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({
message: 'Not Authorised!',
})
wrapper = Wrapper()
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent)
// second submission causes mutation to reject
await wrapper.find('form').trigger('submit')
await wrapper.vm.updateEditorContent(postContent)
})
it('shows an error toaster when apollo mutation rejects', async () => {
await wrapper.find('form').trigger('submit')
await wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate
expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
})
})
})
@ -263,7 +323,7 @@ describe('ContributionForm.vue', () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit')
await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
@ -274,7 +334,7 @@ describe('ContributionForm.vue', () => {
wrapper.vm.updateEditorContent(postContent)
expectedParams.variables.categoryIds = categoryIds
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
await wrapper.find('form').trigger('submit')
await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
})

View File

@ -1,5 +1,5 @@
<template>
<ds-form ref="contributionForm" v-model="form" :schema="formSchema" @submit="submit">
<ds-form ref="contributionForm" v-model="form" :schema="formSchema">
<template slot-scope="{ errors }">
<ds-card>
<hc-teaser-image :contribution="contribution" @addTeaserImage="addTeaserImage">
@ -9,7 +9,11 @@
:src="contribution.image | proxyApiUrl"
/>
</hc-teaser-image>
<ds-space />
<hc-user :user="currentUser" :trunc="35" />
<ds-space />
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
<small class="smallTag">{{ form.title.length }}/{{ formSchema.title.max }}</small>
<no-ssr>
<hc-editor
:users="users"
@ -17,6 +21,7 @@
:value="form.content"
@input="updateEditorContent"
/>
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
</no-ssr>
<ds-space margin-bottom="xxx-large" />
<hc-categories-select
@ -41,18 +46,20 @@
<div slot="footer" style="text-align: right">
<ds-button
class="cancel-button"
:disabled="loading || disabled"
:disabled="loading"
ghost
@click.prevent="$router.back()"
>
{{ $t('actions.cancel') }}
</ds-button>
<ds-button
class="submit-button-for-test"
type="submit"
icon="check"
:loading="loading"
:disabled="disabled || errors"
:disabled="disabledByContent || errors"
primary
@click.prevent="submit"
>
{{ $t('actions.save') }}
</ds-button>
@ -65,18 +72,21 @@
<script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor'
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 HcCategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import HcTeaserImage from '~/components/TeaserImage/TeaserImage'
import HcUser from '~/components/User'
export default {
components: {
HcEditor,
HcCategoriesSelect,
HcTeaserImage,
HcUser,
},
props: {
contribution: { type: Object, default: () => {} },
@ -86,6 +96,7 @@ export default {
form: {
title: '',
content: '',
contentLength: 0,
teaserImage: null,
image: null,
language: null,
@ -94,13 +105,16 @@ export default {
},
formSchema: {
title: { required: true, min: 3, max: 64 },
content: { required: true, min: 3 },
content: [{ required: true }],
},
id: null,
loading: false,
disabled: false,
disabledByContent: true,
slug: null,
users: [],
contentMin: 3,
contentMax: 2000,
hashtags: [],
}
},
@ -113,8 +127,9 @@ export default {
}
this.id = contribution.id
this.slug = contribution.slug
this.form.content = contribution.content
this.form.title = contribution.title
this.form.content = contribution.content
this.manageContent(this.form.content)
this.form.image = contribution.image
this.form.categoryIds = this.categoryIds(contribution.categories)
},
@ -128,6 +143,9 @@ export default {
: locales.find(loc => this.$i18n.locale() === loc.code)
return locale.name
},
...mapGetters({
currentUser: 'auth/user',
}),
},
mounted() {
this.availableLocales()
@ -160,7 +178,7 @@ export default {
.then(res => {
this.loading = false
this.$toast.success(this.$t('contribution.success'))
this.disabled = true
this.disabledByContent = true
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({
@ -171,12 +189,21 @@ export default {
.catch(err => {
this.$toast.error(err.message)
this.loading = false
this.disabled = false
this.disabledByContent = false
})
},
updateEditorContent(value) {
// this.form.content = value
// TODO: Do smth????? what is happening
this.$refs.contributionForm.update('content', value)
this.manageContent(value)
},
manageContent(content) {
// filter HTML out of content value
const str = content.replace(/<\/?[^>]+(>|$)/gm, '')
// Set counter length of text
this.form.contentLength = str.length
// Enable save button if requirements are met
this.disabledByContent = !(this.contentMin <= str.length && str.length <= this.contentMax)
},
availableLocales() {
orderBy(locales, 'name').map(locale => {
@ -233,6 +260,11 @@ export default {
</script>
<style lang="scss">
.smallTag {
width: 100%;
position: relative;
left: 90%;
}
.post-title {
margin-top: $space-x-small;
margin-bottom: $space-xx-small;

View File

@ -1,8 +1,10 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Editor from './Editor'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
const localVue = createLocalVue()
localVue.use(Vuex)

View File

@ -2,6 +2,9 @@ import { mount, createLocalVue } from '@vue/test-utils'
import CommentForm from './CommentForm.vue'
import Styleguide from '@human-connection/styleguide'
import Vuex from 'vuex'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
const localVue = createLocalVue()
localVue.use(Vuex)
@ -48,6 +51,7 @@ describe('CommentForm.vue', () => {
'editor/placeholder': () => {
return 'some cool placeholder'
},
'editor/editPending': () => false,
}
const store = new Vuex.Store({
getters,

View File

@ -1,5 +1,5 @@
<template>
<ds-form v-model="form" @submit="handleSubmit">
<ds-form v-show="!editPending" v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<ds-card>
<hc-editor
@ -33,6 +33,7 @@ import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor'
import PostCommentsQuery from '~/graphql/PostCommentsQuery.js'
import CommentMutations from '~/graphql/CommentMutations.js'
import { mapGetters } from 'vuex'
export default {
components: {
@ -52,6 +53,11 @@ export default {
users: [],
}
},
computed: {
...mapGetters({
editPending: 'editor/editPending',
}),
},
methods: {
updateEditorContent(value) {
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim()

View File

@ -16,11 +16,12 @@
</span>
</h3>
<ds-space margin-bottom="large" />
<div v-if="comments && comments.length" class="comments">
<div v-if="comments && comments.length" id="comments" class="comments">
<comment
v-for="(comment, index) in comments"
:key="comment.id"
:comment="comment"
:post="post"
@deleteComment="comments.splice(index, 1)"
/>
</div>

View File

@ -0,0 +1,108 @@
<template>
<ds-form v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<ds-card>
<!-- with no-ssr the content is not shown -->
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<ds-space />
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">
<ds-flex-item :width="{ base: '0%', md: '50%', sm: '0%', xs: '0%' }" />
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '30%', xs: '30%' }">
<ds-button ghost class="cancelBtn" @click.prevent="closeEditWindow">
{{ $t('actions.cancel') }}
</ds-button>
</ds-flex-item>
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '40%', xs: '40%' }">
<ds-button type="submit" :loading="loading" :disabled="disabled || errors" primary>
{{ $t('post.comment.submit') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</ds-card>
</template>
</ds-form>
</template>
<script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor'
import { mapMutations } from 'vuex'
import CommentMutations from '~/graphql/CommentMutations.js'
export default {
components: {
HcEditor,
},
props: {
comment: {
type: Object,
default() {
return {}
},
},
},
data() {
return {
disabled: true,
loading: false,
form: {
content: this.comment.content,
},
users: [],
}
},
methods: {
...mapMutations({
setEditPending: 'editor/SET_EDIT_PENDING',
}),
updateEditorContent(value) {
const sanitizedContent = value.replace(/<(?:.|\n)*?>/gm, '').trim()
this.disabled = value === this.comment.content || sanitizedContent.length < 1
this.form.content = value
},
closeEditWindow() {
this.$emit('showEditCommentMenu', false)
},
handleSubmit() {
this.loading = true
this.disabled = true
this.$apollo
.mutate({
mutation: CommentMutations().UpdateComment,
variables: {
content: this.form.content,
id: this.comment.id,
},
})
.then(() => {
this.loading = false
this.$toast.success(this.$t('post.comment.updated'))
this.disabled = false
this.$emit('showEditCommentMenu', false)
this.setEditPending(false)
})
.catch(err => {
this.$toast.error(err.message)
})
},
},
apollo: {
User: {
query() {
return gql`
{
User(orderBy: slug_asc) {
id
slug
}
}
`
},
result({ data: { User } }) {
this.users = User
},
},
},
}
</script>

View File

@ -20,5 +20,14 @@ export default () => {
}
}
`,
UpdateComment: gql`
mutation($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
content
contentExcerpt
}
}
`,
}
}

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag'
export default app => {
const lang = app.$i18n.locale().toUpperCase()
return gql(`
return gql`
query Comment($postId: ID) {
Comment(postId: $postId) {
id
@ -30,5 +30,5 @@ export default app => {
}
}
}
`)
`
}

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag'
export default i18n => {
const lang = i18n.locale().toUpperCase()
return gql(`
return gql`
query Post($slug: String!) {
Post(slug: $slug) {
id
@ -73,14 +73,14 @@ export default i18n => {
shoutedByCurrentUser
}
}
`)
`
}
export const filterPosts = i18n => {
const lang = i18n.locale().toUpperCase()
return gql(`
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
Post(filter: $filter, first: $first, offset: $offset) {
return gql`
query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id
title
contentExcerpt
@ -118,5 +118,5 @@ export const filterPosts = i18n => {
shoutedCount
}
}
`)
`
}

View File

@ -23,6 +23,12 @@
"bank": "Bankverbindung",
"germany": "Deutschland"
},
"sorting": {
"newest": "Neuste",
"oldest": "Älteste",
"popular": "Beliebt",
"commented": "meist Kommentiert"
},
"login": {
"copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.",
"login": "Einloggen",
@ -168,7 +174,8 @@
},
"social-media": {
"name": "Soziale Medien",
"placeholder": "Füge eine Social-Media URL hinzu",
"placeholder": "Deine Social-Media URL",
"requireUnique": "Dieser Link existiert bereits",
"submit": "Link hinzufügen",
"successAdd": "Social-Media hinzugefügt. Profil aktualisiert!",
"successDelete": "Social-Media gelöscht. Profil aktualisiert!"
@ -286,6 +293,7 @@
"reportContent": "Melden",
"validations": {
"email": "muss eine gültige E-Mail Adresse sein",
"url": "muss eine gültige URL sein",
"verification-code": "muss genau 6 Buchstaben lang sein"
}
},

View File

@ -18,11 +18,17 @@
"tribunal": "Registry court",
"register": "Registry number",
"director": "Managing Director",
"taxident": "Value added tax identification number according to § 27 a Value Added Tax Act (Germany)",
"responsible": "Responsible according to § 55 Abs. 2 RStV (Germany) ",
"taxident": "USt-ID. according to §27a of the German Sales Tax Law:",
"responsible": "responsible for contents of this page (§ 55 Abs. 2 RStV)",
"bank": "bank account",
"germany": "Germany"
},
"sorting": {
"newest": "Newest",
"oldest": "Oldest",
"popular": "Popular",
"commented": "most Commented"
},
"login": {
"copy": "If you already have a human-connection account, login here.",
"login": "Login",
@ -169,7 +175,8 @@
},
"social-media": {
"name": "Social media",
"placeholder": "Add social media url",
"placeholder": "Your social media url",
"requireUnique": "You added this url already",
"submit": "Add link",
"successAdd": "Added social media. Updated user profile!",
"successDelete": "Deleted social media. Updated user profile!"
@ -246,7 +253,8 @@
},
"comment": {
"submit": "Comment",
"submitted": "Comment Submitted"
"submitted": "Comment Submitted",
"updated": "Changes Saved"
}
},
"comment": {
@ -287,6 +295,7 @@
"reportContent": "Report",
"validations": {
"email": "must be a valid email address",
"url": "must be a valid URL",
"verification-code": "must be 6 characters long"
}
},

View File

@ -305,15 +305,6 @@ module.exports = {
tokenName: 'human-connection-token', // optional, default: apollo-token
tokenExpires: 3, // optional, default: 7 (days)
// includeNodeModules: true, // optional, default: false (this includes graphql-tag for node_modules folder)
// optional
errorHandler(error) {
/* eslint-disable-next-line no-console */
console.log(
'%cError',
'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;',
error.message,
)
},
// Watch loading state for all queries
// See 'Smart Query > options > watchLoading' for detail

View File

@ -50,16 +50,16 @@
},
"dependencies": {
"@human-connection/styleguide": "0.5.17",
"@nuxtjs/apollo": "4.0.0-rc4.2",
"@nuxtjs/apollo": "^4.0.0-rc10",
"@nuxtjs/axios": "~5.5.4",
"@nuxtjs/dotenv": "~1.3.0",
"@nuxtjs/style-resources": "~0.1.2",
"accounting": "~0.4.1",
"apollo-cache-inmemory": "~1.5.1",
"apollo-cache-inmemory": "~1.6.2",
"apollo-client": "~2.6.3",
"cookie-universal-nuxt": "~2.0.16",
"cookie-universal-nuxt": "~2.0.17",
"cross-env": "~5.2.0",
"date-fns": "2.0.0-beta.2",
"date-fns": "2.0.0-beta.3",
"express": "~4.17.1",
"graphql": "~14.4.2",
"isemail": "^3.2.0",
@ -70,21 +70,21 @@
"nuxt-env": "~0.1.0",
"stack-utils": "^1.0.2",
"string-hash": "^1.1.3",
"tiptap": "1.21.0",
"tiptap-extensions": "1.22.2",
"tiptap": "~1.24.0",
"tiptap-extensions": "~1.26.0",
"v-tooltip": "~2.0.2",
"vue-count-to": "~1.0.13",
"vue-izitoast": "1.1.2",
"vue-sweetalert-icons": "~3.2.0",
"vuex-i18n": "~1.11.0",
"vuex-i18n": "~1.13.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@babel/core": "~7.5.4",
"@babel/core": "~7.5.5",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "~7.5.4",
"@vue/cli-shared-utils": "~3.9.0",
"@vue/eslint-config-prettier": "~4.0.1",
"@babel/preset-env": "~7.5.5",
"@vue/cli-shared-utils": "~3.10.0",
"@vue/eslint-config-prettier": "~5.0.0",
"@vue/server-test-utils": "~1.0.0-beta.29",
"@vue/test-utils": "~1.0.0-beta.29",
"babel-core": "~7.0.0-bridge.0",
@ -94,20 +94,22 @@
"eslint-config-prettier": "~6.0.0",
"eslint-config-standard": "~12.0.0",
"eslint-loader": "~2.2.1",
"eslint-plugin-import": "~2.18.0",
"eslint-plugin-jest": "~22.8.0",
"eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.14.1",
"eslint-plugin-node": "~9.1.0",
"eslint-plugin-prettier": "~3.1.0",
"eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.0",
"eslint-plugin-vue": "~5.2.3",
"flush-promises": "^1.0.2",
"fuse.js": "^3.4.5",
"jest": "~24.8.0",
"mutation-observer": "^1.0.3",
"node-sass": "~4.12.0",
"nodemon": "~1.19.1",
"prettier": "~1.18.2",
"sass-loader": "~7.1.0",
"tippy.js": "^4.3.4",
"tippy.js": "^4.3.5",
"vue-jest": "~3.0.4",
"vue-svg-loader": "~0.12.0"
}

140
webapp/pages/index.spec.js Normal file
View File

@ -0,0 +1,140 @@
import { config, shallowMount, mount, createLocalVue } from '@vue/test-utils'
import PostIndex from './index.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
import VTooltip from 'v-tooltip'
import FilterMenu from '~/components/FilterMenu/FilterMenu'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
localVue.use(VTooltip)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['router-link'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('PostIndex', () => {
let wrapper
let Wrapper
let store
let mocks
beforeEach(() => {
store = new Vuex.Store({
getters: {
'posts/posts': () => {
return [
{
id: 'p23',
name: 'It is a post',
author: {
id: 'u1',
},
},
]
},
'auth/user': () => {
return { id: 'u23' }
},
},
})
mocks = {
$t: key => key,
$filters: {
truncate: a => a,
removeLinks: jest.fn(),
},
// If you are mocking router, than don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$router: {
history: {
push: jest.fn(),
},
push: jest.fn(),
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
queries: {
Post: {
refetch: jest.fn(),
fetchMore: jest.fn().mockResolvedValue([
{
id: 'p23',
name: 'It is a post',
author: {
id: 'u1',
},
},
]),
},
},
},
$route: {
query: {},
},
}
})
describe('shallowMount', () => {
Wrapper = () => {
return shallowMount(PostIndex, {
store,
mocks,
localVue,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('refetches Posts when changeFilterBubble is emitted', () => {
wrapper.find(FilterMenu).vm.$emit('changeFilterBubble')
expect(mocks.$apollo.queries.Post.refetch).toHaveBeenCalledTimes(1)
})
it('clears the search when the filter menu emits clearSearch', () => {
wrapper.find(FilterMenu).vm.$emit('clearSearch')
expect(wrapper.vm.hashtag).toBeNull()
})
it('calls the changeFilterBubble if there are hasthags in the route query', () => {
mocks.$route.query.hashtag = { id: 'hashtag' }
wrapper = Wrapper()
expect(mocks.$apollo.queries.Post.refetch).toHaveBeenCalledTimes(1)
})
describe('mount', () => {
beforeEach(() => {
wrapper = mount(PostIndex, {
store,
mocks,
localVue,
})
})
it('sets the post in the store when there are posts', () => {
wrapper
.findAll('li')
.at(0)
.trigger('click')
expect(wrapper.vm.sorting).toEqual('createdAt_desc')
})
it('loads more posts when a user clicks on the load more button', () => {
wrapper
.findAll('button')
.at(2)
.trigger('click')
expect(mocks.$apollo.queries.Post.fetchMore).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@ -9,6 +9,17 @@
@clearSearch="clearSearch"
/>
</ds-flex-item>
<ds-flex-item>
<div class="sorting-dropdown">
<ds-select
v-model="selected"
:options="sortingOptions"
size="large"
v-bind:icon-right="sortingIcon"
@input="toggleOnlySorting"
></ds-select>
</div>
</ds-flex-item>
<hc-post-card
v-for="(post, index) in posts"
:key="post.id"
@ -53,6 +64,36 @@ export default {
pageSize: 12,
filter: {},
hashtag,
placeholder: this.$t('sorting.newest'),
selected: this.$t('sorting.newest'),
sortingIcon: 'sort-amount-desc',
sorting: 'createdAt_desc',
sortingOptions: [
{
label: this.$t('sorting.newest'),
value: 'Newest',
icons: 'sort-amount-desc',
order: 'createdAt_desc',
},
{
label: this.$t('sorting.oldest'),
value: 'Oldest',
icons: 'sort-amount-asc',
order: 'createdAt_asc',
},
{
label: this.$t('sorting.popular'),
value: 'Popular',
icons: 'fire',
order: 'shoutedCount_desc',
},
{
label: this.$t('sorting.commented'),
value: 'Commented',
icons: 'comment',
order: 'commentsCount_desc',
},
],
}
},
mounted() {
@ -89,7 +130,12 @@ export default {
}
}
this.filter = filter
this.$apollo.queries.Post.refresh()
this.$apollo.queries.Post.refetch()
},
toggleOnlySorting(x) {
this.sortingIcon = x.icons
this.sorting = x.order
this.$apollo.queries.Post.refetch()
},
clearSearch() {
this.$router.push({ path: '/' })
@ -144,6 +190,7 @@ export default {
filter: this.filter,
first: this.pageSize,
offset: 0,
orderBy: this.sorting,
}
},
fetchPolicy: 'cache-and-network',
@ -161,4 +208,11 @@ export default {
transform: translate(-120%, -120%);
box-shadow: $box-shadow-x-large;
}
.sorting-dropdown {
width: 250px;
position: relative;
float: right;
padding: 0 18px;
}
</style>

View File

@ -134,9 +134,9 @@
</ds-text>
<template>
<ds-space v-for="link in socialMediaLinks" :key="link.username" margin="x-small">
<a :href="link.url">
<a :href="link.url" target="_blank">
<ds-avatar :image="link.favicon" />
{{ 'link.username' }}
{{ link.username }}
</a>
</ds-space>
</template>

View File

@ -1,4 +1,5 @@
import { mount, createLocalVue } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import MySocialMedia from './my-social-media.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
@ -12,23 +13,17 @@ localVue.use(Filters)
describe('my-social-media.vue', () => {
let wrapper
let store
let mocks
let getters
let input
let submitBtn
const socialMediaUrl = 'https://freeradical.zone/@mattwr18'
const newSocialMediaUrl = 'https://twitter.com/mattwr18'
const faviconUrl = 'https://freeradical.zone/favicon.ico'
beforeEach(() => {
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest
.fn()
.mockRejectedValue({ message: 'Ouch!' })
.mockResolvedValueOnce({
data: { CreateSocialMeda: { id: 's1', url: socialMediaUrl } },
}),
mutate: jest.fn(),
},
$toast: {
error: jest.fn(),
@ -43,79 +38,161 @@ describe('my-social-media.vue', () => {
})
describe('mount', () => {
let form, input, submitButton
const Wrapper = () => {
store = new Vuex.Store({
const store = new Vuex.Store({
getters,
})
return mount(MySocialMedia, { store, mocks, localVue })
}
it('renders', () => {
wrapper = Wrapper()
expect(wrapper.contains('div')).toBe(true)
})
describe('given currentUser has a social media account linked', () => {
describe('adding social media link', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return {
socialMedia: [{ id: 's1', url: socialMediaUrl }],
}
},
}
})
it("displays a link to the currentUser's social media", () => {
wrapper = Wrapper()
const socialMediaLink = wrapper.find('a').attributes().href
expect(socialMediaLink).toBe(socialMediaUrl)
form = wrapper.find('form')
input = wrapper.find('input#addSocialMedia')
submitButton = wrapper.find('button')
})
beforeEach(() => {
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest
.fn()
.mockRejectedValue({ message: 'Ouch!' })
.mockResolvedValueOnce({
data: { DeleteSocialMeda: { id: 's1', url: socialMediaUrl } },
}),
},
$toast: {
error: jest.fn(),
success: jest.fn(),
},
}
getters = {
'auth/user': () => {
return {
socialMedia: [{ id: 's1', url: socialMediaUrl }],
}
},
}
it('requires the link to be a valid url', () => {
input.setValue('some value')
form.trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('displays a trash sympol after a social media and allows the user to delete it', () => {
wrapper = Wrapper()
const deleteSelector = wrapper.find({ name: 'delete' })
expect(deleteSelector).toEqual({ selector: 'Component' })
const icon = wrapper.find({ name: 'trash' })
icon.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
it('displays an error message when not saved successfully', async () => {
mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
input.setValue(newSocialMediaUrl)
form.trigger('submit')
await flushPromises()
expect(mocks.$toast.error).toHaveBeenCalledTimes(1)
})
describe('success', () => {
beforeEach(() => {
mocks.$apollo.mutate.mockResolvedValue({
data: { CreateSocialMedia: { id: 's2', url: newSocialMediaUrl } },
})
input.setValue(newSocialMediaUrl)
form.trigger('submit')
})
it('sends the new url to the backend', () => {
const expected = expect.objectContaining({
variables: { url: newSocialMediaUrl },
})
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('displays a success message', async () => {
await flushPromises()
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('clears the form', async () => {
await flushPromises()
expect(input.value).toBe(undefined)
expect(submitButton.vm.$attrs.disabled).toBe(true)
})
})
})
describe('currentUser does not have a social media account linked', () => {
it('allows a user to add a social media link', () => {
describe('given existing social media links', () => {
beforeEach(() => {
getters = {
'auth/user': () => ({
socialMedia: [{ id: 's1', url: socialMediaUrl }],
}),
}
wrapper = Wrapper()
input = wrapper.find({ name: 'social-media' })
input.element.value = socialMediaUrl
input.trigger('input')
submitBtn = wrapper.find('.ds-button')
submitBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
form = wrapper.find('form')
})
describe('for each link it', () => {
it('displays the favicon', () => {
expect(wrapper.find(`img[src="${faviconUrl}"]`).exists()).toBe(true)
})
it('displays the url', () => {
expect(wrapper.find(`a[href="${socialMediaUrl}"]`).exists()).toBe(true)
})
it('displays the edit button', () => {
expect(wrapper.find('a[name="edit"]').exists()).toBe(true)
})
it('displays the delete button', () => {
expect(wrapper.find('a[name="delete"]').exists()).toBe(true)
})
})
it('does not accept a duplicate url', () => {
input = wrapper.find('input#addSocialMedia')
input.setValue(socialMediaUrl)
form.trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
describe('editing social media link', () => {
beforeEach(() => {
const editButton = wrapper.find('a[name="edit"]')
editButton.trigger('click')
input = wrapper.find('input#editSocialMedia')
})
it('disables adding new links while editing', () => {
const addInput = wrapper.find('input#addSocialMedia')
expect(addInput.exists()).toBe(false)
})
it('sends the new url to the backend', () => {
const expected = expect.objectContaining({
variables: { id: 's1', url: newSocialMediaUrl },
})
input.setValue(newSocialMediaUrl)
form.trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('allows the user to cancel editing', () => {
const cancelButton = wrapper.find('button#cancel')
cancelButton.trigger('click')
expect(wrapper.find('input#editSocialMedia').exists()).toBe(false)
})
})
describe('deleting social media link', () => {
beforeEach(() => {
const deleteButton = wrapper.find('a[name="delete"]')
deleteButton.trigger('click')
})
it('sends the link id to the backend', () => {
const expected = expect.objectContaining({
variables: { id: 's1' },
})
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('displays a success message', async () => {
await flushPromises()
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@ -1,49 +1,90 @@
<template>
<ds-card :header="$t('settings.social-media.name')">
<ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small">
<ds-list>
<ds-list-item v-for="link in socialMediaLinks" :key="link.id">
<a :href="link.url" target="_blank">
<img :src="link.favicon | proxyApiUrl" alt="Social Media link" width="16" height="16" />
{{ link.url }}
</a>
&nbsp;&nbsp;
<span class="layout-leave-active">|</span>
&nbsp;&nbsp;
<ds-icon name="edit" class="layout-leave-active" />
<a name="delete" @click="handleDeleteSocialMedia(link)">
<ds-icon name="trash" />
</a>
</ds-list-item>
</ds-list>
</ds-space>
<ds-space margin-top="base">
<div>
<ds-input
v-model="value"
:placeholder="$t('settings.social-media.placeholder')"
name="social-media"
:schema="{ type: 'url' }"
/>
</div>
<ds-space margin-top="base">
<div>
<ds-button primary @click="handleAddSocialMedia">
{{ $t('settings.social-media.submit') }}
</ds-button>
</div>
<ds-form
v-model="formData"
:schema="formSchema"
@input="handleInput"
@input-valid="handleInputValid"
@submit="handleSubmitSocialMedia"
>
<ds-card :header="$t('settings.social-media.name')">
<ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small">
<ds-list>
<ds-list-item v-for="link in socialMediaLinks" :key="link.id" class="list-item--high">
<ds-input
v-if="editingLink.id === link.id"
id="editSocialMedia"
model="socialMediaUrl"
type="text"
:placeholder="$t('settings.social-media.placeholder')"
/>
<template v-else>
<a :href="link.url" target="_blank">
<img :src="link.favicon" alt="Link:" height="16" width="16" />
{{ link.url }}
</a>
<span class="divider">|</span>
<a name="edit" @click="handleEditSocialMedia(link)">
<ds-icon
:aria-label="$t('actions.edit')"
class="icon-button"
name="edit"
:title="$t('actions.edit')"
/>
</a>
<a name="delete" @click="handleDeleteSocialMedia(link)">
<ds-icon
:aria-label="$t('actions.delete')"
class="icon-button"
name="trash"
:title="$t('actions.delete')"
/>
</a>
</template>
</ds-list-item>
</ds-list>
</ds-space>
</ds-space>
</ds-card>
<ds-space margin-top="base">
<ds-input
v-if="!editingLink.id"
id="addSocialMedia"
model="socialMediaUrl"
type="text"
:placeholder="$t('settings.social-media.placeholder')"
/>
<ds-space margin-top="base">
<ds-button primary :disabled="disabled">
{{ editingLink.id ? $t('actions.save') : $t('settings.social-media.submit') }}
</ds-button>
<ds-button v-if="editingLink.id" id="cancel" ghost @click="handleCancel()">
{{ $t('actions.cancel') }}
</ds-button>
</ds-space>
</ds-space>
</ds-card>
</ds-form>
</template>
<script>
import unionBy from 'lodash/unionBy'
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex'
export default {
data() {
return {
value: '',
formData: {
socialMediaUrl: '',
},
formSchema: {
socialMediaUrl: {
type: 'url',
message: this.$t('common.validations.url'),
},
},
disabled: true,
editingLink: {},
}
},
computed: {
@ -51,11 +92,10 @@ export default {
currentUser: 'auth/user',
}),
socialMediaLinks() {
const domainRegex = /^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g
const { socialMedia = [] } = this.currentUser
return socialMedia.map(socialMedia => {
const { id, url } = socialMedia
const matches = url.match(/^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
const [domain] = matches || []
return socialMedia.map(({ id, url }) => {
const [domain] = url.match(domainRegex) || []
const favicon = domain ? `${domain}/favicon.ico` : null
return { id, url, favicon }
})
@ -65,39 +105,28 @@ export default {
...mapMutations({
setCurrentUser: 'auth/SET_USER',
}),
handleAddSocialMedia() {
this.$apollo
.mutate({
mutation: gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`,
variables: {
url: this.value,
},
update: (store, { data }) => {
const socialMedia = [...this.currentUser.socialMedia, data.CreateSocialMedia]
this.setCurrentUser({
...this.currentUser,
socialMedia,
})
},
})
.then(() => {
this.$toast.success(this.$t('settings.social-media.successAdd'))
this.value = ''
})
.catch(error => {
this.$toast.error(error.message)
})
handleCancel() {
this.editingLink = {}
this.formData.socialMediaUrl = ''
this.disabled = true
},
handleDeleteSocialMedia(link) {
this.$apollo
.mutate({
handleEditSocialMedia(link) {
this.editingLink = link
this.formData.socialMediaUrl = link.url
},
handleInput(data) {
this.disabled = true
},
handleInputValid(data) {
if (data.socialMediaUrl.length < 1) {
this.disabled = true
} else {
this.disabled = false
}
},
async handleDeleteSocialMedia(link) {
try {
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
@ -119,19 +148,83 @@ export default {
})
},
})
.then(() => {
this.$toast.success(this.$t('settings.social-media.successDelete'))
})
.catch(error => {
this.$toast.error(error.message)
this.$toast.success(this.$t('settings.social-media.successDelete'))
} catch (err) {
this.$toast.error(err.message)
}
},
async handleSubmitSocialMedia() {
const isEditing = !!this.editingLink.id
const url = this.formData.socialMediaUrl
const duplicateUrl = this.socialMediaLinks.find(link => link.url === url)
if (duplicateUrl && duplicateUrl.id !== this.editingLink.id) {
return this.$toast.error(this.$t('settings.social-media.requireUnique'))
}
let mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
const variables = { url }
let successMessage = this.$t('settings.social-media.successAdd')
if (isEditing) {
mutation = gql`
mutation($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
variables.id = this.editingLink.id
successMessage = this.$t('settings.data.success')
}
try {
await this.$apollo.mutate({
mutation,
variables,
update: (store, { data }) => {
const newSocialMedia = isEditing ? data.UpdateSocialMedia : data.CreateSocialMedia
this.setCurrentUser({
...this.currentUser,
socialMedia: unionBy([newSocialMedia], this.currentUser.socialMedia, 'id'),
})
},
})
this.$toast.success(successMessage)
this.formData.socialMediaUrl = ''
this.disabled = true
this.editingLink = {}
} catch (err) {
this.$toast.error(err.message)
}
},
},
}
</script>
<style lang="scss">
.layout-leave-active {
.divider {
opacity: 0.4;
padding: 0 $space-small;
}
.icon-button {
cursor: pointer;
}
.list-item--high {
.ds-list-item-prefix {
align-self: center;
}
}
</style>

View File

@ -1,6 +1,7 @@
export const state = () => {
return {
placeholder: null,
editPending: false,
}
}
@ -8,10 +9,16 @@ export const getters = {
placeholder(state) {
return state.placeholder
},
editPending(state) {
return state.editPending
},
}
export const mutations = {
SET_PLACEHOLDER_TEXT(state, text) {
state.placeholder = text
},
SET_EDIT_PENDING(state, boolean) {
state.editPending = boolean
},
}

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