mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
feat(webapp): shout comments (#8600)
* shout comments * fix notifications * Remove whitespace for empty category sections * Overhaul post actions * Adjust spacing * Allow fine-grained size control for icons and circle buttons via css variables; adjust comments layout * Adjust spacing * Add test for ActionButton (WIP) * Rename import * Remove text and add count bubble * Use filled icons to indicate active states * Adjust sizes and orientation * Remove unused properties, add test * Fix ObserveButton test * Fix ShoutButton test * fix tests * Adapt styles * Adjust style for larger numbers * Remove unused icon * Fix test structure * Remove unused class names --------- Co-authored-by: Maximilian Harz <maxharz@gmail.com>
This commit is contained in:
parent
51564e5d9b
commit
4b3a26d517
@ -108,10 +108,14 @@ export default {
|
|||||||
count: {
|
count: {
|
||||||
postObservingUsersCount:
|
postObservingUsersCount:
|
||||||
'-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(related:User) WHERE obs.active = true AND NOT related.deleted AND NOT related.disabled',
|
'-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(related:User) WHERE obs.active = true AND NOT related.deleted AND NOT related.disabled',
|
||||||
|
shoutedCount:
|
||||||
|
'<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true',
|
||||||
},
|
},
|
||||||
boolean: {
|
boolean: {
|
||||||
isPostObservedByMe:
|
isPostObservedByMe:
|
||||||
'MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1',
|
'MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1',
|
||||||
|
shoutedByCurrentUser:
|
||||||
|
'MATCH (this) RETURN EXISTS((this)<-[:SHOUTED]-(:User {id: $cypherParams.currentUserId}))',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
enum ShoutTypeEnum {
|
enum ShoutTypeEnum {
|
||||||
Post
|
Post
|
||||||
|
Comment
|
||||||
}
|
}
|
||||||
@ -53,6 +53,14 @@ type Comment {
|
|||||||
)
|
)
|
||||||
postObservingUsersCount: Int!
|
postObservingUsersCount: Int!
|
||||||
@cypher(statement: "MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(u:User) WHERE obs.active = true AND NOT u.disabled = true AND NOT u.deleted = true RETURN COUNT(DISTINCT u)")
|
@cypher(statement: "MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(u:User) WHERE obs.active = true AND NOT u.disabled = true AND NOT u.deleted = true RETURN COUNT(DISTINCT u)")
|
||||||
|
|
||||||
|
shoutedByCurrentUser: Boolean!
|
||||||
|
@cypher(statement: "MATCH (this) RETURN EXISTS((this)<-[:SHOUTED]-(:User {id: $cypherParams.currentUserId}))")
|
||||||
|
|
||||||
|
shoutedCount: Int!
|
||||||
|
@cypher(
|
||||||
|
statement: "MATCH (this)<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true RETURN COUNT(DISTINCT related)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
|
|||||||
@ -243,9 +243,9 @@ type Mutation {
|
|||||||
markTeaserAsViewed(id: ID!): Post
|
markTeaserAsViewed(id: ID!): Post
|
||||||
|
|
||||||
# Shout the given Type and ID
|
# Shout the given Type and ID
|
||||||
shout(id: ID!, type: ShoutTypeEnum): Boolean!
|
shout(id: ID!, type: ShoutTypeEnum!): Boolean!
|
||||||
# Unshout the given Type and ID
|
# Unshout the given Type and ID
|
||||||
unshout(id: ID!, type: ShoutTypeEnum): Boolean!
|
unshout(id: ID!, type: ShoutTypeEnum!): Boolean!
|
||||||
|
|
||||||
toggleObservePost(id: ID!, value: Boolean!): Post!
|
toggleObservePost(id: ID!, value: Boolean!): Post!
|
||||||
}
|
}
|
||||||
|
|||||||
64
webapp/components/ActionButton.spec.js
Normal file
64
webapp/components/ActionButton.spec.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import ActionButton from './ActionButton.vue'
|
||||||
|
|
||||||
|
const localVue = global.localVue
|
||||||
|
|
||||||
|
describe('ActionButton.vue', () => {
|
||||||
|
let mocks
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks = {
|
||||||
|
$t: jest.fn((t) => t),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let wrapper
|
||||||
|
const Wrapper = ({ isDisabled = false } = {}) => {
|
||||||
|
return render(ActionButton, {
|
||||||
|
mocks,
|
||||||
|
localVue,
|
||||||
|
propsData: {
|
||||||
|
icon: 'heart',
|
||||||
|
text: 'Click me',
|
||||||
|
count: 7,
|
||||||
|
disabled: isDisabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when not disabled', () => {
|
||||||
|
it('renders', () => {
|
||||||
|
const wrapper = Wrapper()
|
||||||
|
expect(wrapper.container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows count', () => {
|
||||||
|
const count = screen.getByText('7')
|
||||||
|
expect(count).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('button emits click event', async () => {
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
await fireEvent.click(button)
|
||||||
|
expect(wrapper.emitted().click).toEqual([[]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when disabled', () => {
|
||||||
|
it('renders', () => {
|
||||||
|
const wrapper = Wrapper({ isDisabled: true })
|
||||||
|
expect(wrapper.container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('button does not emit click event', async () => {
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
await fireEvent.click(button)
|
||||||
|
expect(wrapper.emitted().click).toEqual([[]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
61
webapp/components/ActionButton.vue
Normal file
61
webapp/components/ActionButton.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="action-button">
|
||||||
|
<base-button
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="disabled"
|
||||||
|
:icon="icon"
|
||||||
|
:aria-label="text"
|
||||||
|
:filled="filled"
|
||||||
|
circle
|
||||||
|
@click="click"
|
||||||
|
/>
|
||||||
|
<div class="count">{{ count }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
count: { type: Number, required: true },
|
||||||
|
text: { type: String, required: true },
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
filled: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean },
|
||||||
|
loading: { type: Boolean },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
click() {
|
||||||
|
this.$emit('click')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-xx-small;
|
||||||
|
position: relative;
|
||||||
|
--icon-size: calc(var(--circle-button-width, #{$size-button-base}) / 2);
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
user-select: none;
|
||||||
|
color: $color-primary-dark;
|
||||||
|
background-color: $color-secondary-inverse;
|
||||||
|
border: 1px solid $color-primary-dark;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
top: -12px;
|
||||||
|
left: calc(100% - 16px);
|
||||||
|
--diameter: calc(var(--circle-button-width, #{$size-button-base}) * 0.7);
|
||||||
|
min-width: var(--diameter);
|
||||||
|
height: var(--diameter);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-inline: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -17,7 +17,7 @@ describe('CommentCard.vue', () => {
|
|||||||
postId: 'post42',
|
postId: 'post42',
|
||||||
}
|
}
|
||||||
mocks = {
|
mocks = {
|
||||||
$t: jest.fn(),
|
$t: jest.fn((t) => t),
|
||||||
$toast: {
|
$toast: {
|
||||||
success: jest.fn(),
|
success: jest.fn(),
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
|
|||||||
@ -39,15 +39,25 @@
|
|||||||
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
|
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
|
||||||
</base-button>
|
</base-button>
|
||||||
</template>
|
</template>
|
||||||
<base-button
|
<div class="actions">
|
||||||
:title="this.$t('post.comment.reply')"
|
<shout-button
|
||||||
icon="level-down"
|
:disabled="isAuthor"
|
||||||
class="reply-button"
|
:count="comment.shoutedCount"
|
||||||
circle
|
:is-shouted="comment.shoutedByCurrentUser"
|
||||||
size="small"
|
:node-id="comment.id"
|
||||||
v-scroll-to="'.editor'"
|
class="shout-button"
|
||||||
@click="reply"
|
node-type="Comment"
|
||||||
/>
|
/>
|
||||||
|
<base-button
|
||||||
|
:title="this.$t('post.comment.reply')"
|
||||||
|
icon="level-down"
|
||||||
|
class="reply-button"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
v-scroll-to="'.editor'"
|
||||||
|
@click="reply"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</base-card>
|
</base-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -59,6 +69,7 @@ import ContentMenu from '~/components/ContentMenu/ContentMenu'
|
|||||||
import ContentViewer from '~/components/Editor/ContentViewer'
|
import ContentViewer from '~/components/Editor/ContentViewer'
|
||||||
import CommentForm from '~/components/CommentForm/CommentForm'
|
import CommentForm from '~/components/CommentForm/CommentForm'
|
||||||
import CommentMutations from '~/graphql/CommentMutations'
|
import CommentMutations from '~/graphql/CommentMutations'
|
||||||
|
import ShoutButton from '~/components/ShoutButton.vue'
|
||||||
import scrollToAnchor from '~/mixins/scrollToAnchor.js'
|
import scrollToAnchor from '~/mixins/scrollToAnchor.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -67,6 +78,7 @@ export default {
|
|||||||
ContentMenu,
|
ContentMenu,
|
||||||
ContentViewer,
|
ContentViewer,
|
||||||
CommentForm,
|
CommentForm,
|
||||||
|
ShoutButton,
|
||||||
},
|
},
|
||||||
mixins: [scrollToAnchor],
|
mixins: [scrollToAnchor],
|
||||||
data() {
|
data() {
|
||||||
@ -98,6 +110,11 @@ export default {
|
|||||||
hasLongContent() {
|
hasLongContent() {
|
||||||
return this.$filters.removeHtml(this.comment.content).length > COMMENT_MAX_UNTRUNCATED_LENGTH
|
return this.$filters.removeHtml(this.comment.content).length > COMMENT_MAX_UNTRUNCATED_LENGTH
|
||||||
},
|
},
|
||||||
|
isAuthor() {
|
||||||
|
const { author } = this.comment
|
||||||
|
if (!author) return false
|
||||||
|
return this.$store.getters['auth/user'].id === author.id
|
||||||
|
},
|
||||||
isUnavailable() {
|
isUnavailable() {
|
||||||
return (this.comment.deleted || this.comment.disabled) && !this.isModerator
|
return (this.comment.deleted || this.comment.disabled) && !this.isModerator
|
||||||
},
|
},
|
||||||
@ -192,19 +209,14 @@ export default {
|
|||||||
margin-bottom: $space-small;
|
margin-bottom: $space-small;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .base-button {
|
.actions {
|
||||||
align-self: flex-end;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-button {
|
|
||||||
float: right;
|
|
||||||
top: 0px;
|
|
||||||
}
|
|
||||||
.reply-button:after {
|
|
||||||
clear: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes highlight {
|
@keyframes highlight {
|
||||||
0% {
|
0% {
|
||||||
border: $border-size-base solid $color-primary;
|
border: $border-size-base solid $color-primary;
|
||||||
@ -214,3 +226,17 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.actions {
|
||||||
|
margin-top: $space-x-small;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: right;
|
||||||
|
gap: calc($space-base * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shout-button {
|
||||||
|
--circle-button-width: 28px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -44,7 +44,7 @@
|
|||||||
</ds-chip>
|
</ds-chip>
|
||||||
</div>
|
</div>
|
||||||
<!-- group categories -->
|
<!-- group categories -->
|
||||||
<div class="categories" v-if="categoriesActive">
|
<div class="categories" v-if="categoriesActive && group.categories.length > 0">
|
||||||
<category
|
<category
|
||||||
v-for="category in group.categories"
|
v-for="category in group.categories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||||
import ObserveButton from './ObserveButton.vue'
|
import ObserveButton from './ObserveButton.vue'
|
||||||
|
|
||||||
const localVue = global.localVue
|
const localVue = global.localVue
|
||||||
|
|
||||||
describe('ObserveButton', () => {
|
describe('ObserveButton', () => {
|
||||||
let mocks
|
|
||||||
|
|
||||||
const Wrapper = (count = 1, postId = '123', isObserved = true) => {
|
const Wrapper = (count = 1, postId = '123', isObserved = true) => {
|
||||||
return mount(ObserveButton, {
|
return render(ObserveButton, {
|
||||||
mocks,
|
mocks: {
|
||||||
|
$t: jest.fn((t) => t),
|
||||||
|
},
|
||||||
localVue,
|
localVue,
|
||||||
propsData: {
|
propsData: {
|
||||||
count,
|
count,
|
||||||
@ -18,43 +18,39 @@ describe('ObserveButton', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let wrapper
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks = {
|
|
||||||
$t: jest.fn(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('observed', () => {
|
describe('observed', () => {
|
||||||
|
let wrapper
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = Wrapper(1, '123', true)
|
wrapper = Wrapper(1, '123', true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders', () => {
|
it('renders', () => {
|
||||||
expect(wrapper.element).toMatchSnapshot()
|
expect(wrapper.container).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits toggleObservePost with false when clicked', () => {
|
it('emits toggleObservePost with false when clicked', async () => {
|
||||||
const button = wrapper.find('.base-button')
|
const button = screen.getByRole('button')
|
||||||
button.trigger('click')
|
await fireEvent.click(button)
|
||||||
expect(wrapper.emitted('toggleObservePost')).toEqual([['123', false]])
|
expect(wrapper.emitted().toggleObservePost).toEqual([['123', false]])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('unobserved', () => {
|
describe('unobserved', () => {
|
||||||
|
let wrapper
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = Wrapper(1, '123', false)
|
wrapper = Wrapper(1, '123', false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders', () => {
|
it('renders', () => {
|
||||||
expect(wrapper.element).toMatchSnapshot()
|
expect(wrapper.container).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits toggleObservePost with true when clicked', () => {
|
it('emits toggleObservePost with true when clicked', async () => {
|
||||||
const button = wrapper.find('.base-button')
|
const button = screen.getByRole('button')
|
||||||
button.trigger('click')
|
await fireEvent.click(button)
|
||||||
expect(wrapper.emitted('toggleObservePost')).toEqual([['123', true]])
|
expect(wrapper.emitted().toggleObservePost).toEqual([['123', true]])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,26 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<ds-space margin="xx-small" class="text-align-center">
|
<action-button
|
||||||
<base-button :loading="loading" :filled="isObserved" icon="bell" circle @click="toggle" />
|
:loading="false"
|
||||||
<ds-space margin-bottom="xx-small" />
|
:count="count"
|
||||||
<ds-text color="soft" class="observe-button-text">
|
:text="$t('observeButton.observed')"
|
||||||
<ds-heading style="display: inline" tag="h3">{{ count }}x</ds-heading>
|
:filled="isObserved"
|
||||||
{{ $t('observeButton.observed') }}
|
icon="bell"
|
||||||
</ds-text>
|
circle
|
||||||
</ds-space>
|
@click="toggle"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import ActionButton from '~/components/ActionButton.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
ActionButton,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
count: { type: Number, default: 0 },
|
count: { type: Number, default: 0 },
|
||||||
postId: { type: String, default: null },
|
postId: { type: String, default: null },
|
||||||
isObserved: { type: Boolean, default: false },
|
isObserved: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loading: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
toggle() {
|
toggle() {
|
||||||
this.$emit('toggleObservePost', this.postId, !this.isObserved)
|
this.$emit('toggleObservePost', this.postId, !this.isObserved)
|
||||||
@ -28,12 +29,3 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.observe-button-text {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.text-align-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
class="footer"
|
class="footer"
|
||||||
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
|
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
|
||||||
>
|
>
|
||||||
<div class="categories" v-if="categoriesActive">
|
<div class="categories" v-if="categoriesActive && post.categories.length > 0">
|
||||||
<category
|
<category
|
||||||
v-for="category in post.categories"
|
v-for="category in post.categories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||||
import ShoutButton from './ShoutButton.vue'
|
import '@testing-library/jest-dom'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
import ShoutButton from './ShoutButton.vue'
|
||||||
|
|
||||||
const localVue = global.localVue
|
const localVue = global.localVue
|
||||||
|
|
||||||
@ -9,49 +10,54 @@ describe('ShoutButton.vue', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks = {
|
mocks = {
|
||||||
$t: jest.fn(),
|
$t: jest.fn((t) => t),
|
||||||
$apollo: {
|
$apollo: {
|
||||||
mutate: jest.fn(),
|
mutate: jest.fn(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('mount', () => {
|
let wrapper
|
||||||
let wrapper
|
|
||||||
const Wrapper = () => {
|
|
||||||
return mount(ShoutButton, { mocks, localVue })
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
const Wrapper = ({ isShouted = false } = {}) => {
|
||||||
wrapper = Wrapper()
|
return render(ShoutButton, { mocks, localVue, propsData: { isShouted } })
|
||||||
})
|
}
|
||||||
|
|
||||||
it('renders button and text', () => {
|
beforeEach(() => {
|
||||||
expect(mocks.$t).toHaveBeenCalledWith('shoutButton.shouted')
|
wrapper = Wrapper()
|
||||||
expect(wrapper.findAll('.base-button')).toHaveLength(1)
|
})
|
||||||
expect(wrapper.findAll('.shout-button-text')).toHaveLength(1)
|
|
||||||
expect(wrapper.vm.shouted).toBe(false)
|
|
||||||
expect(wrapper.vm.shoutedCount).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toggle the button', async () => {
|
it('renders button and text', () => {
|
||||||
mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } })
|
expect(wrapper.container).toMatchSnapshot()
|
||||||
wrapper.find('.base-button').trigger('click')
|
const button = screen.getByRole('button')
|
||||||
expect(wrapper.vm.shouted).toBe(true)
|
expect(button).toBeInTheDocument()
|
||||||
expect(wrapper.vm.shoutedCount).toBe(1)
|
})
|
||||||
await Vue.nextTick()
|
|
||||||
expect(wrapper.vm.shouted).toBe(true)
|
|
||||||
expect(wrapper.vm.shoutedCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toggle the button, but backend fails', async () => {
|
it('toggle the button', async () => {
|
||||||
mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' })
|
mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } })
|
||||||
await wrapper.find('.base-button').trigger('click')
|
const button = screen.getByRole('button')
|
||||||
expect(wrapper.vm.shouted).toBe(true)
|
await fireEvent.click(button)
|
||||||
expect(wrapper.vm.shoutedCount).toBe(1)
|
expect(wrapper.container).toMatchSnapshot()
|
||||||
await Vue.nextTick()
|
const shoutedCount = screen.getByText('1')
|
||||||
expect(wrapper.vm.shouted).toBe(false)
|
expect(shoutedCount).toBeInTheDocument()
|
||||||
expect(wrapper.vm.shoutedCount).toBe(0)
|
})
|
||||||
|
|
||||||
|
it('toggle the button, but backend fails', async () => {
|
||||||
|
mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' })
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
await fireEvent.click(button)
|
||||||
|
expect(wrapper.container).toMatchSnapshot()
|
||||||
|
let shoutedCount = screen.getByText('1')
|
||||||
|
expect(shoutedCount).toBeInTheDocument()
|
||||||
|
await Vue.nextTick()
|
||||||
|
shoutedCount = screen.getByText('0')
|
||||||
|
expect(shoutedCount).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when shouted', () => {
|
||||||
|
it('renders', () => {
|
||||||
|
wrapper = Wrapper({ isShouted: true })
|
||||||
|
expect(wrapper.container).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,28 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<ds-space margin="xx-small" class="text-align-center">
|
<action-button
|
||||||
<base-button
|
:loading="loading"
|
||||||
:loading="loading"
|
:disabled="disabled"
|
||||||
:disabled="disabled"
|
:count="shoutedCount"
|
||||||
:filled="shouted"
|
:text="$t('shoutButton.shouted')"
|
||||||
icon="heart-o"
|
:filled="shouted"
|
||||||
circle
|
icon="heart-o"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
/>
|
/>
|
||||||
<ds-space margin-bottom="xx-small" />
|
|
||||||
<ds-text color="soft" class="shout-button-text">
|
|
||||||
<ds-heading style="display: inline" tag="h3">{{ shoutedCount }}x</ds-heading>
|
|
||||||
{{ $t('shoutButton.shouted') }}
|
|
||||||
</ds-text>
|
|
||||||
</ds-space>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
|
import ActionButton from '~/components/ActionButton.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
ActionButton,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
count: { type: Number, default: 0 },
|
count: { type: Number, default: 0 },
|
||||||
postId: { type: String, default: null },
|
nodeType: { type: String },
|
||||||
|
nodeId: { type: String, default: null },
|
||||||
isShouted: { type: Boolean, default: false },
|
isShouted: { type: Boolean, default: false },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
@ -58,12 +58,13 @@ export default {
|
|||||||
this.$apollo
|
this.$apollo
|
||||||
.mutate({
|
.mutate({
|
||||||
mutation: gql`
|
mutation: gql`
|
||||||
mutation($id: ID!) {
|
mutation($id: ID!, $type: ShoutTypeEnum!) {
|
||||||
${mutation}(id: $id, type: Post)
|
${mutation}(id: $id, type: $type)
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
variables: {
|
variables: {
|
||||||
id: this.postId,
|
id: this.nodeId,
|
||||||
|
type: this.nodeType,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@ -82,12 +83,3 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.shout-button-text {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.text-align-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
60
webapp/components/__snapshots__/ActionButton.spec.js.snap
Normal file
60
webapp/components/__snapshots__/ActionButton.spec.js.snap
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ActionButton.vue when disabled renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="action-button"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Click me"
|
||||||
|
class="base-button --icon-only --circle"
|
||||||
|
disabled="disabled"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="count"
|
||||||
|
>
|
||||||
|
7
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ActionButton.vue when not disabled renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="action-button"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Click me"
|
||||||
|
class="base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="count"
|
||||||
|
>
|
||||||
|
7
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -1,81 +1,61 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`ObserveButton observed renders 1`] = `
|
exports[`ObserveButton observed renders 1`] = `
|
||||||
<div
|
<div>
|
||||||
class="ds-space text-align-center"
|
|
||||||
style="margin-top: 4px; margin-bottom: 4px;"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="base-button --icon-only --circle --filled"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="base-icon"
|
|
||||||
>
|
|
||||||
<!---->
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!---->
|
|
||||||
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ds-space"
|
circle=""
|
||||||
style="margin-bottom: 4px;"
|
class="action-button"
|
||||||
/>
|
|
||||||
|
|
||||||
<p
|
|
||||||
class="ds-text observe-button-text ds-text-soft"
|
|
||||||
>
|
>
|
||||||
<h3
|
<button
|
||||||
class="ds-heading ds-heading-h3"
|
aria-label="observeButton.observed"
|
||||||
style="display: inline;"
|
class="base-button --icon-only --circle --filled"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
1x
|
<span
|
||||||
</h3>
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
</p>
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="count"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`ObserveButton unobserved renders 1`] = `
|
exports[`ObserveButton unobserved renders 1`] = `
|
||||||
<div
|
<div>
|
||||||
class="ds-space text-align-center"
|
|
||||||
style="margin-top: 4px; margin-bottom: 4px;"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="base-button --icon-only --circle"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="base-icon"
|
|
||||||
>
|
|
||||||
<!---->
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!---->
|
|
||||||
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ds-space"
|
circle=""
|
||||||
style="margin-bottom: 4px;"
|
class="action-button"
|
||||||
/>
|
|
||||||
|
|
||||||
<p
|
|
||||||
class="ds-text observe-button-text ds-text-soft"
|
|
||||||
>
|
>
|
||||||
<h3
|
<button
|
||||||
class="ds-heading ds-heading-h3"
|
aria-label="observeButton.observed"
|
||||||
style="display: inline;"
|
class="base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
1x
|
<span
|
||||||
</h3>
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
</p>
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="count"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
117
webapp/components/__snapshots__/ShoutButton.spec.js.snap
Normal file
117
webapp/components/__snapshots__/ShoutButton.spec.js.snap
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ShoutButton.vue renders button and text 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="action-button"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="shoutButton.shouted"
|
||||||
|
class="base-button --icon-only --circle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="count"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ShoutButton.vue toggle the button 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="action-button"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="shoutButton.shouted"
|
||||||
|
class="base-button --icon-only --circle --filled"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="count"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ShoutButton.vue toggle the button, but backend fails 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="action-button"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="shoutButton.shouted"
|
||||||
|
class="base-button --icon-only --circle --filled"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="count"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ShoutButton.vue when shouted renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="action-button"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="shoutButton.shouted"
|
||||||
|
class="base-button --icon-only --circle --filled"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="base-icon"
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="count"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -112,7 +112,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.--circle {
|
&.--circle {
|
||||||
width: $size-button-base;
|
width: var(--circle-button-width, $size-button-base);
|
||||||
|
height: var(--circle-button-width, $size-button-base);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.--regular {
|
&.--regular {
|
||||||
height: 1.2em;
|
height: var(--icon-size, 1.2em);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.--large {
|
&.--large {
|
||||||
|
|||||||
@ -158,5 +158,7 @@ export const commentFragment = gql`
|
|||||||
contentExcerpt
|
contentExcerpt
|
||||||
isPostObservedByMe
|
isPostObservedByMe
|
||||||
postObservingUsersCount
|
postObservingUsersCount
|
||||||
|
shoutedByCurrentUser
|
||||||
|
shoutedCount
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
<ds-space margin="x-small" />
|
<ds-space margin="x-small" />
|
||||||
</ds-space>
|
</ds-space>
|
||||||
<!-- group categories -->
|
<!-- group categories -->
|
||||||
<template v-if="categoriesActive">
|
<template v-if="categoriesActive && group && group.categories.length > 0">
|
||||||
<hr />
|
<hr />
|
||||||
<ds-space margin-top="small" margin-bottom="small">
|
<ds-space margin-top="small" margin-bottom="small">
|
||||||
<ds-text class="centered-text hyphenate-text" color="soft" size="small">
|
<ds-text class="centered-text hyphenate-text" color="soft" size="small">
|
||||||
|
|||||||
@ -50,7 +50,7 @@ describe('PostSlug', () => {
|
|||||||
})
|
})
|
||||||
const propsData = {}
|
const propsData = {}
|
||||||
mocks = {
|
mocks = {
|
||||||
$t: jest.fn(),
|
$t: jest.fn((t) => t),
|
||||||
$filters: {
|
$filters: {
|
||||||
truncate: (a) => a,
|
truncate: (a) => a,
|
||||||
removeHtml: (a) => a,
|
removeHtml: (a) => a,
|
||||||
|
|||||||
@ -77,7 +77,7 @@
|
|||||||
<!-- content -->
|
<!-- content -->
|
||||||
<content-viewer class="content hyphenate-text" :content="post.content" />
|
<content-viewer class="content hyphenate-text" :content="post.content" />
|
||||||
<!-- categories -->
|
<!-- categories -->
|
||||||
<div v-if="categoriesActive" class="categories">
|
<div v-if="categoriesActive && post.categories.length > 0" class="categories">
|
||||||
<ds-space margin="xx-large" />
|
<ds-space margin="xx-large" />
|
||||||
<ds-space margin="xx-small" />
|
<ds-space margin="xx-small" />
|
||||||
<hc-category
|
<hc-category
|
||||||
@ -97,35 +97,23 @@
|
|||||||
<ds-space margin="xx-small" />
|
<ds-space margin="xx-small" />
|
||||||
<hc-hashtag v-for="tag in sortedTags" :key="tag.id" :id="tag.id" />
|
<hc-hashtag v-for="tag in sortedTags" :key="tag.id" :id="tag.id" />
|
||||||
</div>
|
</div>
|
||||||
<ds-space margin-top="small">
|
<div class="actions">
|
||||||
<ds-flex :gutter="{ lg: 'small' }">
|
<!-- Shout Button -->
|
||||||
<!-- Shout Button -->
|
<shout-button
|
||||||
<ds-flex-item
|
:disabled="isAuthor"
|
||||||
:width="{ lg: '15%', md: '22%', sm: '22%', base: '100%' }"
|
:count="post.shoutedCount"
|
||||||
class="shout-button"
|
:is-shouted="post.shoutedByCurrentUser"
|
||||||
>
|
:node-id="post.id"
|
||||||
<hc-shout-button
|
node-type="Post"
|
||||||
v-if="post.author"
|
/>
|
||||||
:disabled="isAuthor"
|
<!-- Follow Button -->
|
||||||
:count="post.shoutedCount"
|
<observe-button
|
||||||
:is-shouted="post.shoutedByCurrentUser"
|
:is-observed="post.isObservedByMe"
|
||||||
:post-id="post.id"
|
:count="post.observingUsersCount"
|
||||||
/>
|
:post-id="post.id"
|
||||||
</ds-flex-item>
|
@toggleObservePost="toggleObservePost"
|
||||||
<!-- Follow Button -->
|
/>
|
||||||
<ds-flex-item
|
</div>
|
||||||
:width="{ lg: '15%', md: '22%', sm: '22%', base: '100%' }"
|
|
||||||
class="shout-button"
|
|
||||||
>
|
|
||||||
<observe-button
|
|
||||||
:is-observed="post.isObservedByMe"
|
|
||||||
:count="post.observingUsersCount"
|
|
||||||
:post-id="post.id"
|
|
||||||
@toggleObservePost="toggleObservePost"
|
|
||||||
/>
|
|
||||||
</ds-flex-item>
|
|
||||||
</ds-flex>
|
|
||||||
</ds-space>
|
|
||||||
<!-- Comments -->
|
<!-- Comments -->
|
||||||
<ds-section>
|
<ds-section>
|
||||||
<comment-list
|
<comment-list
|
||||||
@ -168,7 +156,7 @@ import CommentList from '~/components/CommentList/CommentList'
|
|||||||
import ContentMenu from '~/components/ContentMenu/ContentMenu'
|
import ContentMenu from '~/components/ContentMenu/ContentMenu'
|
||||||
import DateTimeRange from '~/components/DateTimeRange/DateTimeRange'
|
import DateTimeRange from '~/components/DateTimeRange/DateTimeRange'
|
||||||
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
||||||
import HcShoutButton from '~/components/ShoutButton.vue'
|
import ShoutButton from '~/components/ShoutButton.vue'
|
||||||
import ObserveButton from '~/components/ObserveButton.vue'
|
import ObserveButton from '~/components/ObserveButton.vue'
|
||||||
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
|
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
|
||||||
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
|
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
|
||||||
@ -198,7 +186,7 @@ export default {
|
|||||||
DateTimeRange,
|
DateTimeRange,
|
||||||
HcCategory,
|
HcCategory,
|
||||||
HcHashtag,
|
HcHashtag,
|
||||||
HcShoutButton,
|
ShoutButton,
|
||||||
ObserveButton,
|
ObserveButton,
|
||||||
LocationTeaser,
|
LocationTeaser,
|
||||||
PageParamsLink,
|
PageParamsLink,
|
||||||
@ -425,10 +413,15 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@media only screen and (max-width: 960px) {
|
<style lang="scss" scoped>
|
||||||
.shout-button {
|
.actions {
|
||||||
float: left;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
justify-content: right;
|
||||||
|
gap: $space-small;
|
||||||
|
margin-top: $space-small;
|
||||||
|
margin-bottom: calc($space-base * 2);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user