mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
Merge branch 'master' of github.com:Human-Connection/Nitro-Web into 37-full-text-search-top-bar
This commit is contained in:
commit
9987ad1c95
@ -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;
|
||||
|
||||
@ -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>
|
||||
95
components/Comment.spec.js
Normal file
95
components/Comment.spec.js
Normal 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
76
components/Comment.vue
Normal 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>
|
||||
@ -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
124
components/Modal.spec.js
Normal 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
59
components/Modal.vue
Normal 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>
|
||||
150
components/Modal/DisableModal.spec.js
Normal file
150
components/Modal/DisableModal.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
83
components/Modal/DisableModal.vue
Normal file
83
components/Modal/DisableModal.vue
Normal 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>
|
||||
168
components/Modal/ReportModal.spec.js
Normal file
168
components/Modal/ReportModal.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
127
components/Modal/ReportModal.vue
Normal file
127
components/Modal/ReportModal.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
@ -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
102
components/User.spec.js
Normal 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
182
components/User.vue
Normal 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>
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
26
cypress/integration/moderation/HidePosts.feature
Normal file
26
cypress/integration/moderation/HidePosts.feature
Normal 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
|
||||
"""
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,8 +109,7 @@ module.exports = {
|
||||
'cookie-universal-nuxt',
|
||||
'@nuxtjs/apollo',
|
||||
'@nuxtjs/axios',
|
||||
'@nuxtjs/style-resources',
|
||||
'portal-vue/nuxt'
|
||||
'@nuxtjs/style-resources'
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: ' '
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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> 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> 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>
|
||||
|
||||
@ -48,10 +48,14 @@ export default {
|
||||
title
|
||||
content
|
||||
createdAt
|
||||
disabled
|
||||
deleted
|
||||
slug
|
||||
image
|
||||
author {
|
||||
id
|
||||
disabled
|
||||
deleted
|
||||
}
|
||||
tags {
|
||||
name
|
||||
|
||||
@ -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,
|
||||
|
||||
11
yarn.lock
11
yarn.lock
@ -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==
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user