diff --git a/backend/src/graphql/resolvers/comments.ts b/backend/src/graphql/resolvers/comments.ts index 3400b1d23..e07c6791d 100644 --- a/backend/src/graphql/resolvers/comments.ts +++ b/backend/src/graphql/resolvers/comments.ts @@ -108,10 +108,14 @@ export default { count: { postObservingUsersCount: '-[: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: { isPostObservedByMe: '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}))', }, }), }, diff --git a/backend/src/graphql/types/enum/ShoutTypeEnum.gql b/backend/src/graphql/types/enum/ShoutTypeEnum.gql index 87fcbc5ff..97c17316f 100644 --- a/backend/src/graphql/types/enum/ShoutTypeEnum.gql +++ b/backend/src/graphql/types/enum/ShoutTypeEnum.gql @@ -1,3 +1,4 @@ enum ShoutTypeEnum { Post + Comment } \ No newline at end of file diff --git a/backend/src/graphql/types/type/Comment.gql b/backend/src/graphql/types/type/Comment.gql index b1fd7a838..f82a44a79 100644 --- a/backend/src/graphql/types/type/Comment.gql +++ b/backend/src/graphql/types/type/Comment.gql @@ -53,6 +53,14 @@ type Comment { ) 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)") + + 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 { diff --git a/backend/src/graphql/types/type/Post.gql b/backend/src/graphql/types/type/Post.gql index 784a5f5bc..e6b3a00a0 100644 --- a/backend/src/graphql/types/type/Post.gql +++ b/backend/src/graphql/types/type/Post.gql @@ -243,9 +243,9 @@ type Mutation { markTeaserAsViewed(id: ID!): Post # 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(id: ID!, type: ShoutTypeEnum): Boolean! + unshout(id: ID!, type: ShoutTypeEnum!): Boolean! toggleObservePost(id: ID!, value: Boolean!): Post! } diff --git a/webapp/components/ActionButton.spec.js b/webapp/components/ActionButton.spec.js new file mode 100644 index 000000000..3889d26db --- /dev/null +++ b/webapp/components/ActionButton.spec.js @@ -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([[]]) + }) + }) +}) diff --git a/webapp/components/ActionButton.vue b/webapp/components/ActionButton.vue new file mode 100644 index 000000000..f440deff8 --- /dev/null +++ b/webapp/components/ActionButton.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/webapp/components/CommentCard/CommentCard.spec.js b/webapp/components/CommentCard/CommentCard.spec.js index 7764afd1e..78d87e497 100644 --- a/webapp/components/CommentCard/CommentCard.spec.js +++ b/webapp/components/CommentCard/CommentCard.spec.js @@ -17,7 +17,7 @@ describe('CommentCard.vue', () => { postId: 'post42', } mocks = { - $t: jest.fn(), + $t: jest.fn((t) => t), $toast: { success: jest.fn(), error: jest.fn(), diff --git a/webapp/components/CommentCard/CommentCard.vue b/webapp/components/CommentCard/CommentCard.vue index d805d26ee..448dce76b 100644 --- a/webapp/components/CommentCard/CommentCard.vue +++ b/webapp/components/CommentCard/CommentCard.vue @@ -39,15 +39,25 @@ {{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }} - +
+ + +
@@ -59,6 +69,7 @@ import ContentMenu from '~/components/ContentMenu/ContentMenu' import ContentViewer from '~/components/Editor/ContentViewer' import CommentForm from '~/components/CommentForm/CommentForm' import CommentMutations from '~/graphql/CommentMutations' +import ShoutButton from '~/components/ShoutButton.vue' import scrollToAnchor from '~/mixins/scrollToAnchor.js' export default { @@ -67,6 +78,7 @@ export default { ContentMenu, ContentViewer, CommentForm, + ShoutButton, }, mixins: [scrollToAnchor], data() { @@ -98,6 +110,11 @@ export default { hasLongContent() { 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() { return (this.comment.deleted || this.comment.disabled) && !this.isModerator }, @@ -192,19 +209,14 @@ export default { margin-bottom: $space-small; } - > .base-button { - align-self: flex-end; + .actions { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; } } -.reply-button { - float: right; - top: 0px; -} -.reply-button:after { - clear: both; -} - @keyframes highlight { 0% { border: $border-size-base solid $color-primary; @@ -214,3 +226,17 @@ export default { } } + + diff --git a/webapp/components/Group/GroupTeaser.vue b/webapp/components/Group/GroupTeaser.vue index 4a905b417..7e6523048 100644 --- a/webapp/components/Group/GroupTeaser.vue +++ b/webapp/components/Group/GroupTeaser.vue @@ -44,7 +44,7 @@ -
+
{ - let mocks - const Wrapper = (count = 1, postId = '123', isObserved = true) => { - return mount(ObserveButton, { - mocks, + return render(ObserveButton, { + mocks: { + $t: jest.fn((t) => t), + }, localVue, propsData: { count, @@ -18,43 +18,39 @@ describe('ObserveButton', () => { }) } - let wrapper - - beforeEach(() => { - mocks = { - $t: jest.fn(), - } - }) - describe('observed', () => { + let wrapper + beforeEach(() => { wrapper = Wrapper(1, '123', true) }) it('renders', () => { - expect(wrapper.element).toMatchSnapshot() + expect(wrapper.container).toMatchSnapshot() }) - it('emits toggleObservePost with false when clicked', () => { - const button = wrapper.find('.base-button') - button.trigger('click') - expect(wrapper.emitted('toggleObservePost')).toEqual([['123', false]]) + it('emits toggleObservePost with false when clicked', async () => { + const button = screen.getByRole('button') + await fireEvent.click(button) + expect(wrapper.emitted().toggleObservePost).toEqual([['123', false]]) }) }) describe('unobserved', () => { + let wrapper + beforeEach(() => { wrapper = Wrapper(1, '123', false) }) it('renders', () => { - expect(wrapper.element).toMatchSnapshot() + expect(wrapper.container).toMatchSnapshot() }) - it('emits toggleObservePost with true when clicked', () => { - const button = wrapper.find('.base-button') - button.trigger('click') - expect(wrapper.emitted('toggleObservePost')).toEqual([['123', true]]) + it('emits toggleObservePost with true when clicked', async () => { + const button = screen.getByRole('button') + await fireEvent.click(button) + expect(wrapper.emitted().toggleObservePost).toEqual([['123', true]]) }) }) }) diff --git a/webapp/components/ObserveButton.vue b/webapp/components/ObserveButton.vue index 2c275709b..2c6488a5e 100644 --- a/webapp/components/ObserveButton.vue +++ b/webapp/components/ObserveButton.vue @@ -1,26 +1,27 @@ - - diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue index cb2e8b66b..3b880d4d8 100644 --- a/webapp/components/PostTeaser/PostTeaser.vue +++ b/webapp/components/PostTeaser/PostTeaser.vue @@ -53,7 +53,7 @@ class="footer" v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)" > -
+
{ beforeEach(() => { mocks = { - $t: jest.fn(), + $t: jest.fn((t) => t), $apollo: { mutate: jest.fn(), }, } }) - describe('mount', () => { - let wrapper - const Wrapper = () => { - return mount(ShoutButton, { mocks, localVue }) - } + let wrapper - beforeEach(() => { - wrapper = Wrapper() - }) + const Wrapper = ({ isShouted = false } = {}) => { + return render(ShoutButton, { mocks, localVue, propsData: { isShouted } }) + } - it('renders button and text', () => { - expect(mocks.$t).toHaveBeenCalledWith('shoutButton.shouted') - 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) - }) + beforeEach(() => { + wrapper = Wrapper() + }) - it('toggle the button', async () => { - mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } }) - wrapper.find('.base-button').trigger('click') - expect(wrapper.vm.shouted).toBe(true) - expect(wrapper.vm.shoutedCount).toBe(1) - await Vue.nextTick() - expect(wrapper.vm.shouted).toBe(true) - expect(wrapper.vm.shoutedCount).toBe(1) - }) + it('renders button and text', () => { + expect(wrapper.container).toMatchSnapshot() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) - it('toggle the button, but backend fails', async () => { - mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' }) - await wrapper.find('.base-button').trigger('click') - expect(wrapper.vm.shouted).toBe(true) - expect(wrapper.vm.shoutedCount).toBe(1) - await Vue.nextTick() - expect(wrapper.vm.shouted).toBe(false) - expect(wrapper.vm.shoutedCount).toBe(0) + it('toggle the button', async () => { + mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } }) + const button = screen.getByRole('button') + await fireEvent.click(button) + expect(wrapper.container).toMatchSnapshot() + const shoutedCount = screen.getByText('1') + expect(shoutedCount).toBeInTheDocument() + }) + + 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() }) }) }) diff --git a/webapp/components/ShoutButton.vue b/webapp/components/ShoutButton.vue index 4b644bc25..a8aca9e74 100644 --- a/webapp/components/ShoutButton.vue +++ b/webapp/components/ShoutButton.vue @@ -1,28 +1,28 @@ - - diff --git a/webapp/components/__snapshots__/ActionButton.spec.js.snap b/webapp/components/__snapshots__/ActionButton.spec.js.snap new file mode 100644 index 000000000..c5d1d4581 --- /dev/null +++ b/webapp/components/__snapshots__/ActionButton.spec.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionButton.vue when disabled renders 1`] = ` +
+
+ + +
+ 7 +
+
+
+`; + +exports[`ActionButton.vue when not disabled renders 1`] = ` +
+
+ + +
+ 7 +
+
+
+`; diff --git a/webapp/components/__snapshots__/ObserveButton.spec.js.snap b/webapp/components/__snapshots__/ObserveButton.spec.js.snap index c3ba629be..064e6256f 100644 --- a/webapp/components/__snapshots__/ObserveButton.spec.js.snap +++ b/webapp/components/__snapshots__/ObserveButton.spec.js.snap @@ -1,81 +1,61 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ObserveButton observed renders 1`] = ` -
- - +
- -

-

- 1x -

- - - -

+ + + + + + + + +
+ 1 +
+
`; exports[`ObserveButton unobserved renders 1`] = ` -
- - +
- -

-

- 1x -

- - - -

+ + + + + + + + +
+ 1 +
+
`; diff --git a/webapp/components/__snapshots__/ShoutButton.spec.js.snap b/webapp/components/__snapshots__/ShoutButton.spec.js.snap new file mode 100644 index 000000000..94254b870 --- /dev/null +++ b/webapp/components/__snapshots__/ShoutButton.spec.js.snap @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShoutButton.vue renders button and text 1`] = ` +
+
+ + +
+ 0 +
+
+
+`; + +exports[`ShoutButton.vue toggle the button 1`] = ` +
+
+ + +
+ 1 +
+
+
+`; + +exports[`ShoutButton.vue toggle the button, but backend fails 1`] = ` +
+
+ + +
+ 1 +
+
+
+`; + +exports[`ShoutButton.vue when shouted renders 1`] = ` +
+
+ + +
+ 0 +
+
+
+`; diff --git a/webapp/components/_new/generic/BaseButton/BaseButton.vue b/webapp/components/_new/generic/BaseButton/BaseButton.vue index a51c3101c..7985e66c7 100644 --- a/webapp/components/_new/generic/BaseButton/BaseButton.vue +++ b/webapp/components/_new/generic/BaseButton/BaseButton.vue @@ -112,7 +112,8 @@ export default { } &.--circle { - width: $size-button-base; + width: var(--circle-button-width, $size-button-base); + height: var(--circle-button-width, $size-button-base); border-radius: 50%; } diff --git a/webapp/components/_new/generic/BaseIcon/BaseIcon.vue b/webapp/components/_new/generic/BaseIcon/BaseIcon.vue index ef09e2e03..48d408c25 100644 --- a/webapp/components/_new/generic/BaseIcon/BaseIcon.vue +++ b/webapp/components/_new/generic/BaseIcon/BaseIcon.vue @@ -55,7 +55,7 @@ export default { } &.--regular { - height: 1.2em; + height: var(--icon-size, 1.2em); } &.--large { diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js index e1704923f..58cdbd30d 100644 --- a/webapp/graphql/Fragments.js +++ b/webapp/graphql/Fragments.js @@ -158,5 +158,7 @@ export const commentFragment = gql` contentExcerpt isPostObservedByMe postObservingUsersCount + shoutedByCurrentUser + shoutedCount } ` diff --git a/webapp/pages/groups/_id/_slug.vue b/webapp/pages/groups/_id/_slug.vue index 30c5bd4d1..e4db080bc 100644 --- a/webapp/pages/groups/_id/_slug.vue +++ b/webapp/pages/groups/_id/_slug.vue @@ -131,7 +131,7 @@ -