From 50139664b5fc03c729250d030b224ec906c40980 Mon Sep 17 00:00:00 2001 From: janis Date: Fri, 25 Jul 2025 15:00:13 +0200 Subject: [PATCH 01/14] Add the ability to resolve wiki comments This MR enables the thread resolution functionality on wiki discussions. Changelog: added Signed-off-by: janis --- .../batch_comments/mixins/resolved_status.js | 5 ++ .../wiki_notes/components/note_actions.vue | 67 ++++++++++++++++++- .../components/wiki_comment_form.vue | 40 ++++++++++- .../wiki_notes/components/wiki_discussion.vue | 36 ++++++++-- .../wikis/wiki_notes/components/wiki_note.vue | 31 +++++++++ .../wiki_notes/components/wiki_notes_app.vue | 2 +- .../create_wiki_page_note.mutation.graphql | 16 ++++- ...discussion_toggle_resolve.mutation.graphql | 9 +++ .../graphql/wiki_page_note.fragment.graphql | 1 + 9 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 app/assets/javascripts/wikis/wiki_notes/graphql/discussion_toggle_resolve.mutation.graphql diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index 8f875628a9f1ea..18b9c02d5fc443 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -19,6 +19,11 @@ export default { required: false, default: false, }, + resolvedBy: { + type: Object, + required: false, + default: () => ({}), + }, }, computed: { ...mapState(useNotes, ['isDiscussionResolved']), diff --git a/app/assets/javascripts/wikis/wiki_notes/components/note_actions.vue b/app/assets/javascripts/wikis/wiki_notes/components/note_actions.vue index dfca27620af072..f16e8d43beab8c 100644 --- a/app/assets/javascripts/wikis/wiki_notes/components/note_actions.vue +++ b/app/assets/javascripts/wikis/wiki_notes/components/note_actions.vue @@ -11,6 +11,7 @@ import EmojiPicker from '~/emoji/components/picker.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { WIKI_CONTAINER_TYPE } from '~/wikis/constants'; +import discussionToggleResolveMutation from '../graphql/discussion_toggle_resolve.mutation.graphql'; export default { i18n: { @@ -74,10 +75,31 @@ export default { required: false, default: false, }, + canResolve: { + type: Boolean, + required: false, + default: false, + }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isResolving: { + type: Boolean, + required: false, + default: false, + }, + discussionId: { + type: String, + required: false, + default: '', + }, }, data() { return { isReportAbuseDrawerOpen: false, + loading: false, }; }, computed: { @@ -101,6 +123,21 @@ export default { }, ); }, + resolveIcon() { + if (!this.isResolving || !this.loading) { + return this.isResolved ? 'check-circle-filled' : 'check-circle'; + } + return null; + }, + resolveButtonTitle() { + let title = __('Resolve thread'); + + if (this.resolvedBy) { + title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name }); + } + + return title; + }, }, methods: { toggleAbuseDrawer(val) { @@ -109,11 +146,25 @@ export default { handleCopyLink() { this.$toast?.show(__('Link copied to clipboard.')); }, + async onResolve() { + this.loading = true; + try { + await this.$apollo.mutate({ + mutation: discussionToggleResolveMutation, + variables: { + id: this.discussionId, + resolve: !this.isResolved, + }, + }); + } finally { + this.loading = false; + } + }, }, }; diff --git a/app/assets/javascripts/wikis/wiki_notes/graphql/create_wiki_page_note.mutation.graphql b/app/assets/javascripts/wikis/wiki_notes/graphql/create_wiki_page_note.mutation.graphql index 08d7878808d6ca..4b939f294336aa 100644 --- a/app/assets/javascripts/wikis/wiki_notes/graphql/create_wiki_page_note.mutation.graphql +++ b/app/assets/javascripts/wikis/wiki_notes/graphql/create_wiki_page_note.mutation.graphql @@ -1,6 +1,11 @@ #import "./wiki_page_note.fragment.graphql" -mutation createWikiPageNote($input: CreateNoteInput!) { +mutation createWikiPageNote( + $input: CreateNoteInput! + $changeResolve: Boolean! + $resolve: Boolean! + $discussionId: DiscussionID! +) { createNote(input: $input) { note { id @@ -15,4 +20,13 @@ mutation createWikiPageNote($input: CreateNoteInput!) { } errors } + + discussionToggleResolve(input: { id: $discussionId, resolve: $resolve }) + @include(if: $changeResolve) { + discussion { + id + resolved + } + errors + } } diff --git a/app/assets/javascripts/wikis/wiki_notes/graphql/discussion_toggle_resolve.mutation.graphql b/app/assets/javascripts/wikis/wiki_notes/graphql/discussion_toggle_resolve.mutation.graphql new file mode 100644 index 00000000000000..4dafdb845d4c98 --- /dev/null +++ b/app/assets/javascripts/wikis/wiki_notes/graphql/discussion_toggle_resolve.mutation.graphql @@ -0,0 +1,9 @@ +mutation toggleWikiNoteResolveDiscussion($id: DiscussionID!, $resolve: Boolean!) { + discussionToggleResolve(input: { id: $id, resolve: $resolve }) { + discussion { + id + resolved + } + errors + } +} diff --git a/app/assets/javascripts/wikis/wiki_notes/graphql/wiki_page_note.fragment.graphql b/app/assets/javascripts/wikis/wiki_notes/graphql/wiki_page_note.fragment.graphql index 66c826bff5928f..4a05f6ac4090bf 100644 --- a/app/assets/javascripts/wikis/wiki_notes/graphql/wiki_page_note.fragment.graphql +++ b/app/assets/javascripts/wikis/wiki_notes/graphql/wiki_page_note.fragment.graphql @@ -34,6 +34,7 @@ fragment WikiPageNote on Note { adminNote awardEmoji createNote + resolveNote } discussion { id -- GitLab From 05a0ce485ad70495308f9d2c68daed3f8a76050e Mon Sep 17 00:00:00 2001 From: janis Date: Mon, 28 Jul 2025 13:16:30 +0200 Subject: [PATCH 02/14] Add tests to resolve action Signed-off-by: janis --- .../wiki_notes/components/note_actions.vue | 2 +- .../notes/components/note_actions_spec.js | 64 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/wikis/wiki_notes/components/note_actions.vue b/app/assets/javascripts/wikis/wiki_notes/components/note_actions.vue index f16e8d43beab8c..c83b7bc0fb9f2f 100644 --- a/app/assets/javascripts/wikis/wiki_notes/components/note_actions.vue +++ b/app/assets/javascripts/wikis/wiki_notes/components/note_actions.vue @@ -188,7 +188,7 @@ export default { v-if="canResolve" ref="resolveButton" v-gl-tooltip - data-testid="resolve-line-button" + data-testid="wiki-note-resolve-button" category="tertiary" class="note-action-button" :class="{ '!gl-text-success': isResolved }" diff --git a/spec/frontend/wikis/notes/components/note_actions_spec.js b/spec/frontend/wikis/notes/components/note_actions_spec.js index d59057866adf1e..d0f296a62c04d5 100644 --- a/spec/frontend/wikis/notes/components/note_actions_spec.js +++ b/spec/frontend/wikis/notes/components/note_actions_spec.js @@ -1,12 +1,18 @@ import { GlDisclosureDropdownGroup } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import EmojiPicker from '~/emoji/components/picker.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NoteActions from '~/wikis/wiki_notes/components/note_actions.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import discussionToggleResolveMutation from '~/wikis/wiki_notes/graphql/discussion_toggle_resolve.mutation.graphql'; + +Vue.use(VueApollo); describe('WikiNoteActions', () => { let wrapper; + let mockApollo; const findAuthorBadge = () => wrapper.findByTestId('wiki-note-user-author-badge'); const findAuthorBadgeText = () => findAuthorBadge().text().trim(); @@ -19,9 +25,12 @@ describe('WikiNoteActions', () => { const findCopyNoteButton = () => wrapper.findByTestId('wiki-note-copy-note'); const findDeleteButton = () => wrapper.findByTestId('wiki-note-delete-button'); const findEmojiPicker = () => wrapper.findComponent(EmojiPicker); + const findResolveButton = () => wrapper.findByTestId('wiki-note-resolve-button'); const createWrapper = (propsData, injectData) => { + mockApollo = createMockApollo(); return shallowMountExtended(NoteActions, { + apolloProvider: mockApollo, provide: { containerName: 'test-project', pageAuthorEmail: 'author@example.com', @@ -127,6 +136,59 @@ describe('WikiNoteActions', () => { wrapper = createWrapper({ canAwardEmoji: true }); expect(findEmojiPicker().exists()).toBe(true); }); + + it('should render a gray resolve button when canResolve is true', () => { + wrapper = createWrapper({ canResolve: true }); + expect(findResolveButton().exists()).toBe(true); + expect(findResolveButton().classes('!gl-text-success')).toBe(false); + }); + + it('should not render resolve button when canResolve is false', () => { + wrapper = createWrapper({ canResolve: false }); + expect(findResolveButton().exists()).toBe(false); + }); + + it('should render a green resolve button when isResolved is true', () => { + wrapper = createWrapper({ canResolve: true, isResolved: true }); + expect(findResolveButton().exists()).toBe(true); + expect(findResolveButton().classes('!gl-text-success')).toBe(true); + }); + + it('resolves the discussion when resolve button is clicked', async () => { + wrapper = createWrapper({ canResolve: true, discussionId: 'foo123' }); + mockApollo.defaultClient.mutate = jest.fn(); + + expect(findResolveButton().exists()).toBe(true); + + await findResolveButton().vm.$emit('click'); + await nextTick(); + + expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: discussionToggleResolveMutation, + variables: { + id: 'foo123', + resolve: true, + }, + }); + }); + + it('unresolves the discussion when discussion is resolved and resolve button is clicked', async () => { + wrapper = createWrapper({ canResolve: true, isResolved: true, discussionId: 'foo123' }); + mockApollo.defaultClient.mutate = jest.fn(); + + expect(findResolveButton().exists()).toBe(true); + + await findResolveButton().vm.$emit('click'); + await nextTick(); + + expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: discussionToggleResolveMutation, + variables: { + id: 'foo123', + resolve: false, + }, + }); + }); }); describe('actions dropdown group', () => { -- GitLab From 5e1b2b2168126759c3642aecde60b3b83206bb44 Mon Sep 17 00:00:00 2001 From: janis Date: Mon, 28 Jul 2025 13:38:25 +0200 Subject: [PATCH 03/14] Fix existing spec Signed-off-by: janis --- .../wikis/notes/components/wiki_comment_form_spec.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/frontend/wikis/notes/components/wiki_comment_form_spec.js b/spec/frontend/wikis/notes/components/wiki_comment_form_spec.js index 3c9af5d20a0406..a4c994d4332242 100644 --- a/spec/frontend/wikis/notes/components/wiki_comment_form_spec.js +++ b/spec/frontend/wikis/notes/components/wiki_comment_form_spec.js @@ -217,12 +217,12 @@ describe('WikiCommentForm', () => { await wrapper.vm.handleSave(); expect($apollo.mutate).toHaveBeenCalledWith({ mutation: expect.any(Object), - variables: { + variables: expect.objectContaining({ input: { body: 'Test comment', id: 'gid://gitlab/Note/12', }, - }, + }), }); }); @@ -231,14 +231,14 @@ describe('WikiCommentForm', () => { await wrapper.vm.handleSave(); expect($apollo.mutate).toHaveBeenCalledWith({ mutation: expect.any(Object), - variables: { + variables: expect.objectContaining({ input: { body: 'Test comment', noteableId: '1', discussionId: '1', internal: false, }, - }, + }), }); }); @@ -247,14 +247,14 @@ describe('WikiCommentForm', () => { expect($apollo.mutate).toHaveBeenCalledWith({ mutation: expect.any(Object), - variables: { + variables: expect.objectContaining({ input: { body: 'Test comment', noteableId: '1', discussionId: null, internal: false, }, - }, + }), }); }); -- GitLab From 1b484c7cc7c6ee43ba79ed1f5843e87ed6a4b42b Mon Sep 17 00:00:00 2001 From: janis Date: Mon, 28 Jul 2025 14:15:51 +0200 Subject: [PATCH 04/14] Add display specs Signed-off-by: janis --- .../components/wiki_comment_form.vue | 7 ++- .../components/wiki_comment_form_spec.js | 45 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/wikis/wiki_notes/components/wiki_comment_form.vue b/app/assets/javascripts/wikis/wiki_notes/components/wiki_comment_form.vue index 118c616e4de360..e392bd3a710a68 100644 --- a/app/assets/javascripts/wikis/wiki_notes/components/wiki_comment_form.vue +++ b/app/assets/javascripts/wikis/wiki_notes/components/wiki_comment_form.vue @@ -305,12 +305,15 @@ export default {