Merge branch 'master' of github.com:Ocelot-Social-Community/Ocelot-Social into 4548-single-person-org

This commit is contained in:
Wolfgang Huß 2022-05-06 15:19:51 +02:00
commit 22681b2f1f
35 changed files with 639 additions and 251 deletions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v17.9.0

View File

@ -4,8 +4,27 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.0.7](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.6...1.0.7)
- Bump rosie from 2.0.1 to 2.1.0 [`#4520`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4520)
- fix: 🍰 Renew JWT In Decode Test [`#4798`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4798)
- docs: 🍰 Refine Main README.md With Test Tech Stack And Video Link [`#4772`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4772)
- docs: 🍰 Change README.md DB Commands For Docker [`#4765`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4765)
- Bump date-fns from 2.23.0 to 2.25.0 [`#4753`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4753)
- Bump neo4j-driver from 4.0.2 to 4.3.4 [`#4729`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4729)
- chore: 🍰 Update Neode To v0.4.7 [`#4751`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4751)
- doc: 🍰 Update README.md Etc. [`#4733`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4733)
- feat: 🍰 New CSS For Internal Pages [`#4741`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4741)
- fix: 🍰 Change Notification E-Mails Settings Page Link [`#4742`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4742)
- Refactor internal pages to new CSS [`acad80c`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/acad80c3c8262934dd2e38961c08c0fde769099a)
- Renew JWT in decode test [`46eb6b8`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/46eb6b82ea802d4d6ca7294cd32d1fe16425bfea)
- Revert "Renew JWT in decode test" only for changing the Neode version [`a0d92b4`](https://github.com/Ocelot-Social-Community/Ocelot-Social/commit/a0d92b4853d09d725c1fb7886cbfed2a00e1f05c)
#### [1.0.6](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.5...1.0.6)
> 4 October 2021
- chore: 🍰 Set 'sendNotification' Emails Init Admin [`#4690`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4690)
- feat: 🍰 Send Notification E-Mail [`#4623`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4623)
- feat: 🍰 Implement Progress Bar Again [`#4357`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4357)
- Bump nodemailer-html-to-text from 3.1.0 to 3.2.0 in /backend [`#4531`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4531)

View File

@ -20,6 +20,11 @@ At the same time, it should be possible in the future to link these networks wit
In other words, we are interested in a network of networks and in keeping the data as close as possible to the user and the operator they trusts.
## Introduction
Have a look into our short video:
[ocelot.social - GitHub - Developer Welcome - Tutorial (english)](https://www.youtube.com/watch?v=gZSL6KvBIiY&list=PLFMD5liPP01kbuReHxYXxv_1fI5rIgS1f&index=1)
## Directory Layout
There are three important directories:
@ -192,6 +197,14 @@ The only deployment method in this repository for development purposes as descri
* [NodeJS](https://nodejs.org/en/)
* [Neo4J](https://neo4j.com/)
### For Testing
* [Cypress](https://docs.cypress.io/)
* [Storybook](https://storybook.js.org/)
* [Jest](https://jestjs.io/)
* [Vue Test Utils](https://vue-test-utils.vuejs.org/)
* [ESLint](https://eslint.org/)
## Attributions
Locale Icons made by [Freepik](http://www.freepik.com/) from [www.flaticon.com](https://www.flaticon.com/) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/).

1
backend/.nvmrc Normal file
View File

@ -0,0 +1 @@
v12.19.0

View File

@ -1,6 +1,6 @@
{
"name": "ocelot-social-backend",
"version": "1.0.6",
"version": "1.0.7",
"description": "GraphQL Backend for ocelot.social",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",
@ -66,7 +66,6 @@
"debug": "~4.1.1",
"dotenv": "~8.2.0",
"express": "^4.17.1",
"faker": "Marak/faker.js#master",
"graphql": "^14.6.0",
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
@ -120,6 +119,7 @@
"xregexp": "^4.3.0"
},
"devDependencies": {
"@faker-js/faker": "5.1.0",
"apollo-server-testing": "~2.11.0",
"chai": "~4.2.0",
"cucumber": "~6.0.5",

View File

@ -1,8 +1,8 @@
import { v4 as uuid } from 'uuid'
import faker from 'faker'
import slugify from 'slug'
import { hashSync } from 'bcryptjs'
import { Factory } from 'rosie'
import faker from '@faker-js/faker'
import { getDriver, getNeode } from './neo4j'
import CONFIG from '../config/index.js'
import generateInviteCode from '../schema/resolvers/helpers/generateInviteCode.js'

View File

@ -19,8 +19,8 @@ export async function up(next) {
SET donationInfo.createdAt = toString(datetime())
SET donationInfo.updatedAt = donationInfo.createdAt
SET donationInfo.showDonations = false
SET donationInfo.goal = 15000
SET donationInfo.progress = 1200
SET donationInfo.goal = 15000.0
SET donationInfo.progress = 1200.0
RETURN donationInfo {.*}
`,
{ donationId },

View File

@ -1,7 +1,7 @@
import faker from 'faker'
import sample from 'lodash/sample'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../server'
import faker from '@faker-js/faker'
import Factory from '../db/factories'
import { getNeode, getDriver } from '../db/neo4j'
import { gql } from '../helpers/jest'

View File

@ -21,8 +21,14 @@ const neode = getNeode()
// iss: 'http://localhost:4000',
// sub: 'u3'
// }
// !!! if the token expires go into the GraphQL Playground in the browser at 'http://localhost:4000' with a running backend and a seeded Neo4j database
// now do the login mutation:
// mutation {
// login(email:"user@example.org", password:"1234")
// }
// replace this token here with the one you received as the result
export const validAuthorizationHeader =
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImxvY2F0aW9uTmFtZSI6bnVsbCwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJhYm91dCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9zYXNoYV9zaGVzdGFrb3YvMTI4LmpwZyIsImlkIjoidTMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5vcmciLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTU1MDg0NjY4MCwiZXhwIjoxNjM3MjQ2NjgwLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.eZ_mVKas4Wzoc_JrQTEWXyRn7eY64cdIg4vqQ-F_7Jc'
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InUzIiwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTYzNzY0NDMwMCwiZXhwIjoxNzAwNzU5NTAwLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.ispIfRfgkXuYoIhKx7x2jPxgvHDJVv1ogMycLmfUnsk'
beforeAll(async () => {
await cleanDatabase()
@ -47,6 +53,7 @@ describe('decode', () => {
beforeEach(() => {
authorizationHeader = null
})
it('returns null', returnsNull)
})
@ -54,6 +61,7 @@ describe('decode', () => {
beforeEach(() => {
authorizationHeader = undefined
})
it('returns null', returnsNull)
})
@ -61,6 +69,7 @@ describe('decode', () => {
beforeEach(() => {
authorizationHeader = 'blah'
})
it('returns null', returnsNull)
})
@ -68,6 +77,7 @@ describe('decode', () => {
beforeEach(() => {
authorizationHeader = validAuthorizationHeader
})
it('returns null', returnsNull)
describe('and corresponding user in the database', () => {

View File

@ -41,7 +41,6 @@ const publishNotifications = async (context, promises) => {
notifications.forEach((notificationAdded, index) => {
pubsub.publish(NOTIFICATION_ADDED, { notificationAdded })
if (notificationAdded.to.sendNotificationEmails) {
// Wolle await
sendMail(
notificationTemplate({
email: notificationsEmailAddresses[index].email,

View File

@ -38,8 +38,8 @@ const newlyCreatedNodesWithLocales = [
nameRU: 'Вельцхайм',
nameNL: 'Welzheim',
namePL: 'Welzheim',
lng: 9.63444,
lat: 48.87472,
lng: 9.634741,
lat: 48.874924,
},
state: {
id: expect.stringContaining('region'),

View File

@ -963,6 +963,11 @@
exec-sh "^0.3.2"
minimist "^1.2.0"
"@faker-js/faker@5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.1.0.tgz#cee1d77ada0d0dbbe77201d18b1ebabf432d9c0f"
integrity sha512-0VonSKh7fBCqvY+V2FLN2ZW4pR4ZtWJalWmwSaiaB7yK7y4qp8vDfuaq9QdLjf/cdZGx3M7Wc4Q+x4fZHxI21Q==
"@graphql-toolkit/common@0.10.4":
version "0.10.4"
resolved "https://registry.yarnpkg.com/@graphql-toolkit/common/-/common-0.10.4.tgz#7785f2a3f14559d0778859c49f4442078c196695"
@ -4587,10 +4592,6 @@ extsprintf@^1.2.0:
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
faker@Marak/faker.js#master:
version "4.1.0"
resolved "https://codeload.github.com/Marak/faker.js/tar.gz/3b2fa4aebccee52ae1bafc15d575061fb30c3cf1"
fast-deep-equal@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"

View File

@ -1,9 +1,12 @@
import { When } from "cypress-cucumber-preprocessor/steps";
When('I add a social media link', () => {
cy.get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
cy.get('button')
.contains('Add link')
.click()
.get('#editSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Save')
.click()
})

View File

@ -2,9 +2,12 @@ import { Given } from "cypress-cucumber-preprocessor/steps";
Given('I have added a social media link', () => {
cy.visit('/settings/my-social-media')
.get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Add link')
.click()
.get('#editSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Save')
.click()
})

View File

@ -1,6 +1,6 @@
{
"name": "ocelot-social",
"version": "1.0.6",
"version": "1.0.7",
"description": "Free and open source software program code available to run social networks.",
"author": "ocelot.social Community",
"license": "MIT",
@ -36,7 +36,7 @@
"date-fns": "^2.25.0",
"dotenv": "^8.2.0",
"expect": "^25.3.0",
"faker": "Marak/faker.js#master",
"@faker-js/faker": "5.1.0",
"graphql-request": "^2.0.0",
"import": "^0.0.6",
"jsonwebtoken": "^8.5.1",
@ -44,7 +44,7 @@
"neo4j-driver": "^4.3.4",
"neode": "^0.4.7",
"npm-run-all": "^4.1.5",
"rosie": "^2.0.1",
"rosie": "^2.1.0",
"slug": "^5.1.0"
},
"resolutions": {

1
webapp/.nvmrc Normal file
View File

@ -0,0 +1 @@
v12.19.0

View File

@ -1,8 +1,8 @@
import faker from '@faker-js/faker'
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcCommentList from './CommentList.vue'
import helpers from '~/storybook/helpers'
import faker from 'faker'
helpers.init()

View File

@ -3,12 +3,16 @@
<template #default="{ openMenu, closeMenu }">
<slot name="button">
<menu-bar-button
class="legend-question-button"
icon="question-circle"
circle
ghost
class="legend-question-button"
@mouseover.native="openMenu()"
@mouseleave.native="closeMenu()"
:onClick="
() => {
isDropdownOpen ? closeMenu() : openMenu()
isDropdownOpen = !isDropdownOpen
}
"
/>
</slot>
</template>
@ -60,6 +64,7 @@ export default {
{ iconName: 'quote-right', name: `editor.legend.quote`, shortcut: '> + space' },
{ iconName: 'minus', name: `editor.legend.ruler`, shortcut: '---' },
],
isDropdownOpen: false,
}
},
}

View File

@ -179,10 +179,24 @@ export default {
}
.html {
width: 100%;
// width: 100%;
// height: 100%;
// see this working solution here: https://stackoverflow.com/questions/35814653/automatic-height-when-embedding-a-youtube-video
position: relative;
padding-bottom: 56.25%; /* 16:9 */
height: 0;
iframe {
// width: 100%;
// height: auto;
// same solution example as above
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}

View File

@ -0,0 +1,128 @@
import { mount } from '@vue/test-utils'
import MySomethingList from './MySomethingList.vue'
import Vue from 'vue'
const localVue = global.localVue
describe('MySomethingList.vue', () => {
let wrapper
let propsData
let data
let mocks
beforeEach(() => {
propsData = {
useFormData: { dummy: '' },
useItems: [{ id: 'id', dummy: 'dummy' }],
namePropertyKey: 'dummy',
callbacks: { edit: jest.fn(), submit: jest.fn(), delete: jest.fn() },
}
data = () => {
return {}
}
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest.fn(),
},
$toast: {
error: jest.fn(),
success: jest.fn(),
},
}
})
describe('mount', () => {
let form, slots
const Wrapper = () => {
slots = {
'list-item': '<div class="list-item"></div>',
'edit-item': '<div class="edit-item"></div>',
}
return mount(MySomethingList, {
propsData,
data,
mocks,
localVue,
slots,
})
}
describe('given existing item', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('for each item it', () => {
it('displays the item as slot "list-item"', () => {
expect(wrapper.find('.list-item').exists()).toBe(true)
})
it('displays the edit button', () => {
expect(wrapper.find('.base-button[data-test="edit-button"]').exists()).toBe(true)
})
it('displays the delete button', () => {
expect(wrapper.find('.base-button[data-test="delete-button"]').exists()).toBe(true)
})
})
describe('editing item', () => {
beforeEach(async () => {
const editButton = wrapper.find('.base-button[data-test="edit-button"]')
editButton.trigger('click')
await Vue.nextTick()
})
it('disables adding items while editing', () => {
const submitButton = wrapper.find('.base-button[data-test="add-save-button"]')
expect(submitButton.text()).not.toContain('settings.social-media.submit')
})
it('allows the user to cancel editing', async () => {
expect(wrapper.find('.edit-item').exists()).toBe(true)
const cancelButton = wrapper.find('button#cancel')
cancelButton.trigger('click')
await Vue.nextTick()
expect(wrapper.find('.edit-item').exists()).toBe(false)
})
})
describe('calls callback functions', () => {
it('calls edit', async () => {
const editButton = wrapper.find('.base-button[data-test="edit-button"]')
editButton.trigger('click')
await Vue.nextTick()
const expectedItem = expect.objectContaining({ id: 'id', dummy: 'dummy' })
expect(propsData.callbacks.edit).toHaveBeenCalledTimes(1)
expect(propsData.callbacks.edit).toHaveBeenCalledWith(expect.any(Object), expectedItem)
})
it('calls submit', async () => {
form = wrapper.find('form')
form.trigger('submit')
await Vue.nextTick()
form.trigger('submit')
await Vue.nextTick()
const expectedItem = expect.objectContaining({ id: '' })
expect(propsData.callbacks.submit).toHaveBeenCalledTimes(1)
expect(propsData.callbacks.submit).toHaveBeenCalledWith(
expect.any(Object),
true,
expectedItem,
{ dummy: '' },
)
})
it('calls delete', async () => {
const deleteButton = wrapper.find('.base-button[data-test="delete-button"]')
deleteButton.trigger('click')
await Vue.nextTick()
const expectedItem = expect.objectContaining({ id: 'id', dummy: 'dummy' })
expect(propsData.callbacks.delete).toHaveBeenCalledTimes(1)
expect(propsData.callbacks.delete).toHaveBeenCalledWith(expect.any(Object), expectedItem)
})
})
})
})
})

View File

@ -0,0 +1,185 @@
<template>
<ds-form
v-model="formData"
:schema="formSchema"
@input="handleInput"
@input-valid="handleInputValid"
@submit="handleSubmitItem"
>
<div v-if="isEditing">
<ds-space margin="base">
<ds-heading tag="h5">
{{
isCreation
? $t('settings.social-media.addNewTitle')
: $t('settings.social-media.editTitle', { name: editingItem[namePropertyKey] })
}}
</ds-heading>
</ds-space>
<ds-space v-if="items" margin-top="base">
<slot name="edit-item" />
</ds-space>
</div>
<div v-else>
<ds-space v-if="items" margin-top="base">
<ds-list>
<ds-list-item v-for="item in items" :key="item.id" class="list-item--high">
<template>
<slot name="list-item" :item="item" />
<span class="divider">|</span>
<base-button
icon="edit"
circle
ghost
@click="handleEditItem(item)"
:title="$t('actions.edit')"
data-test="edit-button"
/>
<base-button
icon="trash"
circle
ghost
@click="handleDeleteItem(item)"
:title="$t('actions.delete')"
data-test="delete-button"
/>
</template>
</ds-list-item>
</ds-list>
</ds-space>
</div>
<ds-space margin-top="base">
<ds-space margin-top="base">
<base-button
filled
:disabled="loading || !(!isEditing || (isEditing && !disabled))"
:loading="loading"
type="submit"
data-test="add-save-button"
>
{{ isEditing ? $t('actions.save') : $t('settings.social-media.submit') }}
</base-button>
<base-button v-if="isEditing" id="cancel" danger @click="handleCancel()">
{{ $t('actions.cancel') }}
</base-button>
</ds-space>
</ds-space>
</ds-form>
</template>
<script>
export default {
name: 'MySomethingList',
props: {
useFormData: {
type: Object,
default: () => ({}),
},
useFormSchema: {
type: Object,
default: () => ({}),
},
useItems: {
type: Array,
default: () => [],
},
defaultItem: {
type: Object,
default: () => ({}),
},
namePropertyKey: {
type: String,
required: true,
},
callbacks: {
type: Object,
default: () => ({
handleInput: () => {},
handleInputValid: () => {},
edit: () => {},
submit: () => {},
delete: () => {},
}),
},
},
data() {
return {
formData: this.useFormData,
formSchema: this.useFormSchema,
items: this.useItems,
disabled: true,
loading: false,
editingItem: null,
}
},
computed: {
isEditing() {
return this.editingItem !== null
},
isCreation() {
return this.editingItem !== null && this.editingItem.id === ''
},
},
watch: {
// can change by a parents callback and again given trough by v-bind from there
useItems(newItems) {
this.items = newItems
},
},
methods: {
handleInput(data) {
this.callbacks.handleInput(this, data)
this.disabled = true
},
handleInputValid(data) {
this.callbacks.handleInputValid(this, data)
},
handleEditItem(item) {
this.editingItem = item
this.callbacks.edit(this, item)
},
async handleSubmitItem() {
if (!this.isEditing) {
this.handleEditItem({ ...this.defaultItem, id: '' })
} else {
this.loading = true
if (await this.callbacks.submit(this, this.isCreation, this.editingItem, this.formData)) {
this.disabled = true
this.editingItem = null
}
this.loading = false
}
},
handleCancel() {
this.editingItem = null
this.disabled = true
},
async handleDeleteItem(item) {
await this.callbacks.delete(this, item)
},
},
}
</script>
<style lang="scss" scope>
.divider {
opacity: 0.4;
padding: 0 $space-small;
}
.icon-button {
cursor: pointer;
}
.list-item--high {
.ds-list-item-prefix {
align-self: center;
}
.ds-list-item-content {
display: flex;
align-items: center;
}
}
</style>

View File

@ -0,0 +1,36 @@
import { shallowMount } from '@vue/test-utils'
import SocialMediaListItem from './SocialMediaListItem.vue'
describe('SocialMediaListItem.vue', () => {
let wrapper
let propsData
const socialMediaUrl = 'https://freeradical.zone/@mattwr18'
const faviconUrl = 'https://freeradical.zone/favicon.ico'
beforeEach(() => {
propsData = {}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(SocialMediaListItem, { propsData })
}
describe('given existing social media links', () => {
beforeEach(() => {
propsData = { item: { id: 's1', url: socialMediaUrl, favicon: faviconUrl } }
wrapper = Wrapper()
})
describe('for each link item 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)
})
})
})
})
})

View File

@ -0,0 +1,18 @@
<template>
<a :href="item.url" target="_blank">
<img :src="item.favicon" alt="Link:" height="16" width="16" />
{{ item.url }}
</a>
</template>
<script>
export default {
name: 'SocialMediaListItem',
props: {
item: {
type: Object,
default: () => ({}),
},
},
}
</script>

View File

@ -1,3 +1,4 @@
import faker from '@faker-js/faker'
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcEmpty from '~/components/Empty/Empty'
@ -8,7 +9,6 @@ import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import HcHashtag from '~/components/Hashtag/Hashtag'
import helpers from '~/storybook/helpers'
import faker from 'faker'
import { post } from '~/components/PostTeaser/PostTeaser.story.js'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'

View File

@ -773,6 +773,8 @@
"name": "Sicherheit"
},
"social-media": {
"addNewTitle": "Neuen Link hinzufügen",
"editTitle": "Link \"{name}\" ändern",
"name": "Soziale Netzwerke",
"placeholder": "Deine Webadresse des Sozialen Netzwerkes",
"requireUnique": "Dieser Link existiert bereits",

View File

@ -773,6 +773,8 @@
"name": "Security"
},
"social-media": {
"addNewTitle": "Add new link",
"editTitle": "Edit link \"{name}\"",
"name": "Social media",
"placeholder": "Your social media url",
"requireUnique": "You added this url already",

View File

@ -1,6 +1,6 @@
{
"name": "@ocelot-social/maintenance",
"version": "1.0.6",
"version": "1.0.7",
"description": "Maintenance page for ocelot.social",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",

View File

@ -201,6 +201,7 @@ export default {
/** * A Boolean indicating if the cookie transmission requires a
* secure protocol (https). Defaults to false. */
secure: CONFIG.COOKIE_HTTPS_ONLY,
sameSite: 'lax', // for the meaning see https://www.thinktecture.com/de/identity/samesite/samesite-in-a-nutshell/
},
// includeNodeModules: true, // optional, default: false (this includes graphql-tag for node_modules folder)

View File

@ -1,6 +1,6 @@
{
"name": "ocelot-social-webapp",
"version": "1.0.6",
"version": "1.0.7",
"description": "ocelot.social Frontend",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",
@ -108,6 +108,7 @@
"@babel/core": "~7.12.3",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "~7.9.0",
"@faker-js/faker": "5.1.0",
"@storybook/addon-a11y": "^6.3.6",
"@storybook/addon-actions": "^5.3.21",
"@storybook/addon-notes": "^5.3.18",
@ -136,7 +137,6 @@
"eslint-plugin-promise": "~4.3.1",
"eslint-plugin-standard": "~5.0.0",
"eslint-plugin-vue": "~6.2.2",
"faker": "^5.1.0",
"flush-promises": "^1.0.2",
"identity-obj-proxy": "^3.0.0",
"jest": "~26.6.3",

View File

@ -33,7 +33,7 @@ describe('my-social-media.vue', () => {
})
describe('mount', () => {
let form, input, submitButton
let form, input
const Wrapper = () => {
const store = new Vuex.Store({
getters,
@ -42,11 +42,12 @@ describe('my-social-media.vue', () => {
}
describe('adding social media link', () => {
beforeEach(() => {
beforeEach(async () => {
wrapper = Wrapper()
form = wrapper.find('form')
input = wrapper.find('input#addSocialMedia')
submitButton = wrapper.find('button')
form.trigger('submit')
await Vue.nextTick()
input = wrapper.find('input#editSocialMedia')
})
it('requires the link to be a valid url', async () => {
@ -79,7 +80,6 @@ describe('my-social-media.vue', () => {
const expected = expect.objectContaining({
variables: { url: newSocialMediaUrl },
})
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
@ -88,10 +88,10 @@ describe('my-social-media.vue', () => {
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('clears the form', async () => {
it('switches back to list', async () => {
await flushPromises()
expect(input.value).toBe(undefined)
expect(submitButton.vm.$attrs.disabled).toBe(true)
const submitButton = wrapper.find('.base-button[data-test="add-save-button"]')
expect(submitButton.text()).not.toContain('settings.social-media.submit')
})
})
})
@ -100,10 +100,9 @@ describe('my-social-media.vue', () => {
beforeEach(() => {
getters = {
'auth/user': () => ({
socialMedia: [{ id: 's1', url: socialMediaUrl }],
socialMedia: [{ id: 's1', url: socialMediaUrl, favicon: faviconUrl }],
}),
}
wrapper = Wrapper()
form = wrapper.find('form')
})
@ -116,18 +115,12 @@ describe('my-social-media.vue', () => {
it('displays the url', () => {
expect(wrapper.find(`a[href="${socialMediaUrl}"]`).exists()).toBe(true)
})
it('displays the edit button', () => {
expect(wrapper.find('.base-button[data-test="edit-button"]').exists()).toBe(true)
})
it('displays the delete button', () => {
expect(wrapper.find('.base-button[data-test="delete-button"]').exists()).toBe(true)
})
})
it('does not accept a duplicate url', async () => {
wrapper.find('input#addSocialMedia').setValue(socialMediaUrl)
form.trigger('submit')
await Vue.nextTick()
wrapper.find('input#editSocialMedia').setValue(socialMediaUrl)
form.trigger('submit')
await Vue.nextTick()
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
@ -141,12 +134,6 @@ describe('my-social-media.vue', () => {
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', async () => {
const expected = expect.objectContaining({
variables: { id: 's1', url: newSocialMediaUrl },
@ -156,13 +143,6 @@ describe('my-social-media.vue', () => {
await Vue.nextTick()
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('allows the user to cancel editing', async () => {
const cancelButton = wrapper.find('button#cancel')
cancelButton.trigger('click')
await Vue.nextTick()
expect(wrapper.find('input#editSocialMedia').exists()).toBe(false)
})
})
describe('deleting social media link', () => {
@ -176,7 +156,6 @@ describe('my-social-media.vue', () => {
const expected = expect.objectContaining({
variables: { id: 's1' },
})
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})

View File

@ -1,97 +1,73 @@
<template>
<ds-form
v-model="formData"
:schema="formSchema"
@input="handleInput"
@input-valid="handleInputValid"
@submit="handleSubmitSocialMedia"
>
<base-card>
<h2 class="title">{{ $t('settings.social-media.name') }}</h2>
<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>
<base-button
icon="edit"
circle
ghost
@click="handleEditSocialMedia(link)"
:title="$t('actions.edit')"
data-test="edit-button"
/>
<base-button
icon="trash"
circle
ghost
@click="handleDeleteSocialMedia(link)"
:title="$t('actions.delete')"
data-test="delete-button"
/>
</template>
</ds-list-item>
</ds-list>
</ds-space>
<ds-space margin-top="base">
<base-card>
<ds-heading tag="h2" class="title">{{ $t('settings.social-media.name') }}</ds-heading>
<my-something-list
:useFormData="useFormData"
:useFormSchema="useFormSchema"
:useItems="socialMediaLinks"
:defaultItem="{ url: '' }"
:namePropertyKey="'url'"
:callbacks="{
handleInput: () => {},
handleInputValid,
edit: callbackEditSocialMedia,
submit: handleSubmitSocialMedia,
delete: callbackDeleteSocialMedia,
}"
>
<template #list-item="{ item }">
<social-media-list-item :item="item" />
</template>
<template #edit-item>
<ds-input
v-if="!editingLink.id"
id="addSocialMedia"
id="editSocialMedia"
model="socialMediaUrl"
type="text"
:placeholder="$t('settings.social-media.placeholder')"
/>
<ds-space margin-top="base">
<base-button filled :disabled="disabled" type="submit">
{{ editingLink.id ? $t('actions.save') : $t('settings.social-media.submit') }}
</base-button>
<base-button v-if="editingLink.id" id="cancel" danger @click="handleCancel()">
{{ $t('actions.cancel') }}
</base-button>
</ds-space>
</ds-space>
</base-card>
</ds-form>
</template>
</my-something-list>
</base-card>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
import unionBy from 'lodash/unionBy'
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex'
import MySomethingList from '~/components/_new/features/MySomethingList/MySomethingList.vue'
import SocialMediaListItem from '~/components/_new/features/SocialMedia/SocialMediaListItem.vue'
export default {
components: {
MySomethingList,
SocialMediaListItem,
},
data() {
return {
formData: {
useFormData: {
socialMediaUrl: '',
},
formSchema: {
useFormSchema: {
socialMediaUrl: {
type: 'url',
message: this.$t('common.validations.url'),
},
},
disabled: true,
editingLink: {},
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
currentSocialMediaLinks() {
const domainRegex = /^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g
const { socialMedia = [] } = this.currentUser
return socialMedia.map(({ id, url }) => {
const [domain] = url.match(domainRegex) || []
const favicon = domain ? `${domain}/favicon.ico` : null
return { id, url, favicon }
})
},
socialMediaLinks() {
const domainRegex = /^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g
const { socialMedia = [] } = this.currentUser
@ -106,28 +82,83 @@ export default {
...mapMutations({
setCurrentUser: 'auth/SET_USER',
}),
handleCancel() {
this.editingLink = {}
this.formData.socialMediaUrl = ''
this.disabled = true
},
handleEditSocialMedia(link) {
this.editingLink = link
this.formData.socialMediaUrl = link.url
},
handleInput(data) {
this.disabled = true
},
handleInputValid(data) {
handleInputValid(thisList, data) {
if (data.socialMediaUrl.length < 1) {
this.disabled = true
thisList.disabled = true
} else {
this.disabled = false
thisList.disabled = false
}
},
async handleDeleteSocialMedia(link) {
callbackEditSocialMedia(thisList, link) {
thisList.formData.socialMediaUrl = link.url
// try to set focus on link edit field
// thisList.$refs.socialMediaUrl.$el.focus()
// !!! Check for existenz
// this.$scopedSlots.default()[0].context.$refs
// thisList.$scopedSlots['edit-item']()[0].$el.focus()
// console.log(thisList.$scopedSlots['edit-item']()[0].context.$refs)
// console.log(thisList.$scopedSlots['edit-item']()[0].context.$refs)
// console.log(thisList.$refs)
},
async handleSubmitSocialMedia(thisList, isCreation, item, formData) {
item.url = formData.socialMediaUrl
const items = this.socialMediaLinks
const duplicateUrl = items.find((eleItem) => eleItem.url === item.url)
if (duplicateUrl && duplicateUrl.id !== item.id) {
return thisList.$toast.error(thisList.$t('settings.social-media.requireUnique'))
}
let mutation, variables, successMessage
if (isCreation) {
mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
variables = { url: item.url }
successMessage = thisList.$t('settings.social-media.successAdd')
} else {
mutation = gql`
mutation($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
variables = { id: item.id, url: item.url }
successMessage = thisList.$t('settings.data.success')
}
try {
await this.$apollo.mutate({
await thisList.$apollo.mutate({
mutation,
variables,
update: (_store, { data }) => {
const newSocialMedia = !isCreation ? data.UpdateSocialMedia : data.CreateSocialMedia
this.setCurrentUser({
...this.currentUser,
socialMedia: unionBy([newSocialMedia], this.currentUser.socialMedia, 'id'),
})
},
})
thisList.$toast.success(successMessage)
return true
} catch (err) {
thisList.$toast.error(err.message)
return false
}
},
async callbackDeleteSocialMedia(thisList, item) {
try {
await thisList.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
@ -137,11 +168,11 @@ export default {
}
`,
variables: {
id: link.id,
id: item.id,
},
update: (store, { data }) => {
const socialMedia = this.currentUser.socialMedia.filter(
(element) => element.id !== link.id,
(element) => element.id !== item.id,
)
this.setCurrentUser({
...this.currentUser,
@ -150,87 +181,11 @@ export default {
},
})
this.$toast.success(this.$t('settings.social-media.successDelete'))
thisList.$toast.success(thisList.$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)
thisList.$toast.error(err.message)
}
},
},
}
</script>
<style lang="scss">
.divider {
opacity: 0.4;
padding: 0 $space-small;
}
.icon-button {
cursor: pointer;
}
.list-item--high {
.ds-list-item-prefix {
align-self: center;
}
.ds-list-item-content {
display: flex;
align-items: center;
}
}
</style>

View File

@ -21,18 +21,30 @@ export default ({ app, req, cookie, store }) => {
const changeHandler = async (mutation) => {
if (process.server) return
const newLocale = mutation.payload.locale
const currentLocale = await app.$cookies.get(key)
const isDifferent = newLocale !== currentLocale
const localeInStore = mutation.payload.locale
let cookieExists = true
let localeInCookies = await app.$cookies.get(key)
if (!localeInCookies) {
cookieExists = false
localeInCookies = navigator.language.split('-')[0] // get browser language
}
const isLocaleStoreSameAsCookies = localeInStore === localeInCookies
if (!isDifferent) {
// cookie has to be set, otherwise Cypress test does not work
if (cookieExists && isLocaleStoreSameAsCookies) {
return
}
app.$cookies.set(key, newLocale)
if (!app.$i18n.localeExists(newLocale)) {
import(`~/locales/${newLocale}.json`).then((res) => {
app.$i18n.add(newLocale, res.default)
const expires = new Date()
expires.setDate(expires.getDate() + app.$env.COOKIE_EXPIRE_TIME)
app.$cookies.set(key, localeInStore, {
expires,
// maxAge: app.$env.COOKIE_EXPIRE_TIME * 60 * 60 * 24, // days to seconds
sameSite: 'lax', // for the meaning see https://www.thinktecture.com/de/identity/samesite/samesite-in-a-nutshell/
})
if (!app.$i18n.localeExists(localeInStore)) {
import(`~/locales/${localeInStore}.json`).then((res) => {
app.$i18n.add(localeInStore, res.default)
})
}
@ -42,7 +54,7 @@ export default ({ app, req, cookie, store }) => {
if (user && user._id && token) {
// TODO: SAVE LOCALE
// store.dispatch('usersettings/patch', {
// uiLanguage: newLocale
// uiLanguage: localeInStore
// }, { root: true })
}
}

View File

@ -1,14 +1,13 @@
import Vue from 'vue'
import Vuex from 'vuex'
import faker from 'faker'
import vuexI18n from 'vuex-i18n/dist/vuex-i18n.umd.js'
import Styleguide from '@human-connection/styleguide'
import faker from '@faker-js/faker'
import Filters from '~/plugins/vue-filters'
import Directives from '~/plugins/vue-directives'
import IziToast from '~/plugins/izi-toast'
import layout from './layout.vue'
import locales from '~/locales/index.js'
import '~/plugins/v-tooltip'
const helpers = {

View File

@ -2379,6 +2379,11 @@
ts-node "^8"
tslib "^1"
"@faker-js/faker@5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.1.0.tgz#cee1d77ada0d0dbbe77201d18b1ebabf432d9c0f"
integrity sha512-0VonSKh7fBCqvY+V2FLN2ZW4pR4ZtWJalWmwSaiaB7yK7y4qp8vDfuaq9QdLjf/cdZGx3M7Wc4Q+x4fZHxI21Q==
"@hapi/address@2.x.x":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.0.0.tgz#9f05469c88cb2fd3dcd624776b54ee95c312126a"
@ -10235,11 +10240,6 @@ fake-tag@^1.0.0:
resolved "https://registry.yarnpkg.com/fake-tag/-/fake-tag-1.0.1.tgz#1d59da482240a02bd83500ca98976530ed154b0d"
integrity sha512-qmewZoBpa71mM+y6oxXYW/d1xOYQmeIvnEXAt1oCmdP0sqcogWYLepR87QL1jQVLSVMVYDq2cjY6ec/Wu8/4pg==
faker@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/faker/-/faker-5.1.0.tgz#e10fa1dec4502551aee0eb771617a7e7b94692e8"
integrity sha512-RrWKFSSA/aNLP0g3o2WW1Zez7/MnMr7xkiZmoCfAGZmdkDQZ6l2KtuXHN5XjdvpRjDl8+3vf+Rrtl06Z352+Mw==
fast-deep-equal@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"

View File

@ -1248,6 +1248,11 @@
debug "^3.1.0"
lodash.once "^4.1.1"
"@faker-js/faker@5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.1.0.tgz#cee1d77ada0d0dbbe77201d18b1ebabf432d9c0f"
integrity sha512-0VonSKh7fBCqvY+V2FLN2ZW4pR4ZtWJalWmwSaiaB7yK7y4qp8vDfuaq9QdLjf/cdZGx3M7Wc4Q+x4fZHxI21Q==
"@hapi/address@2.x.x":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.0.0.tgz#9f05469c88cb2fd3dcd624776b54ee95c312126a"
@ -3058,10 +3063,6 @@ extsprintf@^1.2.0:
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
faker@Marak/faker.js#master:
version "5.1.0"
resolved "https://codeload.github.com/Marak/faker.js/tar.gz/91dc8a3372426bc691be56153b33e81a16459f49"
fast-deep-equal@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
@ -5274,10 +5275,10 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
hash-base "^3.0.0"
inherits "^2.0.1"
rosie@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/rosie/-/rosie-2.0.1.tgz#c250c4787ce450b72aa9eff26509f68589814fa2"
integrity sha1-wlDEeHzkULcqqe/yZQn2hYmBT6I=
rosie@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/rosie/-/rosie-2.1.0.tgz#0213a9d2b0401a2549cbce5f1cd914caffa22358"
integrity sha512-Dbzdc+prLXZuB/suRptDnBUY29SdGvND3bLg6cll8n7PNqzuyCxSlRfrkn8PqjS9n4QVsiM7RCvxCkKAkTQRjA==
rxjs@^6.3.3, rxjs@^6.6.3:
version "6.6.7"