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 dfca27620af0728672a22b7bbfc422a9be68675f..2d947bc442ec9e4544ae61d0f2f22da189a43c2c 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, + }, + discussionId: { + type: String, + required: false, + default: '', + }, + resolvedBy: { + type: Object, + required: false, + default: null, + }, }, data() { return { isReportAbuseDrawerOpen: false, + loading: false, }; }, computed: { @@ -101,6 +123,21 @@ export default { }, ); }, + resolveIcon() { + if (!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 08d7878808d6ca5266943fd8049480ef61ed0632..7eccfda3fec332c98e247ecd93e22a9031c60bef 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,7 +1,13 @@ #import "./wiki_page_note.fragment.graphql" -mutation createWikiPageNote($input: CreateNoteInput!) { - createNote(input: $input) { +mutation createWikiPageNote( + $input: CreateNoteInput! + $changeResolve: Boolean! + $resolve: Boolean! + $discussionId: DiscussionID! + $skipCreateNote: Boolean! +) { + createNote(input: $input) @skip(if: $skipCreateNote) { note { id discussion { @@ -15,4 +21,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 0000000000000000000000000000000000000000..4dafdb845d4c982903e4de5a0d7d1f040acf344b --- /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 66c826bff5928f70ce5805e6aaad47386ff2454a..4a05f6ac4090bfe21c3fd67bc0e9caf00caeb847 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 diff --git a/spec/frontend/wikis/notes/components/note_actions_spec.js b/spec/frontend/wikis/notes/components/note_actions_spec.js index d59057866adf1e3a4533a2cc877886ce7d97a482..fb7b32c7d7a7bebb316b1ed640ba1ffde080506f 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,68 @@ 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('should show the correct tooltip for the resolve button', () => { + wrapper = createWrapper({ + canResolve: true, + isResolved: true, + resolvedBy: { name: 'user1' }, + }); + expect(findResolveButton().attributes('title')).toBe('Resolved by user1'); + }); + + 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', () => { 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 3c9af5d20a0406f6621a2f9075fbf9c4975be867..c31ae5977d3e353bd66f81667a2e66d18909da92 100644 --- a/spec/frontend/wikis/notes/components/wiki_comment_form_spec.js +++ b/spec/frontend/wikis/notes/components/wiki_comment_form_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlFormCheckbox, GlButton } from '@gitlab/ui'; +import { GlAlert, GlButton } from '@gitlab/ui'; import { nextTick } from 'vue'; import WikiCommentForm from '~/wikis/wiki_notes/components/wiki_comment_form.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -38,6 +38,8 @@ describe('WikiCommentForm', () => { }); const wikiCommentContainer = () => wrapper.findByTestId('wiki-note-comment-form-container'); + const findResolveCheckbox = () => wrapper.findByTestId('wiki-note-resolve-checkbox'); + const findUnresolveCheckbox = () => wrapper.findByTestId('wiki-note-unresolve-checkbox'); describe('user is not logged in', () => { beforeEach(() => { @@ -101,6 +103,24 @@ describe('WikiCommentForm', () => { expect(wrapper.vm.$refs.markdownEditor.autofocus).toBe(true); }); + it('should display resolve checkbox when isReply is true', () => { + wrapper = createWrapper({ props: { isReply: true } }); + expect(findResolveCheckbox().exists()).toBe(true); + expect(findUnresolveCheckbox().exists()).toBe(false); + }); + + it('should display unresolve checkbox when isReply is true and discussionResolved is true', () => { + wrapper = createWrapper({ props: { isReply: true, discussionResolved: true } }); + expect(findResolveCheckbox().exists()).toBe(false); + expect(findUnresolveCheckbox().exists()).toBe(true); + }); + + it('should not display any resolve checkbox when isReply is false', () => { + wrapper = createWrapper({ props: { isReply: false } }); + expect(findResolveCheckbox().exists()).toBe(false); + expect(findUnresolveCheckbox().exists()).toBe(false); + }); + describe('handle errors', () => { beforeEach(async () => { wrapper.vm.setError(['could not submit data']); @@ -217,12 +237,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 +251,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,18 +267,40 @@ describe('WikiCommentForm', () => { expect($apollo.mutate).toHaveBeenCalledWith({ mutation: expect.any(Object), - variables: { + variables: expect.objectContaining({ input: { body: 'Test comment', noteableId: '1', discussionId: null, internal: false, }, + }), + }); + }); + + it('should call apollo mutate with the correct data when resolve is selected', async () => { + createWrapperWithNote({ isReply: true, discussionId: '1' }); + await findResolveCheckbox().vm.$emit('input', true); + await wrapper.vm.handleSave(); + + expect($apollo.mutate).toHaveBeenCalledWith({ + mutation: expect.any(Object), + variables: { + input: { + body: 'Test comment', + noteableId: '1', + discussionId: '1', + internal: false, + }, + changeResolve: true, + discussionId: '1', + resolve: true, + skipCreateNote: false, }, }); }); - it('should not start sumitting if the user does not confirm to continue with sensitive tokens', async () => { + it('should not start submitting if the user does not confirm to continue with sensitive tokens', async () => { jest .spyOn(secretsDetection, 'detectAndConfirmSensitiveTokens') .mockImplementation(() => false); @@ -267,7 +309,7 @@ describe('WikiCommentForm', () => { expect(Boolean(wrapper.emitted('creating-note:start'))).toBe(false); }); - it('should start sumitting if the user confirms to continue with sensitive tokens', async () => { + it('should start submitting if the user confirms to continue with sensitive tokens', async () => { // also applies to when there are no sensitive tokens in the note jest .spyOn(secretsDetection, 'detectAndConfirmSensitiveTokens') @@ -338,7 +380,7 @@ describe('WikiCommentForm', () => { describe('handle comment button and internal note check box', () => { const submitButton = () => wrapper.findByTestId('wiki-note-comment-button'); - const internalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const internalNoteCheckbox = () => wrapper.findByTestId('wiki-internal-note-checkbox'); beforeEach(() => { wrapper = createWrapper({ props: { canSetInternalNote: true } }); diff --git a/spec/frontend/wikis/notes/components/wiki_discussion_spec.js b/spec/frontend/wikis/notes/components/wiki_discussion_spec.js index 4cff49bbc18107227bf6c6c165116230aa179745..495b352db5f5487712bf9f3bdc86048bab642f71 100644 --- a/spec/frontend/wikis/notes/components/wiki_discussion_spec.js +++ b/spec/frontend/wikis/notes/components/wiki_discussion_spec.js @@ -8,7 +8,7 @@ import WikiDiscussionsSignedOut from '~/wikis/wiki_notes/components/wiki_discuss import WikiCommentForm from '~/wikis/wiki_notes/components/wiki_comment_form.vue'; import * as autosave from '~/lib/utils/autosave'; import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; -import { currentUserData, note, noteableId, noteableType } from '../mock_data'; +import { currentUserData, discussion, noteableId, noteableType, note } from '../mock_data'; describe('WikiDiscussion', () => { let wrapper; @@ -20,7 +20,7 @@ describe('WikiDiscussion', () => { const createWrapper = ({ props, provideData = { userData: currentUserData } } = {}) => shallowMountExtended(WikiDiscussion, { propsData: { - discussion: [note], + discussion, noteableId, ...props, }, @@ -60,14 +60,19 @@ describe('WikiDiscussion', () => { beforeEach(() => { wrapper = createWrapper({ props: { - discussion: [ - note, - { - ...note, - body: 'another example note', - bodyHtml: '

another example note

', + discussion: { + ...discussion, + notes: { + nodes: [ + note, + { + ...note, + body: 'another example note', + bodyHtml: '

another example note

', + }, + ], }, - ], + }, }, }); }); @@ -112,27 +117,32 @@ describe('WikiDiscussion', () => { beforeEach(() => { wrapper = createWrapper({ props: { - discussion: [ - note, - { - ...note, - id: 2, - body: 'first note', - bodyHtml: '

first note

', - }, - { - ...note, - id: 3, - body: 'second note', - bodyHtml: '

second note

', - }, - { - ...note, - id: 4, - body: 'third note', - bodyHtml: '

third note

', + discussion: { + ...discussion, + notes: { + nodes: [ + note, + { + ...note, + id: 2, + body: 'first note', + bodyHtml: '

first note

', + }, + { + ...note, + id: 3, + body: 'second note', + bodyHtml: '

second note

', + }, + { + ...note, + id: 4, + body: 'third note', + bodyHtml: '

third note

', + }, + ], }, - ], + }, }, }); }); @@ -161,7 +171,7 @@ describe('WikiDiscussion', () => { describe('when user is not signed in', () => { beforeEach(() => { wrapper = createWrapper({ - props: { discussion: [note, note] }, + props: { discussion: { ...discussion, notes: { nodes: [note, note] } } }, provideData: { currentUserData: null }, }); }); @@ -181,7 +191,9 @@ describe('WikiDiscussion', () => { describe('component functions properly when user is signed in', () => { beforeEach(() => { - wrapper = createWrapper({ props: { discussion: [note, note] } }); + wrapper = createWrapper({ + props: { discussion: { ...discussion, notes: { nodes: [note, note] } } }, + }); }); it('should call clearDraft whenever toggle reply is called with a value of false', () => { diff --git a/spec/frontend/wikis/notes/components/wiki_note_spec.js b/spec/frontend/wikis/notes/components/wiki_note_spec.js index 47161be0535b3995845dc3db1101f0dd27a7b967..b8909ba696db97aa366bac05dcf2a573e653c16b 100644 --- a/spec/frontend/wikis/notes/components/wiki_note_spec.js +++ b/spec/frontend/wikis/notes/components/wiki_note_spec.js @@ -40,6 +40,10 @@ describe('WikiNote', () => { return shallowMountExtended(WikiNote, { propsData: { noteableId, + resolvable: true, + isResolved: true, + discussionId: '1', + resolvedBy: { name: 'user1', id: '1' }, ...props, }, mocks: { @@ -116,9 +120,28 @@ describe('WikiNote', () => { showEdit: false, canReportAsAbuse: true, accessLevel: 'Maintainer', + canResolve: true, + isResolved: false, + discussionId: '1', }); }); + it('renders note actions without resolve button when the user cannot resolve', () => { + const noteWithoutPermission = { + ...note, + userPermissions: { + ...note.userPermissions, + resolveNote: false, + }, + }; + + wrapper = createWrapper({ note: noteWithoutPermission }); + + const noteActions = wrapper.findComponent(NoteActions); + + expect(noteActions.props('canResolve')).not.toBe(true); + }); + it('renders note body correctly', () => { const noteBody = wrapper.findComponent(NoteBody); diff --git a/spec/frontend/wikis/notes/components/wiki_notes_app_spec.js b/spec/frontend/wikis/notes/components/wiki_notes_app_spec.js index 1e09717b347dab5bf80dbe6bca4337c014376e3a..f638024186f58507bb519edfa50447a2e6b7e274 100644 --- a/spec/frontend/wikis/notes/components/wiki_notes_app_spec.js +++ b/spec/frontend/wikis/notes/components/wiki_notes_app_spec.js @@ -44,6 +44,7 @@ const mockDiscussion = (...children) => { adminNote: true, awardEmoji: true, createNote: true, + resolveNote: true, }, discussion: null, })), @@ -242,15 +243,21 @@ describe('WikiNotesApp', () => { expect(wikiDiscussions.at(1).props('noteableId')).toEqual('gid://gitlab/WikiPage/1'); expect(wikiDiscussions.at(2).props('noteableId')).toEqual('gid://gitlab/WikiPage/1'); - expect(wikiDiscussions.at(0).props('discussion')).toHaveLength(1); - expect(wikiDiscussions.at(1).props('discussion')).toHaveLength(1); - expect(wikiDiscussions.at(2).props('discussion')).toHaveLength(3); + expect(wikiDiscussions.at(0).props('discussion').notes.nodes).toHaveLength(1); + expect(wikiDiscussions.at(1).props('discussion').notes.nodes).toHaveLength(1); + expect(wikiDiscussions.at(2).props('discussion').notes.nodes).toHaveLength(3); - expect(wikiDiscussions.at(0).props('discussion')[0].body).toEqual('Discussion 1'); - expect(wikiDiscussions.at(1).props('discussion')[0].body).toEqual('Discussion 2'); - expect(wikiDiscussions.at(2).props('discussion')[0].body).toEqual('Discussion 3 Note 1'); - expect(wikiDiscussions.at(2).props('discussion')[1].body).toEqual('Discussion 3 Note 2'); - expect(wikiDiscussions.at(2).props('discussion')[2].body).toEqual('Discussion 3 Note 3'); + expect(wikiDiscussions.at(0).props('discussion').notes.nodes[0].body).toEqual('Discussion 1'); + expect(wikiDiscussions.at(1).props('discussion').notes.nodes[0].body).toEqual('Discussion 2'); + expect(wikiDiscussions.at(2).props('discussion').notes.nodes[0].body).toEqual( + 'Discussion 3 Note 1', + ); + expect(wikiDiscussions.at(2).props('discussion').notes.nodes[1].body).toEqual( + 'Discussion 3 Note 2', + ); + expect(wikiDiscussions.at(2).props('discussion').notes.nodes[2].body).toEqual( + 'Discussion 3 Note 3', + ); }); it('should not render error alert', () => { @@ -310,7 +317,7 @@ describe('WikiNotesApp', () => { wikiDiscussions.at(2).vm.$emit('note-deleted', discussions.nodes[2].notes.nodes[0].id); await nextTick(); - const findNotes = () => wikiDiscussions.at(2).props('discussion'); + const findNotes = () => wikiDiscussions.at(2).props('discussion').notes.nodes; expect(findNotes()).toHaveLength(2); expect(findNotes()).not.toContainEqual({ id: discussions.nodes[2].notes.nodes[0].id, diff --git a/spec/frontend/wikis/notes/mock_data.js b/spec/frontend/wikis/notes/mock_data.js index 4495c8bc92397c7c32dab5f4934792caeae8c408..41b9d9031ed5b68113b69e5b6edd7c4abf8cebd6 100644 --- a/spec/frontend/wikis/notes/mock_data.js +++ b/spec/frontend/wikis/notes/mock_data.js @@ -73,6 +73,7 @@ export const note = { adminNote: false, awardEmoji: false, createNote: true, + resolveNote: true, }, discussion: { __typename: 'Discussion', @@ -120,3 +121,21 @@ export const wikiCommentFormProvideData = { markdownDocsPath, isContainerArchived, }; + +export const discussion = { + __typename: 'Discussion', + id: 'gid://gitlab/Discussion/1', + resolvable: true, + resolved: false, + resolvedAt: null, + resolvedBy: { + id: 'gid://gitlab/User/1', + name: 'user1', + }, + userPermissions: { + resolveNote: true, + }, + notes: { + nodes: [note], + }, +};