Merge pull request #237 from Human-Connection/236-list-social-media-accounts

List socialMedia links
This commit is contained in:
Robert Schäfer 2019-04-15 12:07:10 +02:00 committed by GitHub
commit 73f2ff59e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 440 additions and 5 deletions

View File

@ -10,8 +10,8 @@
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,graphql",
"lint": "eslint src --config .eslintrc.js",
"test": "run-s test:jest test:cucumber",
"test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev",
"test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev",
"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",
"test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand",
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
"test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand",
@ -94,4 +94,4 @@
"nodemon": "~1.18.11",
"supertest": "~4.0.2"
}
}
}

View File

@ -7,6 +7,7 @@ import reports from './resolvers/reports.js'
import posts from './resolvers/posts.js'
import moderation from './resolvers/moderation.js'
import rewards from './resolvers/rewards.js'
import socialMedia from './resolvers/socialMedia.js'
import notifications from './resolvers/notifications'
export const typeDefs = fs
@ -27,6 +28,7 @@ export const resolvers = {
...posts.Mutation,
...moderation.Mutation,
...rewards.Mutation,
...socialMedia.Mutation,
...notifications.Mutation
}
}

View File

@ -10,12 +10,14 @@ import permissionsMiddleware from './permissionsMiddleware'
import userMiddleware from './userMiddleware'
import includedFieldsMiddleware from './includedFieldsMiddleware'
import orderByMiddleware from './orderByMiddleware'
import validUrlMiddleware from './validUrlMiddleware'
import notificationsMiddleware from './notificationsMiddleware'
export default schema => {
let middleware = [
passwordMiddleware,
dateTimeMiddleware,
validUrlMiddleware,
sluggifyMiddleware,
excerptMiddleware,
xssMiddleware,

View File

@ -74,6 +74,7 @@ const permissions = shield({
UpdateBadge: isAdmin,
DeleteBadge: isAdmin,
AddUserBadges: isAdmin,
CreateSocialMedia: isAuthenticated,
// AddBadgeRewarded: isAdmin,
// RemoveBadgeRewarded: isAdmin,
reward: isAdmin,

View File

@ -0,0 +1,18 @@
const validURL = str => {
const isValid = str.match(/^(?:https?:\/\/)(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
return !!isValid
}
export default {
Mutation: {
CreateSocialMedia: async (resolve, root, args, context, info) => {
let socialMedia
if (validURL(args.url)) {
socialMedia = await resolve(root, args, context, info)
} else {
throw Error('Input is not a URL')
}
return socialMedia
}
}
}

View File

@ -0,0 +1,21 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
export default {
Mutation: {
CreateSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, true)
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()
return socialMedia
}
}
}

View File

@ -0,0 +1,49 @@
import Factory from '../seed/factories'
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../jest/helpers'
const factory = Factory()
describe('CreateSocialMedia', () => {
let client
let headers
const mutation = `
mutation($url: String!) {
CreateSocialMedia(url: $url) {
url
}
}
`
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'
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('authenticated', () => {
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
it('rejects empty string', async () => {
const variables = { url: '' }
await expect(client.request(mutation, variables)).rejects.toThrow('Input is not a URL')
})
it('validates URLs', async () => {
const variables = { url: 'not-a-url' }
await expect(client.request(mutation, variables)).rejects.toThrow('Input is not a URL')
})
})
})

View File

@ -128,6 +128,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")
createdAt: String
updatedAt: String
@ -318,3 +319,10 @@ type SharedInboxEndpoint {
id: ID!
uri: String
}
type SocialMedia {
id: ID!
url: String
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
}

View File

@ -61,3 +61,52 @@ Then(
'I can see my new name {string} when I click on my profile picture in the top right',
name => matchNameInUserMenu(name)
)
When('I click on the {string} link', link => {
cy.get('a')
.contains(link)
.click()
})
Then('I should be on the {string} page', page => {
cy.location()
.should(loc => {
expect(loc.pathname).to.eq(page)
})
.get('h3')
.should('contain', 'Social media')
})
Then('I add a social media link', () => {
cy.get("input[name='social-media']")
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Add link')
.click()
})
Then('it gets saved successfully', () => {
cy.get('.iziToast-message')
.should('contain', 'Updated user')
})
Then('the new social media link shows up on the page', () => {
cy.get('a[href="https://freeradical.zone/peter-pan"]')
.should('have.length', 1)
})
Given('I have added a social media link', () => {
cy.openPage('/settings/my-social-media')
.get("input[name='social-media']")
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Add link')
.click()
})
Then('they should be able to see my social media links', () => {
cy.get('.ds-card-content')
.contains('Where else can I find Peter Pan?')
.get('a[href="https://freeradical.zone/peter-pan"]')
.should('have.length', 1)
})

View File

@ -0,0 +1,21 @@
Feature: List Social Media Accounts
As a User
I'd like to enter my social media
So I can show them to other users to get in contact
Background:
Given I have a user account
And I am logged in
Scenario: Adding 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
When I add a social media link
Then it gets saved successfully
And the new social media link shows up on the page
Scenario: Other user's 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

View File

@ -98,6 +98,10 @@ export default app => {
}
}
}
socialMedia {
id
url
}
}
}
`)

View File

@ -15,7 +15,8 @@
"followers": "Folgen",
"following": "Folgt",
"shouted": "Empfohlen",
"commented": "Kommentiert"
"commented": "Kommentiert",
"socialMedia": "Wo sonst finde ich"
},
"search": {
"placeholder": "Suchen",
@ -51,6 +52,11 @@
},
"languages": {
"name": "Sprachen"
},
"social-media": {
"name": "Soziale Medien",
"submit": "Link hinzufügen",
"success": "Profil aktualisiert"
}
},
"admin": {

View File

@ -15,7 +15,8 @@
"followers": "Followers",
"following": "Following",
"shouted": "Shouted",
"commented": "Commented"
"commented": "Commented",
"socialMedia": "Where else can I find"
},
"search": {
"placeholder": "Search",
@ -51,6 +52,11 @@
},
"languages": {
"name": "Languages"
},
"social-media": {
"name": "Social media",
"submit": "Add link",
"success": "Updated user profile"
}
},
"admin": {

View File

@ -202,6 +202,37 @@
</p>
</template>
</ds-card>
<ds-space
v-if="user.socialMedia && user.socialMedia.length"
margin="large"
>
<ds-card style="position: relative; height: auto;">
<ds-space
margin="x-small"
>
<ds-text
tag="h5"
color="soft"
>
{{ $t('profile.socialMedia') }} {{ user.name | truncate(15) }}?
</ds-text>
<template>
<ds-space
v-for="link in socialMediaLinks"
:key="link.username"
margin="x-small"
>
<a :href="link.url">
<ds-avatar
:image="link.favicon"
/>
{{ link.username }}
</a>
</ds-space>
</template>
</ds-space>
</ds-card>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: 3, md: 5, lg: 3 }">
<ds-flex
@ -348,6 +379,19 @@ export default {
return []
}
return this.uniq(this.user.contributions.filter(post => !post.deleted))
},
socialMediaLinks() {
const { socialMedia = [] } = this.user
return socialMedia.map(socialMedia => {
const { url } = socialMedia
const matches = url.match(
/^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:\/\n?]+)/g
)
const [domain] = matches || []
const favicon = domain ? `${domain}/favicon.ico` : null
const username = url.split('/').pop()
return { url, username, favicon }
})
}
},
watch: {

View File

@ -34,6 +34,10 @@ export default {
{
name: this.$t('settings.security.name'),
path: `/settings/security`
},
{
name: this.$t('settings.social-media.name'),
path: `/settings/my-social-media`
}
// TODO implement
/* {
@ -59,6 +63,7 @@ export default {
/* {
name: this.$t('settings.languages.name'),
path: `/settings/languages`
},
} */
]
}

View File

@ -0,0 +1,88 @@
import { mount, createLocalVue } from '@vue/test-utils'
import MySocialMedia from './my-social-media.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
describe('my-social-media.vue', () => {
let wrapper
let Wrapper
let store
let mocks
let getters
let input
let submitBtn
const socialMediaUrl = 'https://freeradical.zone/@mattwr18'
beforeEach(() => {
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest
.fn()
.mockRejectedValue({ message: 'Ouch!' })
.mockResolvedValueOnce({
data: { CreateSocialMeda: { id: 's1', url: socialMediaUrl } }
})
},
$toast: {
error: jest.fn(),
success: jest.fn()
}
}
getters = {
'auth/user': () => {
return {}
}
}
})
describe('mount', () => {
const Wrapper = () => {
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', () => {
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)
})
})
describe('currentUser does not have a social media account linked', () => {
it('allows a user to add a social media link', () => {
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)
})
})
})
})

View File

@ -0,0 +1,107 @@
<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.url"
>
<a :href="link.url">
<img
:src="link.favicon"
alt="Social Media link"
width="16"
height="16"
>
{{ link.url }}
</a>
</ds-list-item>
</ds-list>
</ds-space>
<div>
<ds-input
v-model="value"
placeholder="Add social media url"
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-space>
</ds-card>
</template>
<script>
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex'
export default {
data() {
return {
value: ''
}
},
computed: {
...mapGetters({
currentUser: 'auth/user'
}),
socialMediaLinks() {
const { socialMedia = [] } = this.currentUser
return socialMedia.map(socialMedia => {
const { url } = socialMedia
const matches = url.match(
/^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:\/\n?]+)/g
)
const [domain] = matches || []
const favicon = domain ? `${domain}/favicon.ico` : null
return { url, favicon }
})
}
},
methods: {
...mapMutations({
setCurrentUser: 'auth/SET_USER'
}),
handleAddSocialMedia() {
this.$apollo
.mutate({
mutation: gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
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.success')),
(this.value = '')
)
}
}
}
</script>

View File

@ -83,6 +83,10 @@ export const actions = {
role
about
locationName
socialMedia {
id
url
}
}
}`)
})