Merge branch 'master' of github.com:Human-Connection/Nitro-Web into 37-full-text-search-top-bar

This commit is contained in:
Robert Schäfer 2019-03-18 15:08:31 +01:00
commit 9987ad1c95
35 changed files with 1622 additions and 637 deletions

View File

@ -4,6 +4,22 @@
// Transition Easing
$easeOut: cubic-bezier(0.19, 1, 0.22, 1);
.disabled-content {
position: relative;
&::before {
@include border-radius($border-radius-x-large);
box-shadow: inset 0 0 0 5px $color-danger;
content: "";
display: block;
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}
}
.layout-enter-active {
transition: opacity 80ms ease-out;
transition-delay: 80ms;

View File

@ -1,184 +0,0 @@
<template>
<dropdown
:disabled="!hasAuthor || !showAuthorPopover"
placement="top-start"
offset="0"
>
<template
slot="default"
slot-scope="{openMenu, closeMenu, isOpen}"
>
<a
v-router-link
:href="author.slug ? $router.resolve({ name: 'profile-slug', params: { slug: author.slug } }).href : null"
:class="['author', isOpen && 'active']"
@mouseover="openMenu(true)"
@mouseleave="closeMenu(true)"
>
<div style="display: inline-block; float: left; margin-right: 4px; height: 100%; vertical-align: middle;">
<ds-avatar
:image="author.avatar"
:name="author.name"
style="display: inline-block; vertical-align: middle;"
size="32px"
/>
</div>
<div style="display: inline-block; height: 100%; vertical-align: middle;">
<b
class="username"
style="vertical-align: middle;"
>
{{ author.name | truncate(trunc, 18) }}
</b>
<template v-if="post.createdAt">
<br>
<ds-text
size="small"
color="soft"
>
{{ post.createdAt | dateTime('dd. MMMM yyyy HH:mm') }}
</ds-text>
</template>
</div>
</a>
</template>
<template
slot="popover"
>
<div style="min-width: 250px">
<!--<ds-avatar
:image="author.avatar"
:name="author.name || 'Anonymus'"
class="profile-avatar"
size="90px" />-->
<hc-badges
v-if="author.badges && author.badges.length"
:badges="author.badges"
/>
<ds-text
v-if="author.location"
align="center"
color="soft"
size="small"
style="margin-top: 5px"
bold
>
<ds-icon name="map-marker" /> {{ author.location.name }}
</ds-text>
<ds-flex
style="margin-top: -10px"
>
<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">
<ds-number
:count="fanCount"
:label="$t('profile.followers')"
size="x-large"
/>
</ds-space>
</ds-flex-item>
<ds-flex-item class="ds-tab-nav-item ds-tab-nav-item-active">
<ds-space margin="small">
<ds-number
:count="author.contributionsCount"
:label="$t('common.post', null, author.contributionsCount)"
/>
</ds-space>
</ds-flex-item>
<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">
<ds-number
:count="author.commentsCount"
:label="$t('common.comment', null, author.commentsCount)"
/>
</ds-space>
</ds-flex-item>
</ds-flex>
<!--<ds-text
color="soft"
size="small">
<ds-icon name="map-marker" /> Hamburg, Deutschland
</ds-text>-->
<ds-flex
v-if="!itsMe"
gutter="x-small"
style="margin-bottom: 0;"
>
<ds-flex-item :width="{base: 3}">
<hc-follow-button
:follow-id="author.id"
:is-followed="author.followedByCurrentUser"
@optimistic="follow => author.followedByCurrentUser = follow"
@update="follow => author.followedByCurrentUser = follow"
/>
</ds-flex-item>
<ds-flex-item :width="{base: 1}">
<ds-button fullwidth>
<ds-icon name="user-times" />
</ds-button>
</ds-flex-item>
</ds-flex>
<!--<ds-space margin-bottom="x-small" />-->
</div>
</template>
</dropdown>
</template>
<script>
import HcFollowButton from '~/components/FollowButton.vue'
import HcBadges from '~/components/Badges.vue'
import Dropdown from '~/components/Dropdown'
export default {
name: 'HcAuthor',
components: {
HcFollowButton,
HcBadges,
Dropdown
},
props: {
post: { type: Object, default: null },
trunc: { type: Number, default: null },
showAuthorPopover: { type: Boolean, default: true }
},
computed: {
itsMe() {
return this.author.slug === this.$store.getters['auth/user'].slug
},
fanCount() {
let count = Number(this.author.followedByCount) || 0
return count
},
author() {
return this.hasAuthor
? this.post.author
: {
name: 'Anonymus'
}
},
hasAuthor() {
return Boolean(this.post && this.post.author)
}
}
}
</script>
<style lang="scss">
.profile-avatar {
display: block;
margin: auto;
margin-top: -45px;
border: #fff 5px solid;
}
.author {
white-space: nowrap;
position: relative;
display: flex;
align-items: center;
&:hover,
&.active {
z-index: 999;
}
}
</style>

View File

@ -0,0 +1,95 @@
import { config, shallowMount, mount, createLocalVue } from '@vue/test-utils'
import Comment from './Comment.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
config.stubs['no-ssr'] = '<span><slot /></span>'
describe('Comment.vue', () => {
let wrapper
let Wrapper
let propsData
let mocks
let getters
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn()
}
getters = {
'auth/user': () => {
return {}
},
'auth/isModerator': () => false
}
})
describe('shallowMount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters
})
return shallowMount(Comment, { store, propsData, mocks, localVue })
}
describe('given a comment', () => {
beforeEach(() => {
propsData.comment = {
id: '2',
contentExcerpt: 'Hello I am a comment content'
}
})
it('renders content', () => {
const wrapper = Wrapper()
expect(wrapper.text()).toMatch('Hello I am a comment content')
})
describe('which is disabled', () => {
beforeEach(() => {
propsData.comment.disabled = true
})
it('renders no comment data', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('comment content')
})
it('has no "disabled-content" css class', () => {
const wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('disabled-content')
})
it('translates a placeholder', () => {
const wrapper = Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['comment.content.unavailable-placeholder']]
expect(calls).toEqual(expect.arrayContaining(expected))
})
describe('for a moderator', () => {
beforeEach(() => {
getters['auth/isModerator'] = () => true
})
it('renders comment data', () => {
const wrapper = Wrapper()
expect(wrapper.text()).toMatch('comment content')
})
it('has a "disabled-content" css class', () => {
const wrapper = Wrapper()
expect(wrapper.classes()).toContain('disabled-content')
})
})
})
})
})
})

76
components/Comment.vue Normal file
View File

@ -0,0 +1,76 @@
<template>
<div v-if="(comment.deleted || comment.disabled) && !isModerator">
<ds-text
style="padding-left: 40px; font-weight: bold;"
color="soft"
>
<ds-icon name="ban" /> {{ this.$t('comment.content.unavailable-placeholder') }}
</ds-text>
</div>
<div
v-else
:class="{'comment': true, 'disabled-content': (comment.deleted || comment.disabled)}"
>
<ds-space
margin-bottom="x-small"
>
<hc-user :user="author" />
</ds-space>
<no-ssr>
<content-menu
placement="bottom-end"
resource-type="comment"
:resource="comment"
style="float-right"
:is-owner="isAuthor(author.id)"
/>
</no-ssr>
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<ds-space margin-bottom="small" />
<div
style="padding-left: 40px;"
v-html="comment.contentExcerpt"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import HcUser from '~/components/User.vue'
import ContentMenu from '~/components/ContentMenu'
export default {
components: {
HcUser,
ContentMenu
},
props: {
comment: {
type: Object,
default() {
return {}
}
}
},
computed: {
...mapGetters({
user: 'auth/user',
isModerator: 'auth/isModerator'
}),
displaysComment() {
return !this.unavailable || this.isModerator
},
author() {
if (this.deleted) return {}
return this.comment.author || {}
}
},
methods: {
isAuthor(id) {
return this.user.id === id
}
}
}
</script>

View File

@ -52,10 +52,9 @@ export default {
},
props: {
placement: { type: String, default: 'top-end' },
itemId: { type: String, required: true },
name: { type: String, required: true },
resource: { type: Object, required: true },
isOwner: { type: Boolean, default: false },
context: {
resourceType: {
type: String,
required: true,
validator: value => {
@ -67,19 +66,19 @@ export default {
routes() {
let routes = []
if (this.isOwner && this.context === 'contribution') {
if (this.isOwner && this.resourceType === 'contribution') {
routes.push({
name: this.$t(`contribution.edit`),
path: this.$router.resolve({
name: 'post-edit-id',
params: {
id: this.itemId
id: this.resource.id
}
}).href,
icon: 'edit'
})
}
if (this.isOwner && this.context === 'comment') {
if (this.isOwner && this.resourceType === 'comment') {
routes.push({
name: this.$t(`comment.edit`),
callback: () => {
@ -91,21 +90,25 @@ export default {
if (!this.isOwner) {
routes.push({
name: this.$t(`report.${this.context}.title`),
callback: this.openReportDialog,
name: this.$t(`report.${this.resourceType}.title`),
callback: () => {
this.openModal('report')
},
icon: 'flag'
})
}
if (!this.isOwner && this.isModerator) {
routes.push({
name: this.$t(`disable.${this.context}.title`),
callback: () => {},
name: this.$t(`disable.${this.resourceType}.title`),
callback: () => {
this.openModal('disable')
},
icon: 'eye-slash'
})
}
if (this.isOwner && this.context === 'user') {
if (this.isOwner && this.resourceType === 'user') {
routes.push({
name: this.$t(`settings.data.name`),
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
@ -128,18 +131,14 @@ export default {
}
toggleMenu()
},
openReportDialog() {
openModal(dialog) {
this.$store.commit('modal/SET_OPEN', {
name: 'report',
name: dialog,
data: {
context: this.context,
id: this.itemId,
name: this.name
type: this.resourceType,
resource: this.resource
}
})
},
openDisableDialog() {
this.$toast.error('NOT IMPLEMENTED!')
}
}
}

124
components/Modal.spec.js Normal file
View File

@ -0,0 +1,124 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import Modal from './Modal.vue'
import DisableModal from './Modal/DisableModal.vue'
import ReportModal from './Modal/ReportModal.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import { getters, mutations } from '../store/modal'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
describe('Modal.vue', () => {
let Wrapper
let wrapper
let store
let state
let mocks
const createWrapper = mountMethod => {
return () => {
store = new Vuex.Store({
state,
getters: {
'modal/open': getters.open,
'modal/data': getters.data
},
mutations: {
'modal/SET_OPEN': mutations.SET_OPEN
}
})
return mountMethod(Modal, { store, mocks, localVue })
}
}
beforeEach(() => {
mocks = {
$filters: {
truncate: a => a
},
$toast: {
success: () => {},
error: () => {}
},
$t: () => {}
}
state = {
open: null,
data: {}
}
})
describe('shallowMount', () => {
const Wrapper = createWrapper(shallowMount)
it('initially empty', () => {
wrapper = Wrapper()
expect(wrapper.contains(DisableModal)).toBe(false)
expect(wrapper.contains(ReportModal)).toBe(false)
})
describe('store/modal holds data to disable', () => {
beforeEach(() => {
state = {
open: 'disable',
data: {
type: 'contribution',
resource: {
id: 'c456',
title: 'some title'
}
}
}
wrapper = Wrapper()
})
it('renders disable modal', () => {
expect(wrapper.contains(DisableModal)).toBe(true)
})
it('passes data to disable modal', () => {
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'contribution',
name: 'some title',
id: 'c456'
})
})
describe('child component emits close', () => {
it('turns empty', () => {
wrapper.find(DisableModal).vm.$emit('close')
expect(wrapper.contains(DisableModal)).toBe(false)
})
})
describe('store/modal data contains a comment', () => {
it('passes author name to disable modal', () => {
state.data = {
type: 'comment',
resource: { id: 'c456', author: { name: 'Author name' } }
}
wrapper = Wrapper()
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'comment',
name: 'Author name',
id: 'c456'
})
})
it('does not crash if author is undefined', () => {
state.data = { type: 'comment', resource: { id: 'c456' } }
wrapper = Wrapper()
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'comment',
name: '',
id: 'c456'
})
})
})
})
})
})

59
components/Modal.vue Normal file
View File

@ -0,0 +1,59 @@
<template>
<div class="modal-wrapper">
<disable-modal
v-if="open === 'disable'"
:id="data.resource.id"
:type="data.type"
:name="name"
@close="close"
/>
<report-modal
v-if="open === 'report'"
:id="data.resource.id"
:type="data.type"
:name="name"
@close="close"
/>
</div>
</template>
<script>
import DisableModal from '~/components/Modal/DisableModal'
import ReportModal from '~/components/Modal/ReportModal'
import { mapGetters } from 'vuex'
export default {
name: 'Modal',
components: {
DisableModal,
ReportModal
},
computed: {
...mapGetters({
data: 'modal/data',
open: 'modal/open'
}),
name() {
if (!this.data || !this.data.resource) return ''
const {
resource: { name, title, author }
} = this.data
switch (this.data.type) {
case 'user':
return name
case 'contribution':
return title
case 'comment':
return author && author.name
default:
return null
}
}
},
methods: {
close() {
this.$store.commit('modal/SET_OPEN', {})
}
}
}
</script>

View File

@ -0,0 +1,150 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import DisableModal from './DisableModal.vue'
import Vue from 'vue'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('DisableModal.vue', () => {
let store
let mocks
let propsData
let wrapper
beforeEach(() => {
propsData = {
type: 'contribution',
name: 'blah',
id: 'c42'
}
mocks = {
$filters: {
truncate: a => a
},
$toast: {
success: () => {},
error: () => {}
},
$t: jest.fn(),
$apollo: {
mutate: jest.fn().mockResolvedValue()
}
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(DisableModal, { propsData, mocks, localVue })
}
describe('given a user', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u2',
name: 'Bob Ross'
}
})
it('mentions user name', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['disable.user.message', { name: 'Bob Ross' }]]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
describe('given a contribution', () => {
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'c3',
name: 'This is some post title.'
}
})
it('mentions contribution title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [
['disable.contribution.message', { name: 'This is some post title.' }]
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(DisableModal, { propsData, mocks, localVue })
}
beforeEach(jest.useFakeTimers)
describe('given id', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u4711'
}
})
describe('click cancel button', () => {
beforeEach(async () => {
wrapper = Wrapper()
await wrapper.find('button.cancel').trigger('click')
})
it('does not emit "close" yet', () => {
expect(wrapper.emitted().close).toBeFalsy()
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('does not call mutation', () => {
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
})
describe('click confirm button', () => {
beforeEach(async () => {
wrapper = Wrapper()
await wrapper.find('button.confirm').trigger('click')
})
it('calls mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
it('passes id to mutation', () => {
const calls = mocks.$apollo.mutate.mock.calls
const [[{ variables }]] = calls
expect(variables).toEqual({ id: 'u4711' })
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
})
})
})
})

View File

@ -0,0 +1,83 @@
<template>
<ds-modal
:title="title"
:is-open="isOpen"
@cancel="cancel"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template slot="footer">
<ds-button
class="cancel"
@click="cancel"
>
{{ $t('disable.cancel') }}
</ds-button>
<ds-button
danger
class="confirm"
icon="exclamation-circle"
@click="confirm"
>
{{ $t('disable.submit') }}
</ds-button>
</template>
</ds-modal>
</template>
<script>
import gql from 'graphql-tag'
export default {
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
id: { type: String, required: true }
},
data() {
return {
isOpen: true,
success: false,
loading: false
}
},
computed: {
title() {
return this.$t(`disable.${this.type}.title`)
},
message() {
const name = this.$filters.truncate(this.name, 30)
return this.$t(`disable.${this.type}.message`, { name })
}
},
methods: {
cancel() {
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
},
async confirm() {
try {
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
disable(id: $id)
}
`,
variables: { id: this.id }
})
this.$toast.success(this.$t('disable.success'))
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
} catch (err) {
this.$toast.error(err.message)
}
}
}
}
</script>

View File

@ -0,0 +1,168 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import ReportModal from './ReportModal.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('ReportModal.vue', () => {
let wrapper
let Wrapper
let propsData
let mocks
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'c43'
}
mocks = {
$t: jest.fn(),
$filters: {
truncate: a => a
},
$toast: {
success: () => {},
error: () => {}
},
$apollo: {
mutate: jest.fn().mockResolvedValue()
}
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(ReportModal, { propsData, mocks, localVue })
}
describe('defaults', () => {
it('success false', () => {
expect(Wrapper().vm.success).toBe(false)
})
it('loading false', () => {
expect(Wrapper().vm.loading).toBe(false)
})
})
describe('given a user', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u4',
name: 'Bob Ross'
}
})
it('mentions user name', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['report.user.message', { name: 'Bob Ross' }]]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
describe('given a post', () => {
beforeEach(() => {
propsData = {
id: 'p23',
type: 'post',
name: 'It is a post'
}
})
it('mentions post title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['report.post.message', { name: 'It is a post' }]]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(ReportModal, { propsData, mocks, localVue })
}
beforeEach(jest.useFakeTimers)
it('renders', () => {
expect(Wrapper().is('div')).toBe(true)
})
describe('given id', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u4711'
}
wrapper = Wrapper()
})
describe('click cancel button', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.find('button.cancel').trigger('click')
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
it('emits "close"', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
it('does not call mutation', () => {
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
})
})
describe('click confirm button', () => {
beforeEach(() => {
wrapper.find('button.confirm').trigger('click')
})
it('calls report mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
it('sets success', () => {
expect(wrapper.vm.success).toBe(true)
})
it('displays a success message', () => {
const calls = mocks.$t.mock.calls
const expected = [['report.success']]
expect(calls).toEqual(expect.arrayContaining(expected))
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
it('resets success', () => {
expect(wrapper.vm.success).toBe(false)
})
})
})
})
})
})

View File

@ -0,0 +1,127 @@
<template>
<ds-modal
:title="title"
:is-open="isOpen"
@cancel="cancel"
>
<transition name="ds-transition-fade">
<ds-flex
v-if="success"
class="hc-modal-success"
centered
>
<sweetalert-icon icon="success" />
</ds-flex>
</transition>
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template
slot="footer"
>
<ds-button
class="cancel"
icon="close"
@click="cancel"
>
{{ $t('report.cancel') }}
</ds-button>
<ds-button
danger
class="confirm"
icon="exclamation-circle"
:loading="loading"
@click="confirm"
>
{{ $t('report.submit') }}
</ds-button>
</template>
</ds-modal>
</template>
<script>
import gql from 'graphql-tag'
import { SweetalertIcon } from 'vue-sweetalert-icons'
export default {
name: 'ReportModal',
components: {
SweetalertIcon
},
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
id: { type: String, required: true }
},
data() {
return {
isOpen: true,
success: false,
loading: false
}
},
computed: {
title() {
return this.$t(`report.${this.type}.title`)
},
message() {
const name = this.$filters.truncate(this.name, 30)
return this.$t(`report.${this.type}.message`, { name })
}
},
methods: {
async cancel() {
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
},
async confirm() {
this.loading = true
try {
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
report(id: $id) {
id
}
}
`,
variables: { id: this.id }
})
this.success = true
this.$toast.success(this.$t('report.success'))
setTimeout(() => {
this.isOpen = false
setTimeout(() => {
this.success = false
this.$emit('close')
}, 500)
}, 1500)
} catch (err) {
this.success = false
this.$toast.error(err.message)
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss">
.hc-modal-success {
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: #fff;
opacity: 1;
z-index: $z-index-modal;
border-radius: $border-radius-x-large;
}
</style>

View File

@ -2,7 +2,7 @@
<ds-card
:header="post.title"
:image="post.image"
class="post-card"
:class="{'post-card': true, 'disabled-content': post.disabled}"
>
<a
v-router-link
@ -20,16 +20,25 @@
/>
</ds-space>
<!-- eslint-enable vue/no-v-html -->
<ds-space>
<ds-text
v-if="post.createdAt"
align="right"
size="small"
color="soft"
>
{{ post.createdAt | dateTime('dd. MMMM yyyy HH:mm') }}
</ds-text>
</ds-space>
<ds-space
margin="small"
style="position: absolute; bottom: 44px; z-index: 1;"
>
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<hc-author
:post="post"
<hc-user
:user="post.author"
:trunc="35"
:show-author-popover="showAuthorPopover"
/>
</no-ssr>
</ds-space>
@ -52,9 +61,8 @@
</span>
<no-ssr>
<content-menu
context="contribution"
:item-id="post.id"
:name="post.title"
resource-type="contribution"
:resource="post"
:is-owner="isAuthor"
/>
</no-ssr>
@ -64,24 +72,20 @@
</template>
<script>
import HcAuthor from '~/components/Author.vue'
import HcUser from '~/components/User.vue'
import ContentMenu from '~/components/ContentMenu'
import { randomBytes } from 'crypto'
export default {
name: 'HcPostCard',
components: {
HcAuthor,
HcUser,
ContentMenu
},
props: {
post: {
type: Object,
required: true
},
showAuthorPopover: {
type: Boolean,
default: true
}
},
computed: {
@ -126,6 +130,7 @@ export default {
z-index: 1;
}
}
.post-link {
display: block;
position: absolute;

View File

@ -1,23 +0,0 @@
<script>
import HcAuthor from '~/components/Author.vue'
let mouseEnterTimer = null
let mouseLeaveTimer = null
export default {
name: 'HcRelatedUser',
extends: HcAuthor,
computed: {
author() {
return this.hasAuthor
? this.post
: {
name: 'Anonymus'
}
},
hasAuthor() {
return Boolean(this.post)
}
}
}
</script>

View File

@ -1,144 +0,0 @@
<template>
<ds-modal
:title="title"
:is-open="isOpen"
:confirm-label="$t('report.submit')"
:cancel-label="$t('report.cancel')"
confrim-icon="warning"
@confirm="report"
@cancel="close"
>
<transition name="ds-transition-fade">
<ds-flex
v-if="success"
class="hc-modal-success"
centered
>
<sweetalert-icon icon="success" />
</ds-flex>
</transition>
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template
slot="footer"
slot-scope="{ cancel, confirm, cancelLabel, confirmLabel }"
>
<ds-button
ghost
icon="close"
:disabled="disabled || loading"
@click.prevent="cancel('cancel')"
>
{{ cancelLabel }}
</ds-button>
<ds-button
danger
icon="exclamation-circle"
:loading="loading"
:disabled="disabled || loading"
@click.prevent="confirm('confirm')"
>
{{ confirmLabel }}
</ds-button>
</template>
</ds-modal>
</template>
<script>
import gql from 'graphql-tag'
import { SweetalertIcon } from 'vue-sweetalert-icons'
export default {
name: 'ReportModal',
components: {
SweetalertIcon
},
data() {
return {
success: false,
loading: false,
disabled: false
}
},
computed: {
data() {
return this.$store.getters['modal/data'] || {}
},
title() {
if (!this.data.context) return ''
return this.$t(`report.${this.data.context}.title`)
},
message() {
if (!this.data.context) return ''
return this.$t(`report.${this.data.context}.message`, { name: this.name })
},
name() {
return this.$filters.truncate(this.data.name, 30)
},
isOpen() {
return this.$store.getters['modal/open'] === 'report'
}
},
watch: {
isOpen(open) {
if (open) {
this.success = false
this.disabled = false
this.loading = false
}
}
},
methods: {
close() {
this.$store.commit('modal/SET_OPEN', {})
},
report() {
this.loading = true
this.disabled = true
this.$apollo
.mutate({
mutation: gql`
mutation($id: ID!, $description: String) {
report(id: $id, description: $description) {
id
}
}
`,
variables: {
id: this.data.id,
description: '-'
}
})
.then(() => {
this.success = true
this.$toast.success('Thanks for reporting!')
setTimeout(this.close, 1500)
})
.catch(err => {
this.$toast.error(err.message)
this.disabled = false
})
.finally(() => {
this.loading = false
})
}
}
}
</script>
<style lang="scss">
.hc-modal-success {
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: #fff;
opacity: 1;
z-index: $z-index-modal;
border-radius: $border-radius-x-large;
}
</style>

102
components/User.spec.js Normal file
View File

@ -0,0 +1,102 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import User from './User.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import VTooltip from 'v-tooltip'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
const filter = jest.fn(str => str)
localVue.use(Vuex)
localVue.use(VTooltip)
localVue.use(Styleguide)
localVue.filter('truncate', filter)
describe('User.vue', () => {
let wrapper
let Wrapper
let propsData
let mocks
let stubs
let getters
let user
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn()
}
stubs = {
NuxtLink: RouterLinkStub
}
getters = {
'auth/user': () => {
return {}
},
'auth/isModerator': () => false
}
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters
})
return mount(User, { store, propsData, mocks, stubs, localVue })
}
it('renders anonymous user', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton')
expect(wrapper.text()).toMatch('Anonymus')
})
describe('given an user', () => {
beforeEach(() => {
propsData.user = {
name: 'Tilda Swinton',
slug: 'tilda-swinton'
}
})
it('renders user name', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Anonymous')
expect(wrapper.text()).toMatch('Tilda Swinton')
})
describe('user is disabled', () => {
beforeEach(() => {
propsData.user.disabled = true
})
it('renders anonymous user', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton')
expect(wrapper.text()).toMatch('Anonymus')
})
describe('current user is a moderator', () => {
beforeEach(() => {
getters['auth/isModerator'] = () => true
})
it('renders user name', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Anonymous')
expect(wrapper.text()).toMatch('Tilda Swinton')
})
it('has "disabled-content" class', () => {
const wrapper = Wrapper()
expect(wrapper.classes()).toContain('disabled-content')
})
})
})
})
})
})

182
components/User.vue Normal file
View File

@ -0,0 +1,182 @@
<template>
<div v-if="!user || ((user.disabled || user.deleted) && !isModerator)">
<div style="display: inline-block; float: left; margin-right: 4px; height: 100%; vertical-align: middle;">
<ds-avatar
style="display: inline-block; vertical-align: middle;"
size="32px"
/>
</div>
<div style="display: inline-block; height: 100%; vertical-align: middle;">
<b
class="username"
style="vertical-align: middle;"
>
Anonymus
</b>
</div>
</div>
<dropdown
v-else
:class="{'disabled-content': user.disabled}"
placement="top-start"
offset="0"
>
<template
slot="default"
slot-scope="{openMenu, closeMenu, isOpen}"
>
<nuxt-link
:to="userLink"
:class="['user', isOpen && 'active']"
>
<div
@mouseover="openMenu(true)"
@mouseleave="closeMenu(true)"
>
<div style="display: inline-block; float: left; margin-right: 4px; height: 100%; vertical-align: middle;">
<ds-avatar
:image="user.avatar"
:name="user.name"
style="display: inline-block; vertical-align: middle;"
size="32px"
/>
</div>
<div style="display: inline-block; height: 100%; vertical-align: middle;">
<b
class="username"
style="vertical-align: middle;"
>
{{ user.name | truncate(trunc, 18) }}
</b>
</div>
</div>
</nuxt-link>
</template>
<template
slot="popover"
>
<div style="min-width: 250px">
<hc-badges
v-if="user.badges && user.badges.length"
:badges="user.badges"
/>
<ds-text
v-if="user.location"
align="center"
color="soft"
size="small"
style="margin-top: 5px"
bold
>
<ds-icon name="map-marker" /> {{ user.location.name }}
</ds-text>
<ds-flex
style="margin-top: -10px"
>
<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">
<ds-number
:count="fanCount"
:label="$t('profile.followers')"
size="x-large"
/>
</ds-space>
</ds-flex-item>
<ds-flex-item class="ds-tab-nav-item ds-tab-nav-item-active">
<ds-space margin="small">
<ds-number
:count="user.contributionsCount"
:label="$t('common.post', null, user.contributionsCount)"
/>
</ds-space>
</ds-flex-item>
<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">
<ds-number
:count="user.commentsCount"
:label="$t('common.comment', null, user.commentsCount)"
/>
</ds-space>
</ds-flex-item>
</ds-flex>
<ds-flex
v-if="!itsMe"
gutter="x-small"
style="margin-bottom: 0;"
>
<ds-flex-item :width="{base: 3}">
<hc-follow-button
:follow-id="user.id"
:is-followed="user.followedByCurrentUser"
@optimistic="follow => user.followedByCurrentUser = follow"
@update="follow => user.followedByCurrentUser = follow"
/>
</ds-flex-item>
<ds-flex-item :width="{base: 1}">
<ds-button fullwidth>
<ds-icon name="user-times" />
</ds-button>
</ds-flex-item>
</ds-flex>
<!--<ds-space margin-bottom="x-small" />-->
</div>
</template>
</dropdown>
</template>
<script>
import HcFollowButton from '~/components/FollowButton.vue'
import HcBadges from '~/components/Badges.vue'
import Dropdown from '~/components/Dropdown'
import { mapGetters } from 'vuex'
export default {
name: 'HcUser',
components: {
HcFollowButton,
HcBadges,
Dropdown
},
props: {
user: { type: Object, default: null },
trunc: { type: Number, default: null }
},
computed: {
...mapGetters({
isModerator: 'auth/isModerator'
}),
itsMe() {
return this.user.slug === this.$store.getters['auth/user'].slug
},
fanCount() {
let count = Number(this.user.followedByCount) || 0
return count
},
userLink() {
const { slug } = this.user
if (!slug) return ''
return { name: 'profile-slug', params: { slug } }
}
}
}
</script>
<style lang="scss">
.profile-avatar {
display: block;
margin: auto;
margin-top: -45px;
border: #fff 5px solid;
}
.user {
white-space: nowrap;
position: relative;
display: flex;
align-items: center;
&:hover,
&.active {
z-index: 999;
}
}
</style>

View File

@ -64,7 +64,7 @@ When(
)
When('I click on the author', () => {
cy.get('a.author')
cy.get('a.user')
.first()
.click()
.wait(200)
@ -112,7 +112,7 @@ When(/^I confirm the reporting dialog .*:$/, message => {
cy.contains(message) // wait for element to become visible
cy.get('.ds-modal').within(() => {
cy.get('button')
.contains('Send Report')
.contains('Report')
.click()
})
})

View File

@ -155,18 +155,29 @@ When('I press {string}', label => {
})
Given('we have the following posts in our database:', table => {
table.hashes().forEach(({ Author, id, title, content }) => {
table.hashes().forEach(({ Author, ...postAttributes }) => {
const userAttributes = {
name: Author,
email: `${Author}@example.org`,
password: '1234'
}
postAttributes.deleted = Boolean(postAttributes.deleted)
const disabled = Boolean(postAttributes.disabled)
cy.factory()
.create('User', {
name: Author,
email: `${Author}@example.org`,
.create('User', userAttributes)
.authenticateAs(userAttributes)
.create('Post', postAttributes)
if (disabled) {
const moderatorParams = {
email: 'moderator@example.org',
role: 'moderator',
password: '1234'
})
.authenticateAs({
email: `${Author}@example.org`,
password: '1234'
})
.create('Post', { id, title, content })
}
cy.factory()
.create('User', moderatorParams)
.authenticateAs(moderatorParams)
.mutate('mutation($id: ID!) { disable(id: $id) }', postAttributes)
}
})
})
@ -216,3 +227,20 @@ Then('the post was saved successfully', () => {
cy.get('.ds-card-header > .ds-heading').should('contain', lastPost.title)
cy.get('.content').should('contain', lastPost.content)
})
Then(/^I should see only ([0-9]+) posts? on the landing page/, postCount => {
cy.get('.post-card').should('have.length', postCount)
})
Then('the first post on the landing page has the title:', title => {
cy.get('.post-card:first').should('contain', title)
})
Then(
'the page {string} returns a 404 error with a message:',
(route, message) => {
// TODO: how can we check HTTP codes with cypress?
cy.visit(route, { failOnStatusCode: false })
cy.get('.error').should('contain', message)
}
)

View File

@ -0,0 +1,26 @@
Feature: Hide Posts
As the moderator team
we'd like to be able to hide posts from the public
to enforce our network's code of conduct and/or legal regulations
Background:
Given we have the following posts in our database:
| id | title | deleted | disabled |
| p1 | This post should be visible | | |
| p2 | This post is disabled | | x |
| p3 | This post is deleted | x | |
Scenario: Disabled posts don't show up on the landing page
Given I am logged in with a "user" role
Then I should see only 1 post on the landing page
And the first post on the landing page has the title:
"""
This post should be visible
"""
Scenario: Visiting a disabled post's page should return 404
Given I am logged in with a "user" role
Then the page "/post/this-post-is-disabled" returns a 404 error with a message:
"""
This post could not be found
"""

View File

@ -34,6 +34,14 @@ Cypress.Commands.add(
}
)
Cypress.Commands.add(
'mutate',
{ prevSubject: true },
(factory, mutation, variables) => {
return factory.mutate(mutation, variables)
}
)
Cypress.Commands.add(
'authenticateAs',
{ prevSubject: true },

View File

@ -9,31 +9,59 @@ export default app => {
type
createdAt
submitter {
disabled
deleted
name
slug
}
user {
name
slug
disabled
deleted
disabledBy {
slug
name
}
}
comment {
contentExcerpt
author {
name
slug
disabled
deleted
}
post {
disabled
deleted
title
slug
}
disabledBy {
disabled
deleted
slug
name
}
}
post {
title
slug
disabled
deleted
author {
disabled
deleted
name
slug
}
disabledBy {
disabled
deleted
slug
name
}
}
}
}

View File

@ -9,6 +9,8 @@ export default app => {
name
avatar
about
disabled
deleted
locationName
location {
name: name${lang}
@ -28,6 +30,8 @@ export default app => {
name
slug
avatar
disabled
deleted
followedByCount
followedByCurrentUser
contributionsCount
@ -46,6 +50,8 @@ export default app => {
followedBy(first: 7) {
id
name
disabled
deleted
slug
avatar
followedByCount
@ -72,6 +78,8 @@ export default app => {
deleted
image
createdAt
disabled
deleted
categories {
id
name
@ -81,6 +89,8 @@ export default app => {
id
avatar
name
disabled
deleted
location {
name: name${lang}
}

View File

@ -105,10 +105,7 @@
</ds-container>
<div id="overlay" />
<no-ssr>
<portal-target name="modal" />
</no-ssr>
<no-ssr>
<report-modal />
<modal />
</no-ssr>
</div>
</template>
@ -118,15 +115,16 @@ import { mapGetters, mapActions } from 'vuex'
import LocaleSwitch from '~/components/LocaleSwitch'
import Dropdown from '~/components/Dropdown'
import SearchInput from '~/components/SearchInput.vue'
import ReportModal from '~/components/ReportModal'
import Modal from '~/components/Modal'
import seo from '~/components/mixins/seo'
export default {
components: {
Dropdown,
ReportModal,
LocaleSwitch,
SearchInput
SearchInput,
Modal,
LocaleSwitch
},
mixins: [seo],
data() {

View File

@ -136,10 +136,14 @@
"reports": {
"empty": "Glückwunsch, es gibt nichts zu moderieren.",
"name": "Meldungen",
"submitter": "gemeldet von"
"submitter": "gemeldet von",
"disabledBy": "deaktiviert von"
}
},
"disable": {
"submit": "Deaktivieren",
"cancel": "Abbrechen",
"success": "Erfolgreich deaktiviert",
"user": {
"title": "Nutzer sperren",
"type": "Nutzer",
@ -157,8 +161,9 @@
}
},
"report": {
"submit": "Meldung senden",
"submit": "Melden",
"cancel": "Abbrechen",
"success": "Vielen Dank für diese Meldung!",
"user": {
"title": "Nutzer melden",
"type": "Nutzer",
@ -181,7 +186,10 @@
},
"comment": {
"edit": "Kommentar bearbeiten",
"delete": "Kommentar löschen"
"delete": "Kommentar löschen",
"content": {
"unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar"
}
},
"followButton": {
"follow": "Folgen",
@ -190,4 +198,4 @@
"shoutButton": {
"shouted": "empfohlen"
}
}
}

View File

@ -136,10 +136,14 @@
"reports": {
"empty": "Congratulations, nothing to moderate.",
"name": "Reports",
"submitter": "reported by"
"submitter": "reported by",
"disabledBy": "disabled by"
}
},
"disable": {
"submit": "Disable",
"cancel": "Cancel",
"success": "Disabled successfully",
"user": {
"title": "Disable User",
"type": "User",
@ -157,8 +161,9 @@
}
},
"report": {
"submit": "Send Report",
"submit": "Report",
"cancel": "Cancel",
"success": "Thanks for reporting!",
"user": {
"title": "Report User",
"type": "User",
@ -181,7 +186,10 @@
},
"comment": {
"edit": "Edit Comment",
"delete": "Delete Comment"
"delete": "Delete Comment",
"content": {
"unavailable-placeholder": "...this comment is not available anymore"
}
},
"followButton": {
"follow": "Follow",
@ -190,4 +198,4 @@
"shoutButton": {
"shouted": "shouted"
}
}
}

View File

@ -133,7 +133,7 @@
"comment": {
"title": "Désactiver le commentaire",
"type": "Commentaire",
"message": "Souhaitez-vous vraiment désactiver le commentaire de \"<b>{nom}</b>\" ?"
"message": "Souhaitez-vous vraiment désactiver le commentaire de \"<b>{name}</b>\" ?"
}
},
"report": {
@ -166,4 +166,4 @@
"edit": "Rédiger un commentaire",
"delete": "Supprimer le commentaire"
}
}
}

View File

@ -109,8 +109,7 @@ module.exports = {
'cookie-universal-nuxt',
'@nuxtjs/apollo',
'@nuxtjs/axios',
'@nuxtjs/style-resources',
'portal-vue/nuxt'
'@nuxtjs/style-resources'
],
/*

View File

@ -54,10 +54,9 @@
"linkify-it": "~2.1.0",
"nuxt": "~2.4.5",
"nuxt-env": "~0.1.0",
"portal-vue": "~1.5.1",
"string-hash": "~1.1.3",
"tiptap": "~1.14.0",
"tiptap-extensions": "~1.14.0",
"string-hash": "^1.1.3",
"tiptap": "^1.14.0",
"tiptap-extensions": "^1.14.0",
"v-tooltip": "~2.0.0-rc.33",
"vue-count-to": "~1.0.13",
"vue-izitoast": "1.1.2",

View File

@ -97,6 +97,8 @@ export default {
title
contentExcerpt
createdAt
disabled
deleted
slug
image
author {
@ -104,6 +106,8 @@ export default {
avatar
slug
name
disabled
deleted
contributionsCount
shoutedCount
commentsCount

View File

@ -73,6 +73,29 @@
{{ scope.row.submitter.name }}
</nuxt-link>
</template>
<template
slot="disabledBy"
slot-scope="scope"
>
<nuxt-link
v-if="scope.row.type === 'Post' && scope.row.post.disabledBy"
:to="{ name: 'profile-slug', params: { slug: scope.row.post.disabledBy.slug } }"
>
<b>{{ scope.row.post.disabledBy.name | truncate(50) }}</b>
</nuxt-link>
<nuxt-link
v-else-if="scope.row.type === 'Comment' && scope.row.comment.disabledBy"
:to="{ name: 'profile-slug', params: { slug: scope.row.comment.disabledBy.slug } }"
>
<b>{{ scope.row.comment.disabledBy.name | truncate(50) }}</b>
</nuxt-link>
<nuxt-link
v-else-if="scope.row.type === 'User' && scope.row.user.disabledBy"
:to="{ name: 'profile-slug', params: { slug: scope.row.user.disabledBy.slug } }"
>
<b>{{ scope.row.user.disabledBy.name | truncate(50) }}</b>
</nuxt-link>
</template>
</ds-table>
<hc-empty
v-else
@ -101,7 +124,8 @@ export default {
return {
type: ' ',
name: ' ',
submitter: this.$t('moderation.reports.submitter')
submitter: this.$t('moderation.reports.submitter'),
disabledBy: this.$t('moderation.reports.disabledBy')
// actions: ' '
}
}

View File

@ -1,48 +1,61 @@
<template>
<ds-card
v-if="post"
:image="post.image"
:header="post.title"
class="post-card"
<transition
name="fade"
appear
>
<hc-author :post="post" />
<no-ssr>
<content-menu
placement="bottom-end"
context="contribution"
:item-id="post.id"
:name="post.title"
:is-owner="isAuthor(post.author.id)"
<ds-card
v-if="post && ready"
:image="post.image"
:header="post.title"
:class="{'post-card': true, 'disabled-content': post.disabled}"
>
<hc-user :user="post.author" />
<no-ssr>
<content-menu
placement="bottom-end"
resource-type="contribution"
:resource="post"
:is-owner="isAuthor(post.author.id)"
/>
</no-ssr>
<ds-space margin-bottom="small" />
<!-- Content -->
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div
class="content hc-editor-content"
v-html="post.content"
/>
</no-ssr>
<ds-space margin-bottom="small" />
<!-- Content -->
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div
class="content hc-editor-content"
v-html="post.content"
/>
<!-- eslint-enable vue/no-v-html -->
<!-- Shout Button -->
<ds-space margin="xx-large" />
<hc-shout-button
v-if="post.author"
:disabled="isAuthor(post.author.id)"
:count="post.shoutedCount"
:is-shouted="post.shoutedByCurrentUser"
:post-id="post.id"
/>
<!-- Categories -->
<ds-icon
v-for="category in post.categories"
:key="category.id"
v-tooltip="{content: category.name, placement: 'top-start', delay: { show: 300 }}"
:name="category.icon"
size="large"
/>&nbsp;
<ds-space margin-bottom="small" />
<!--<div class="tags">
<ds-space>
<ds-text
v-if="post.createdAt"
align="right"
size="small"
color="soft"
>
{{ post.createdAt | dateTime('dd. MMMM yyyy HH:mm') }}
</ds-text>
</ds-space>
<!-- eslint-enable vue/no-v-html -->
<!-- Shout Button -->
<ds-space margin="xx-large" />
<hc-shout-button
v-if="post.author"
:disabled="isAuthor(post.author.id)"
:count="post.shoutedCount"
:is-shouted="post.shoutedByCurrentUser"
:post-id="post.id"
/>
<!-- Categories -->
<ds-icon
v-for="category in post.categories"
:key="category.id"
v-tooltip="{content: category.name, placement: 'top-start', delay: { show: 300 }}"
:name="category.icon"
size="large"
/>&nbsp;
<ds-space margin-bottom="small" />
<!--<div class="tags">
<ds-icon name="compass" /> <ds-tag
v-for="category in post.categories"
:key="category.id"
@ -50,91 +63,63 @@
{{ category.name }}
</ds-tag>
</div>-->
<!-- Tags -->
<template v-if="post.tags && post.tags.length">
<ds-space margin="xx-small" />
<div class="tags">
<ds-icon name="tags" /> <ds-tag
v-for="tag in post.tags"
:key="tag.id"
>
<ds-icon name="tag" /> {{ tag.name }}
</ds-tag>
</div>
</template>
<ds-space margin="small" />
<!-- Comments -->
<ds-section slot="footer">
<h3 style="margin-top: 0;">
<span>
<ds-icon name="comments" />
<ds-tag
v-if="post.commentsCount"
style="margin-top: -4px; margin-left: -12px; position: absolute;"
color="primary"
size="small"
round
<!-- Tags -->
<template v-if="post.tags && post.tags.length">
<ds-space margin="xx-small" />
<div class="tags">
<ds-icon name="tags" /> <ds-tag
v-for="tag in post.tags"
:key="tag.id"
>
{{ post.commentsCount }}
</ds-tag> &nbsp; Comments
</span>
</h3>
<ds-space margin-bottom="large" />
<div
v-if="post.commentsCount"
id="comments"
class="comments"
>
<div
v-for="comment in post.comments"
:key="comment.id"
class="comment"
>
<ds-space margin-bottom="x-small">
<hc-author :post="comment" />
</ds-space>
<no-ssr>
<content-menu
placement="bottom-end"
context="comment"
style="float-right"
:item-id="comment.id"
:name="comment.author.name"
:is-owner="isAuthor(comment.author.id)"
/>
</no-ssr>
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div
v-if="!comment.deleted"
style="padding-left: 40px;"
v-html="comment.contentExcerpt"
/>
<!-- eslint-enable vue/no-v-html -->
<ds-text
v-else
style="padding-left: 40px; font-weight: bold;"
color="soft"
>
<ds-icon name="ban" /> Vom Benutzer gelöscht
</ds-text>
<ds-icon name="tag" /> {{ tag.name }}
</ds-tag>
</div>
<ds-space margin-bottom="small" />
</div>
<hc-empty
v-else
icon="messages"
/>
</ds-section>
</ds-card>
</template>
<ds-space margin="small" />
<!-- Comments -->
<ds-section slot="footer">
<h3 style="margin-top: 0;">
<span>
<ds-icon name="comments" />
<ds-tag
v-if="post.comments"
style="margin-top: -4px; margin-left: -12px; position: absolute;"
color="primary"
size="small"
round
>
{{ post.commentsCount }}
</ds-tag> &nbsp; Comments
</span>
</h3>
<ds-space margin-bottom="large" />
<div
v-if="post.comments"
id="comments"
class="comments"
>
<comment
v-for="comment in post.comments"
:key="comment.id"
:comment="comment"
/>
</div>
<hc-empty
v-else
icon="messages"
/>
</ds-section>
</ds-card>
</transition>
</template>
<script>
import gql from 'graphql-tag'
import ContentMenu from '~/components/ContentMenu'
import HcAuthor from '~/components/Author.vue'
import HcUser from '~/components/User.vue'
import HcShoutButton from '~/components/ShoutButton.vue'
import HcEmpty from '~/components/Empty.vue'
import Comment from '~/components/Comment.vue'
export default {
transition: {
@ -142,9 +127,10 @@ export default {
mode: 'out-in'
},
components: {
HcAuthor,
HcUser,
HcShoutButton,
HcEmpty,
Comment,
ContentMenu
},
head() {
@ -155,6 +141,7 @@ export default {
data() {
return {
post: null,
ready: false,
title: 'loading'
}
},
@ -164,90 +151,113 @@ export default {
this.title = this.post.title
}
},
async asyncData(context) {
const {
params,
error,
app: { apolloProvider, $i18n }
} = context
const client = apolloProvider.defaultClient
const query = gql(`
query Post($slug: String!) {
Post(slug: $slug) {
id
title
content
createdAt
disabled
deleted
slug
image
author {
id
slug
name
avatar
disabled
deleted
shoutedCount
contributionsCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${$i18n.locale().toUpperCase()}
}
badges {
id
key
icon
}
}
tags {
name
}
commentsCount
comments(orderBy: createdAt_desc) {
id
contentExcerpt
createdAt
disabled
deleted
author {
id
slug
name
avatar
disabled
deleted
shoutedCount
contributionsCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${$i18n.locale().toUpperCase()}
}
badges {
id
key
icon
}
}
}
categories {
id
name
icon
}
shoutedCount
shoutedByCurrentUser
}
}
`)
const variables = { slug: params.slug }
const {
data: { Post }
} = await client.query({ query, variables })
if (Post.length <= 0) {
// TODO: custom 404 error page with translations
const message = 'This post could not be found'
return error({ statusCode: 404, message })
}
const [post] = Post
return {
post,
title: post.title
}
},
mounted() {
setTimeout(() => {
// NOTE: quick fix for jumping flexbox implementation
// will be fixed in a future update of the styleguide
this.ready = true
}, 50)
},
methods: {
isAuthor(id) {
return this.$store.getters['auth/user'].id === id
}
},
apollo: {
Post: {
query() {
return gql(`
query Post($slug: String!) {
Post(slug: $slug) {
id
title
content
createdAt
slug
image
author {
id
slug
name
avatar
shoutedCount
contributionsCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${this.$i18n.locale().toUpperCase()}
}
badges {
id
key
icon
}
}
tags {
name
}
commentsCount
comments(first: 20, orderBy: createdAt_desc) {
id
contentExcerpt
createdAt
deleted
author {
id
slug
name
avatar
shoutedCount
contributionsCount
commentsCount
followedByCount
followedByCurrentUser
location {
name: name${this.$i18n.locale().toUpperCase()}
}
badges {
id
key
icon
}
}
}
categories {
id
name
icon
}
shoutedCount
shoutedByCurrentUser
}
}
`)
},
variables() {
return {
slug: this.$route.params.slug
}
},
prefetch: true,
fetchPolicy: 'cache-and-network'
}
}
}
</script>

View File

@ -48,10 +48,14 @@ export default {
title
content
createdAt
disabled
deleted
slug
image
author {
id
disabled
deleted
}
tags {
name

View File

@ -10,7 +10,10 @@
gutter="base"
>
<ds-flex-item :width="{ base: '100%', sm: 2, md: 2, lg: 1 }">
<ds-card style="position: relative; height: auto;">
<ds-card
:class="{'disabled-content': user.disabled}"
style="position: relative; height: auto;"
>
<ds-avatar
:image="user.avatar"
:name="user.name || 'Anonymus'"
@ -20,9 +23,8 @@
<no-ssr>
<content-menu
placement="bottom-end"
context="user"
:item-id="user.id"
:name="user.name"
resource-type="user"
:resource="user"
:is-owner="myProfile"
/>
</no-ssr>
@ -134,8 +136,8 @@
>
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<hc-related-user
:post="follow"
<user
:user="follow"
:trunc="15"
/>
</no-ssr>
@ -179,8 +181,8 @@
>
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<hc-related-user
:post="follow"
<user
:user="follow"
:trunc="15"
/>
</no-ssr>
@ -273,7 +275,6 @@
>
<hc-post-card
:post="post"
:show-author-popover="false"
/>
</ds-flex-item>
</template>
@ -299,7 +300,7 @@
<script>
import uniqBy from 'lodash/uniqBy'
import HcRelatedUser from '~/components/RelatedUser.vue'
import User from '~/components/User.vue'
import HcPostCard from '~/components/PostCard.vue'
import HcFollowButton from '~/components/FollowButton.vue'
import HcCountTo from '~/components/CountTo.vue'
@ -310,7 +311,7 @@ import ContentMenu from '~/components/ContentMenu'
export default {
components: {
HcRelatedUser,
User,
HcPostCard,
HcFollowButton,
HcCountTo,

View File

@ -8838,11 +8838,6 @@ popper.js@^1.12.9:
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.7.tgz#e31ec06cfac6a97a53280c3e55e4e0c860e7738e"
integrity sha512-4q1hNvoUre/8srWsH7hnoSJ5xVmIL4qgz+s4qf2TnJIMyZFUFMGH+9vE7mXynAlHSZ/NdTmmow86muD0myUkVQ==
portal-vue@~1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/portal-vue/-/portal-vue-1.5.1.tgz#6bed79ef168d9676bb79f41d43c5cd4cedf54dbc"
integrity sha512-7T0K+qyY8bnjnEpQTiLbGsUaGlFcemK9gLurVSr6x1/qzr2HkHDNCOz5i+xhuTD1CrXckf/AGeCnLzvmAHMOHw==
posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@ -10880,7 +10875,7 @@ string-argv@0.0.2:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.0.2.tgz#dac30408690c21f3c3630a3ff3a05877bdcbd736"
integrity sha1-2sMECGkMIfPDYwo/86BYd73L1zY=
string-hash@~1.1.3:
string-hash@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b"
integrity sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=
@ -11292,7 +11287,7 @@ tiptap-commands@^1.7.0:
prosemirror-state "^1.2.2"
tiptap-utils "^1.3.0"
tiptap-extensions@~1.14.0:
tiptap-extensions@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/tiptap-extensions/-/tiptap-extensions-1.14.0.tgz#5517afd1ca556715a8cce6c022c88584a762004a"
integrity sha512-WzYukrUHGGjCi3+F156LEVn5R58Pw1F6zKHT2o4SMHuv0LrWTIJK1XsDv8uwi/szfTlXm9BJ4MKmRbDulBeioQ==
@ -11316,7 +11311,7 @@ tiptap-utils@^1.3.0:
prosemirror-tables "^0.7.9"
prosemirror-utils "^0.7.6"
tiptap@^1.14.0, tiptap@~1.14.0:
tiptap@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/tiptap/-/tiptap-1.14.0.tgz#8dd84b199533e08f0dcc34b39d517ea73e20fb95"
integrity sha512-38gCYeJx5O83oTnpfgMGGrjem1ZNDK2waaUMq+bkYPaQwvvtyMDGffvEIT9/jcLvA+WYfaNp8BWnn1rqNpYKxA==