From 374c7136d2d5aad741d71112df479e561d3faa0b Mon Sep 17 00:00:00 2001 From: Salihu Date: Tue, 5 Nov 2024 23:59:53 +0100 Subject: [PATCH] Add support for top level discussions in Wikis In project and group wikis, add support for top level comments and discussions. This allows adding, removing and editing comments. You can also link to existing comments and report abuse. --- .../javascripts/graphql_shared/utils.js | 2 +- app/assets/javascripts/notes/i18n.js | 1 + .../javascripts/pages/shared/wikis/show.js | 2 + .../wiki_notes/components/note_actions.vue | 160 ++++++ .../wikis/wiki_notes/components/note_body.vue | 123 +++++ .../wiki_notes/components/note_header.vue | 157 ++++++ .../wiki_notes/components/ordered_layout.vue | 27 + .../components/placeholder_note.vue | 90 ++++ .../components/wiki_comment_form.vue | 346 ++++++++++++ .../wiki_notes/components/wiki_discussion.vue | 160 ++++++ .../components/wiki_discussion_locked.vue | 47 ++ .../wiki_discussions_signed_out.vue | 44 ++ .../wikis/wiki_notes/components/wiki_note.vue | 224 ++++++++ .../components/wiki_notes_activity_header.vue | 17 + .../wiki_notes/components/wiki_notes_app.vue | 157 ++++++ .../pages/shared/wikis/wiki_notes/index.js | 59 +++ .../pages/shared/wikis/wiki_notes/utils.js | 26 + app/assets/stylesheets/page_bundles/wiki.scss | 13 + app/views/shared/wikis/show.html.haml | 18 + locale/gitlab.pot | 30 ++ .../notes/components/note_actions_spec.js | 149 ++++++ .../wikis/notes/components/note_body_spec.js | 129 +++++ .../notes/components/note_header_spec.js | 212 ++++++++ .../notes/components/ordered_layout_spec.js | 51 ++ .../notes/components/placeholder_note_spec.js | 108 ++++ .../components/wiki_comment_form_spec.js | 497 ++++++++++++++++++ .../components/wiki_discussion_locked_spec.js | 91 ++++ .../notes/components/wiki_discussion_spec.js | 212 ++++++++ .../wikis/notes/components/wiki_note_spec.js | 378 +++++++++++++ .../notes/components/wiki_notes_app_spec.js | 233 ++++++++ .../notes/components/wiki_signed_out_spec.js | 55 ++ .../pages/shared/wikis/notes/mock_data.js | 95 ++++ .../pages/shared/wikis/notes/utils_spec.js | 71 +++ 33 files changed, 3983 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_actions.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_body.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_header.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/ordered_layout.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/placeholder_note.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_comment_form.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussion.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussion_locked.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussions_signed_out.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_note.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_notes_activity_header.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_notes_app.vue create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/index.js create mode 100644 app/assets/javascripts/pages/shared/wikis/wiki_notes/utils.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/note_actions_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/note_body_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/note_header_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/ordered_layout_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/placeholder_note_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/wiki_comment_form_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/wiki_discussion_locked_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/wiki_discussion_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/wiki_note_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/wiki_notes_app_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/components/wiki_signed_out_spec.js create mode 100644 spec/frontend/pages/shared/wikis/notes/mock_data.js create mode 100644 spec/frontend/pages/shared/wikis/notes/utils_spec.js diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index fb7f5124e6d689..fcf89b1d54e6bf 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -17,7 +17,7 @@ export const isGid = (id) => { return false; }; -const parseGid = (gid) => { +export const parseGid = (gid) => { const [type, id] = `${gid}`.replace(/gid:\/\/gitlab\//g, '').split('/'); return { type, id }; }; diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js index ba7322b753059e..384aa841a5cfc5 100644 --- a/app/assets/javascripts/notes/i18n.js +++ b/app/assets/javascripts/notes/i18n.js @@ -7,6 +7,7 @@ export const COMMENT_FORM = { error: __('Comment could not be submitted: %{reason}.'), note: __('Note'), comment: __('Comment'), + wiki: __('Wiki'), internalComment: __('Add internal note'), issue: __('issue'), startThread: __('Start thread'), diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js index 9c469ff5353161..7b788b1a07878b 100644 --- a/app/assets/javascripts/pages/shared/wikis/show.js +++ b/app/assets/javascripts/pages/shared/wikis/show.js @@ -7,6 +7,7 @@ import SidebarResizer from './components/sidebar_resizer.vue'; import Wikis from './wikis'; import WikiContentApp from './app.vue'; import WikiSidebarEntries from './components/wiki_sidebar_entries.vue'; +import initNotesApp from './wiki_notes/index'; const mountSidebarResizer = () => { const resizer = document.querySelector('.js-wiki-sidebar-resizer'); @@ -107,6 +108,7 @@ export const mountWikiSidebarEntries = () => { export const mountApplications = () => { mountWikiContentApp(); mountSidebarResizer(); + initNotesApp(); new Wikis(); // eslint-disable-line no-new }; diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_actions.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_actions.vue new file mode 100644 index 00000000000000..764150a983868b --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_actions.vue @@ -0,0 +1,160 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_body.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_body.vue new file mode 100644 index 00000000000000..5e5d9494bf1730 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_body.vue @@ -0,0 +1,123 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_header.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_header.vue new file mode 100644 index 00000000000000..6b271cd588167e --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/note_header.vue @@ -0,0 +1,157 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/ordered_layout.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/ordered_layout.vue new file mode 100644 index 00000000000000..457b9e2ed00682 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/ordered_layout.vue @@ -0,0 +1,27 @@ + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/placeholder_note.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/placeholder_note.vue new file mode 100644 index 00000000000000..54eb86e14432e7 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/placeholder_note.vue @@ -0,0 +1,90 @@ + + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_comment_form.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_comment_form.vue new file mode 100644 index 00000000000000..868fca2f54dc2d --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_comment_form.vue @@ -0,0 +1,346 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussion.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussion.vue new file mode 100644 index 00000000000000..89966550cb7c2b --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussion.vue @@ -0,0 +1,160 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussion_locked.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussion_locked.vue new file mode 100644 index 00000000000000..8b80c46f5e42aa --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussion_locked.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussions_signed_out.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussions_signed_out.vue new file mode 100644 index 00000000000000..2ac97bb4f5e183 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_discussions_signed_out.vue @@ -0,0 +1,44 @@ + + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_note.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_note.vue new file mode 100644 index 00000000000000..3a1cfd0e5b2246 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_note.vue @@ -0,0 +1,224 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_notes_activity_header.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_notes_activity_header.vue new file mode 100644 index 00000000000000..30eb40186f75db --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_notes_activity_header.vue @@ -0,0 +1,17 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_notes_app.vue b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_notes_app.vue new file mode 100644 index 00000000000000..e7aa8d3aa6e8c8 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/components/wiki_notes_app.vue @@ -0,0 +1,157 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/index.js b/app/assets/javascripts/pages/shared/wikis/wiki_notes/index.js new file mode 100644 index 00000000000000..3f263c7ac9ee93 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/index.js @@ -0,0 +1,59 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import createApolloClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import WikiNotesApp from './components/wiki_notes_app.vue'; + +export default () => { + const el = document.querySelector('#js-vue-wiki-notes-app'); + + if (!el) return false; + + // TODO: create a locked wiki dicsussion docs path + const { + pageInfo, + registerPath, + signInPath, + containerId, + containerType, + currentUserData, + markdownPreviewPath, + noteableType, + isContainerArchived, + notesFilters, + reportAbusePath, + } = el.dataset; + + if (!pageInfo) return false; + + Vue.use(VueApollo); + const apolloProvider = new VueApollo({ defaultClient: createApolloClient() }); + + return new Vue({ + el, + apolloProvider, + provide: { + pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)), + containerId, + containerType, + markdownPreviewPath, + currentUserData: JSON.parse(currentUserData || {}), + reportAbusePath, + registerPath, + signInPath, + noteableType, + noteCount: 5, + lockedWikiDocsPath: '', + markdownDocsPath: helpPagePath('user/markdown.md'), + archivedProjectDocsPath: helpPagePath('user/project/working_with_projects.md', { + anchor: 'archive-a-project', + }), + notesFilters: JSON.parse(notesFilters || {}), + isContainerArchived: isContainerArchived === undefined ? false : isContainerArchived !== null, + }, + render(createElement) { + return createElement(WikiNotesApp); + }, + }); +}; diff --git a/app/assets/javascripts/pages/shared/wikis/wiki_notes/utils.js b/app/assets/javascripts/pages/shared/wikis/wiki_notes/utils.js new file mode 100644 index 00000000000000..6a24134932a738 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/wiki_notes/utils.js @@ -0,0 +1,26 @@ +import { COMMENT_FORM } from '~/notes/i18n'; +import { sprintf, __ } from '~/locale'; +import { parseGid, isGid } from '~/graphql_shared/utils'; + +export const createNoteErrorMessages = (err) => { + // TODO: make error message more specific + if (err?.graphQLErrors?.length || err?.clientErrors) { + return [ + sprintf( + COMMENT_FORM.error, + { + reason: __( + 'An unexpected error occurred trying to submit your comment. Please try again.', + ), + }, + false, + ), + ]; + } + + return [COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK]; +}; + +export const getIdFromGid = (val) => (isGid(val) ? parseGid(val).id : val); + +export const getAutosaveKey = (noteableType, noteId) => `Note/${noteableType}/${noteId}`; diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index dfdee05977f23f..106fc54f8308f6 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -146,3 +146,16 @@ ul.wiki-pages-list.content-list { display: none !important; } } + +.edited-text { + color: var(--gray-500, $gray-500); + display: block; + margin: 16px 0 0; + font-size: $gl-font-size-small; + + .author-link { + color: var(--gray-700, $gray-700); + font-size: $gl-font-size-small; + } +} + diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 20317a8230b518..a7ee6f2bd1ccfd 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -32,4 +32,22 @@ templates: templates.to_json, } } +- if @wiki.container.wiki_comments_feature_flag_enabled? + #js-vue-wiki-notes-app{ + data: { + testid: 'wiki-notes-app', + container_id: @wiki.container.id, + container_type: @wiki.container.is_a?(Project) ? 'project' : 'group', + current_user_data: UserSerializer.new.represent(current_user, {only_path: false}, CurrentUserEntity).to_json, + page_info: wiki_page_info(@page, uploads_path: wiki_attachment_upload_url).to_json, + register_path: new_user_registration_path(redirect_to_referer: 'yes'), + sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'), + markdown_preview_path: preview_markdown_path(@wiki.container), + noteable_type: @noteable_type, + is_container_archived: @wiki.container.is_a?(Project) ? @wiki.container.archived : false, + notes_filters: UserPreference.notes_filters.to_json, + report_abuse_path: add_category_abuse_reports_path + } + } + = render 'shared/wikis/sidebar' diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2dc61e496f13b7..3b5e63860907d4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -6338,6 +6338,9 @@ msgstr[1] "" msgid "An unauthenticated user" msgstr "" +msgid "An unexpected error occurred trying to submit your comment. Please try again." +msgstr "" + msgid "An unexpected error occurred while checking the project environment." msgstr "" @@ -7407,6 +7410,9 @@ msgstr "" msgid "Are you sure you want to attempt to merge?" msgstr "" +msgid "Are you sure you want to cancel %{actionText} this comment?" +msgstr "" + msgid "Are you sure you want to cancel editing this %{commentType}?" msgstr "" @@ -19761,6 +19767,9 @@ msgstr "" msgid "Discard" msgstr "" +msgid "Discard Changes" +msgstr "" + msgid "Discard all changes" msgstr "" @@ -41085,6 +41094,9 @@ msgstr "" msgid "Please %{link_to_register} or %{link_to_sign_in} to comment" msgstr "" +msgid "Please %{registerLinkStart}register%{registerLinkEnd} or %{signInLinkStart}sign in%{signInLinkEnd} to add a comment." +msgstr "" + msgid "Please %{registerLinkStart}register%{registerLinkEnd} or %{signInLinkStart}sign in%{signInLinkEnd} to reply." msgstr "" @@ -52702,6 +52714,9 @@ msgstr "" msgid "Something went wrong while fetching branches" msgstr "" +msgid "Something went wrong while fetching comments. Please refresh the page." +msgstr "" + msgid "Something went wrong while fetching comments. Please try again." msgstr "" @@ -55189,6 +55204,9 @@ msgstr "" msgid "The discussion in this %{noteableTypeText} is locked." msgstr "" +msgid "The discussion in this Wiki is locked. Only project members can comment." +msgstr "" + msgid "The discussion in this merge request is locked." msgstr "" @@ -56291,6 +56309,9 @@ msgstr "" msgid "This group does not have any group runners yet." msgstr "" +msgid "This group has been scheduled for deletion and cannot be commented on." +msgstr "" + msgid "This group has no active access tokens." msgstr "" @@ -62164,6 +62185,9 @@ msgstr "" msgid "Wiki" msgstr "" +msgid "Wiki comment form" +msgstr "" + msgid "Wiki page" msgstr "" @@ -63351,6 +63375,9 @@ msgstr "" msgid "Write" msgstr "" +msgid "Write a comment or drag your files here..." +msgstr "" + msgid "Write a comment or drag your files here…" msgstr "" @@ -65313,6 +65340,9 @@ msgstr "" msgid "contains invalid URLs (%{urls})" msgstr "" +msgid "continue %{actionText}" +msgstr "" + msgid "contribute to this project." msgstr "" diff --git a/spec/frontend/pages/shared/wikis/notes/components/note_actions_spec.js b/spec/frontend/pages/shared/wikis/notes/components/note_actions_spec.js new file mode 100644 index 00000000000000..c9bc247c80cec7 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/note_actions_spec.js @@ -0,0 +1,149 @@ +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import NoteActions from '~/pages/shared/wikis/wiki_notes/components/note_actions.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +describe('WikiNoteActions', () => { + let wrapper; + + const findDisclosureDropdownGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup); + const findReportAbuseButton = () => wrapper.findByTestId('wiki-note-report-abuse-button'); + const findEditButton = () => wrapper.findByTestId('wiki-note-edit-button'); + const findReplyButton = () => wrapper.findByTestId('wiki-note-reply-button'); + const findCopyNoteButton = () => wrapper.findByTestId('wiki-note-copy-note'); + const findDeleteButton = () => wrapper.findByTestId('wiki-note-delete-button'); + + const createWrapper = (propsData) => { + return shallowMountExtended(NoteActions, { + propsData: { + authorId: '1', + ...propsData, + }, + }); + }; + + describe('renders correctly', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + describe('note actions', () => { + it('should not render any actions by default', () => { + expect(findEditButton().exists()).toBe(false); + expect(findReportAbuseButton().exists()).toBe(false); + expect(findDisclosureDropdownGroup().exists()).toBe(false); + }); + + it('should render edit button when showEdit is true', () => { + wrapper = createWrapper({ showEdit: true }); + expect(findEditButton().exists()).toBe(true); + }); + + it('should render reply button when showReply is true', () => { + wrapper = createWrapper({ showReply: true }); + expect(findReplyButton().exists()).toBe(true); + }); + }); + + describe('actions dropdown group', () => { + it('should render the dropdown group when canReportAsAbuse is true', () => { + wrapper = createWrapper({ canReportAsAbuse: true }); + expect(findDisclosureDropdownGroup().exists()).toBe(true); + }); + + it('should render the dropdown group when showEdit is true', () => { + wrapper = createWrapper({ showEdit: true }); + expect(findDisclosureDropdownGroup().exists()).toBe(true); + }); + + it('should render the dropdown group when both canReportAsAbuse and showEdit are true', () => { + wrapper = createWrapper({ canReportAsAbuse: true, showEdit: true }); + expect(findDisclosureDropdownGroup().exists()).toBe(true); + }); + + it('should not render the dropdown group when neither canReportAsAbuse nor showEdit is true', () => { + wrapper = createWrapper({ canReportAsAbuse: false, showEdit: false }); + expect(findDisclosureDropdownGroup().exists()).toBe(false); + }); + }); + + describe('actions dropdown', () => { + it('should not render copy link button when noteUrl is empty', () => { + wrapper = createWrapper({ canReportAsAbuse: true }); + expect(findCopyNoteButton().exists()).toBe(false); + }); + + it('should render copy link button when noteUrl is provided', () => { + wrapper = createWrapper({ canReportAsAbuse: true, noteUrl: 'example.com' }); + expect(findCopyNoteButton().exists()).toBe(true); + }); + + it('should not render delete button when showEdit is false', () => { + wrapper = createWrapper({ canReportAsAbuse: true, showEdit: false }); + expect(findDeleteButton().exists()).toBe(false); + }); + + it('should render delete button when showEdit is true', () => { + wrapper = createWrapper({ canReportAsAbuse: true, showEdit: true }); + expect(findDeleteButton().exists()).toBe(true); + }); + + it('should not render report as abuse button when canReportAsAbuse is false', () => { + wrapper = createWrapper({ canReportAsAbuse: false, showEdit: true }); + expect(findReportAbuseButton().exists()).toBe(false); + }); + + it('should render report as abuse button when canReportAsAbuse is true', () => { + wrapper = createWrapper({ canReportAsAbuse: true }); + expect(findReportAbuseButton().exists()).toBe(true); + }); + }); + }); + + describe('actions function correctly', () => { + beforeEach(() => { + wrapper = createWrapper({ + showReply: true, + showEdit: true, + canReportAsAbuse: true, + }); + }); + + describe('note actions', () => { + it('emits reply event when reply is clicked', () => { + findReplyButton().vm.$emit('click'); + expect(Boolean(wrapper.emitted('reply'))).toBe(true); + }); + + it('emits edit event when edit is clicked', () => { + findEditButton().vm.$emit('click'); + expect(Boolean(wrapper.emitted('edit'))).toBe(true); + }); + }); + + describe('actions dropdown', () => { + const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); + + it('emits delete event when the delete button is clicked', () => { + findDeleteButton().vm.$emit('action'); + expect(Boolean(wrapper.emitted('delete'))).toBe(true); + }); + + it('shows report as abuse drawer when report as abuse', async () => { + await findReportAbuseButton().vm.$emit('action'); + + expect(findAbuseCategorySelector().props('showDrawer')).toEqual(true); + }); + + it('closes report as abuse drawer when it emits the close-drawer event', async () => { + await findReportAbuseButton().vm.$emit('action'); + findAbuseCategorySelector().vm.$emit('close-drawer'); + + await nextTick(); + + expect(findAbuseCategorySelector().exists()).toEqual(false); + }); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/note_body_spec.js b/spec/frontend/pages/shared/wikis/notes/components/note_body_spec.js new file mode 100644 index 00000000000000..aa324c143dd31b --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/note_body_spec.js @@ -0,0 +1,129 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import NoteEditedText from '~/notes/components/note_edited_text.vue'; +import WikiCommentForm from '~/pages/shared/wikis/wiki_notes/components/wiki_comment_form.vue'; +import NoteBody from '~/pages/shared/wikis/wiki_notes/components/note_body.vue'; +import { wikiCommentFormProvideData, note, noteableId } from '../mock_data'; + +describe('NoteBody', () => { + let wrapper; + + const createWrapper = (propsData) => + shallowMountExtended(NoteBody, { + propsData: { + note, + noteableId, + ...propsData, + }, + provide: wikiCommentFormProvideData, + stubs: { + WikiCommentForm, + }, + }); + + describe('renders correctly', () => { + describe('when is editing is false', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render note content correctly', async () => { + const content = await wrapper.findByTestId('wiki-note-content').text(); + + expect(content).toBe('an example note'); + }); + + it('should not render "Edited" text when lastEdited is the same as createdAt', () => { + const editedComponent = wrapper.findComponent(NoteEditedText); + + expect(editedComponent.exists()).toBe(false); + }); + + it('should render "Edited" text when lastEditedAt is not the same as createdAt', () => { + // remounting to trigger mounted function + wrapper = createWrapper({ + note: { ...note, lastEditedAt: '2024-11-11T08:11:34Z' }, + noteableId, + }); + + const editedComponent = wrapper.findComponent(NoteEditedText); + expect(editedComponent.exists()).toBe(true); + }); + }); + + describe('when is editing is true', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render note content in wiki comment form if hasDrafts is false', async () => { + await wrapper.setProps({ isEditing: true }); + await nextTick(); + + const wikiCommentForm = wrapper.findComponent(WikiCommentForm); + await nextTick(); + + expect(wikiCommentForm.vm.$refs.markdownEditor.value).toBe('an example note'); + }); + + it('should not render note content in wiki comment form if hasDrafts is true', async () => { + // making sure this component is not setting the content of the wiki comment form if there is a draft + Object.defineProperty(wrapper.vm, 'hasDraft', { + get() { + return true; + }, + }); + + wrapper.setProps({ isEditing: true }); + await nextTick(); + + const wikiCommentForm = wrapper.findComponent(WikiCommentForm); + await nextTick(); + + expect(wikiCommentForm.vm.$refs.markdownEditor.value).toBe(''); + }); + }); + }); + + describe('comment form', () => { + beforeEach(() => { + wrapper = createWrapper({ isEditing: true }); + }); + + it('should emit "cancel:edit" event when cancel event is emitted from the comment form', async () => { + const wikiCommentForm = wrapper.findComponent(WikiCommentForm); + wikiCommentForm.vm.$emit('cancel'); + + await nextTick(); + expect(wrapper.emitted('cancel:edit')).toHaveLength(1); + }); + + it('should emit "creating-note:start" event when creating-note:start event is emitted from the comment form', async () => { + const wikiCommentForm = wrapper.findComponent(WikiCommentForm); + wikiCommentForm.vm.$emit('creating-note:start'); + await nextTick(); + expect(wrapper.emitted('creating-note:start')).toHaveLength(1); + }); + + it('should emit "creating-note:done" event when creating-note:done event is emitted from the comment form', async () => { + const wikiCommentForm = wrapper.findComponent(WikiCommentForm); + wikiCommentForm.vm.$emit('creating-note:done'); + await nextTick(); + expect(wrapper.emitted('creating-note:done')).toHaveLength(1); + }); + + it('should update note text correctly when creating-note:success event is emitted from the comment form', async () => { + const wikiCommentForm = wrapper.findComponent(WikiCommentForm); + wikiCommentForm.vm.$emit('creating-note:success', { + ...note, + body: 'updated note', + bodyHtml: '

updated note

', + }); + + wrapper.setProps({ isEditing: false }); + await nextTick(); + + expect(await wrapper.findByTestId('wiki-note-content').text()).toBe('updated note'); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/note_header_spec.js b/spec/frontend/pages/shared/wikis/notes/components/note_header_spec.js new file mode 100644 index 00000000000000..7cbb3d29f672ac --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/note_header_spec.js @@ -0,0 +1,212 @@ +import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import NoteHeader from '~/pages/shared/wikis/wiki_notes/components/note_header.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('NoteHeader', () => { + let wrapper; + + const author = { + id: 1, + name: 'John Doe', + username: 'johndoe', + path: '/johndoe', + webUrl: 'https://example.com/johndoe', + }; + + const createWrapper = (propsData = {}) => + shallowMountExtended(NoteHeader, { + propsData, + }); + + describe('renders correctly', () => { + const shouldNotDisplayExternalParticipantText = () => { + expect(wrapper.findByText('(external participant)').exists()).toBe(false); + }; + + describe('when author prop is not passed', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should display "A deleted user" text', () => { + expect(wrapper.findByText('A deleted user').exists()).toBe(true); + }); + + it('should not display author name', () => { + expect(wrapper.findByTestId('wiki-note-author-name').exists()).toBe(false); + }); + + it('should not display author username', () => { + expect(wrapper.findByTestId('wiki-note-author-name').exists()).toBe(false); + }); + + it('should not display external participant text', () => { + shouldNotDisplayExternalParticipantText(); + }); + }); + + describe('when author is author prop is passed', () => { + beforeEach(() => { + wrapper = createWrapper({ author }); + }); + + it('should not display "A deleted user"', () => { + expect(wrapper.findByText('A deleted user').exists()).toBe(false); + }); + + describe('email participant is set', () => { + beforeEach(() => { + wrapper = createWrapper({ author, emailParticipant: 'john@example.com' }); + }); + + it('should not render author name link', () => { + expect(wrapper.find('a[data-testid="wiki-note-author-name-link"]').exists()).toBe(false); + }); + + it('should render author name correclty', async () => { + const authorName = await wrapper.findByTestId('wiki-note-author-name').text(); + expect(authorName).toBe('John Doe'); + }); + + it('should not render author username', () => { + expect(wrapper.findByTestId('wiki-note-username').exists()).toBe(false); + }); + + it('should display external participant text', () => { + expect(wrapper.findByText('(external participant)').exists()).toBe(true); + }); + }); + + describe('email participant is not set', () => { + it('should render author name in link', async () => { + const authorNameLink = wrapper.find('a[data-testid="wiki-note-author-name-link"]'); + const authorName = authorNameLink.find('[data-testid="wiki-note-author-name"]'); + + expect(await authorName.text()).toBe('John Doe'); + }); + + it('should render author name link with href to author path when author path is set', () => { + const authorNameLink = wrapper.find('a[data-testid="wiki-note-author-name-link"]'); + expect(authorNameLink.attributes('href')).toBe('/johndoe'); + }); + + it('should default to author webUrl for author name link author path is not set', () => { + wrapper = createWrapper({ author: { ...author, path: null } }); + const authorNameLink = wrapper.find('a[data-testid="wiki-note-author-name-link"]'); + expect(authorNameLink.attributes('href')).toBe('https://example.com/johndoe'); + }); + + it('should render author username correctly', async () => { + const authorUsernameLink = wrapper.find( + 'a[data-testid="wiki-note-author-username-link"]', + ); + const authorUsername = await authorUsernameLink + .find('[data-testid="wiki-note-username"]') + .text(); + expect(authorUsername).toBe('@johndoe'); + }); + + it('should render author username link with href to author path when it is set', () => { + const authorUsernameLink = wrapper.find( + 'a[data-testid="wiki-note-author-username-link"]', + ); + expect(authorUsernameLink.attributes('href')).toBe('/johndoe'); + }); + + it('should default to author webUrl for author username link author path is not set', () => { + wrapper = createWrapper({ author: { ...author, path: null } }); + const authorUsernameLink = wrapper.find( + 'a[data-testid="wiki-note-author-username-link"]', + ); + expect(authorUsernameLink.attributes('href')).toBe('https://example.com/johndoe'); + }); + + it('should note display external participant text', () => { + shouldNotDisplayExternalParticipantText(); + }); + }); + }); + describe('created at', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should not render time ago tooltip when createdAt prop is not passed', () => { + expect(wrapper.findComponent(TimeAgoTooltip).exists()).toBe(false); + }); + + it('should render time ago tooltip when createdAt prop is passed', () => { + wrapper = createWrapper({ createdAt: '2021-01-01T00:00:00.000Z' }); + const toolTip = wrapper.findComponent(TimeAgoTooltip); + + expect(toolTip.exists()).toBe(true); + }); + }); + + describe('internal note', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should not render internal note badge when isInternalNote prop is not passed', () => { + expect(wrapper.findComponent(GlBadge).exists()).toBe(false); + }); + + it('should render internal note badge correctly when isInternalNote prop is true', async () => { + wrapper = createWrapper({ isInternalNote: true }); + const glBadge = wrapper.findComponent(GlBadge); + + expect(glBadge.element.getAttribute('title')).toBe( + 'This internal note will always remain confidential', + ); + expect(await glBadge.text()).toBe('Internal note'); + }); + }); + + describe('showSpinner', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should not render loading icon when showSpinner prop is not passed', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + }); + + it('should render loading icon when showSpinner prop is true', () => { + wrapper = createWrapper({ showSpinner: true }); + const loadingIconLabel = wrapper.findComponent(GlLoadingIcon); + expect(loadingIconLabel.exists()).toBe(true); + }); + }); + }); + + describe('when author username link is hovered', () => { + const hoverUserNameLink = async () => { + await wrapper.findByTestId('wiki-note-author-username-link').trigger('mouseenter'); + }; + + const leaveUserNameLink = async () => { + await wrapper.findByTestId('wiki-note-author-username-link').trigger('mouseleave'); + }; + + beforeEach(() => { + wrapper = createWrapper({ author }); + }); + + it('should underline author Name link', async () => { + await hoverUserNameLink(); + const { classList } = wrapper.findByTestId('wiki-note-author-name-link').element; + + expect(classList.contains('text-underline')).toBe(true); + }); + + it('should remove underline from author name link when the cursor leaves the username link', async () => { + await hoverUserNameLink(); + await leaveUserNameLink(); + + const { classList } = wrapper.findByTestId('wiki-note-author-name-link').element; + expect(classList.contains('text-underline')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/ordered_layout_spec.js b/spec/frontend/pages/shared/wikis/notes/components/ordered_layout_spec.js new file mode 100644 index 00000000000000..8bd17806a5492a --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/ordered_layout_spec.js @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils'; +import OrderedLayout from '~/pages/shared/wikis/wiki_notes/components/ordered_layout.vue'; + +const children = ` + + + + `; + +const TestComponent = { + components: { OrderedLayout }, + template: ` +
+ + ${children} + +
+ `, +}; + +describe('Ordered Layout', () => { + let wrapper; + + const verifyOrder = () => + wrapper.findAll('footer,header,main').wrappers.map((x) => x.element.tagName.toLowerCase()); + + const createComponent = (props = {}) => { + wrapper = mount(TestComponent, { + propsData: props, + }); + }; + + it.each` + slotKeys + ${['header', 'main', 'footer']} + ${['header', 'footer', 'main']} + ${['main', 'header', 'footer']} + ${['main', 'footer', 'header']} + ${['footer', 'header', 'main']} + ${['footer', 'main', 'header']} + `('should render main in the correct order', ({ slotKeys }) => { + createComponent({ slotKeys }); + expect(verifyOrder()).toEqual(slotKeys); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/placeholder_note_spec.js b/spec/frontend/pages/shared/wikis/notes/components/placeholder_note_spec.js new file mode 100644 index 00000000000000..7646d1d7cc0704 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/placeholder_note_spec.js @@ -0,0 +1,108 @@ +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import PlaceholderNote from '~/pages/shared/wikis/wiki_notes/components/placeholder_note.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import { currentUserData, note } from '../mock_data'; + +describe('PlaceholderNote', () => { + let wrapper; + + const createWrapper = ({ props, provideData } = {}) => + shallowMountExtended(PlaceholderNote, { + propsData: { + note, + ...props, + }, + provide: { + currentUserData, + ...provideData, + }, + stubs: { + TimelineEntryItem, + }, + }); + + describe('renders correctly', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render user avatar with link to current user path when set', () => { + const avatarLink = wrapper.findComponent(GlAvatarLink); + expect(avatarLink.attributes('href')).toBe(currentUserData.path); + }); + + it('user avatar ref should default to web url when current user path is not set', () => { + wrapper = createWrapper({ + provideData: { + currentUserData: { + ...currentUserData, + path: null, + }, + }, + }); + const avatarLink = wrapper.findComponent(GlAvatarLink); + expect(avatarLink.attributes('href')).toBe(currentUserData.web_url); + }); + + describe('user avatar', () => { + let avatarLink; + let avatar; + + beforeEach(() => { + avatarLink = wrapper.findComponent(GlAvatarLink); + avatar = avatarLink.findComponent(GlAvatar); + }); + + it('should set the src correctly', () => { + expect(avatar.attributes('src')).toBe(currentUserData.avatar_url); + }); + + it('should set the alt correctly', () => { + expect(avatar.attributes('alt')).toBe(currentUserData.name); + }); + }); + + describe('note header', () => { + let noteHeader; + + beforeEach(() => { + noteHeader = wrapper.findByTestId('wiki-placeholder-note-header'); + }); + + it('should render user link with href to current user path when set', () => { + const userLink = noteHeader.find('a'); + expect(userLink.attributes('href')).toBe(currentUserData.path); + }); + + it('user link href should default to web url when current user path is not set', () => { + wrapper = createWrapper({ + provideData: { + currentUserData: { + ...currentUserData, + path: null, + }, + }, + }); + noteHeader = wrapper.findByTestId('wiki-placeholder-note-header'); + + const userLink = noteHeader.find('a'); + expect(userLink.attributes('href')).toBe(`${currentUserData.web_url}`); + }); + + it('should render note header text correctly', async () => { + const userName = await noteHeader.text(); + expect(userName).toBe(`${currentUserData.name} @${currentUserData.username}`); + }); + }); + + it('should rander note body text correctly', async () => { + const noteBody = await wrapper + .findByTestId('wiki-placeholder-note-body') + .find('.note-text') + .text(); + + expect(noteBody).toBe(note.body); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/wiki_comment_form_spec.js b/spec/frontend/pages/shared/wikis/notes/components/wiki_comment_form_spec.js new file mode 100644 index 00000000000000..78313abaaa7527 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/wiki_comment_form_spec.js @@ -0,0 +1,497 @@ +import { GlAlert, GlFormCheckbox, GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import WikiCommentForm from '~/pages/shared/wikis/wiki_notes/components/wiki_comment_form.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WikiDiscussionsSignedOut from '~/pages/shared/wikis/wiki_notes/components/wiki_discussions_signed_out.vue'; +import WikiDiscussionLocked from '~/pages/shared/wikis/wiki_notes/components/wiki_discussion_locked.vue'; +import * as secretsDetection from '~/lib/utils/secret_detection'; +import * as confirmViaGLModal from '~/lib/utils/confirm_via_gl_modal/confirm_action'; +import { wikiCommentFormProvideData, noteableId } from '../mock_data'; + +describe('WikiCommentForm', () => { + let wrapper; + + const $apollo = { + mutate: jest.fn(), + }; + + const createWrapper = ({ props, provideData } = {}) => + shallowMountExtended(WikiCommentForm, { + propsData: { noteableId, noteId: '12', discussionId: '1', ...props }, + provide: { ...wikiCommentFormProvideData, ...provideData }, + mocks: { + $apollo, + }, + stubs: { + GlButton, + MarkdownEditor: { + template: '
', + props: { + value: '', + autofocus: false, + }, + methods: { + focus: jest.fn(), + }, + }, + }, + }); + + const wikiCommentContainer = () => wrapper.findByTestId('wiki-note-comment-form-container'); + + describe('user is not logged in', () => { + beforeEach(() => { + wrapper = createWrapper({ + provideData: { + currentUserData: null, + }, + }); + }); + + it('should only render wiki discussion signed out component', () => { + expect(wikiCommentContainer().element.children).toHaveLength(1); + + const wikiDiscussionsSignedOut = + wikiCommentContainer().findComponent(WikiDiscussionsSignedOut); + expect(wikiDiscussionsSignedOut.exists()).toBe(true); + }); + }); + + describe('user is logged in', () => { + describe('user cannot create note', () => { + beforeEach(() => { + wrapper = createWrapper({ + provideData: { + isContainerArchived: true, + }, + }); + }); + + it('should render only wiki discussion locked component when user cannot create note', () => { + expect(wikiCommentContainer().element.children).toHaveLength(1); + + const wikiDiscussionLocked = wikiCommentContainer().findComponent(WikiDiscussionLocked); + expect(wikiDiscussionLocked.exists()).toBe(true); + }); + }); + + describe('user can create note', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render only the wiki comment form', () => { + expect(wikiCommentContainer().element.children).toHaveLength(1); + + const commentForm = wikiCommentContainer().find('[data-testid=wiki-note-comment-form]'); + expect(commentForm.exists()).toBe(true); + }); + + it('should not autofocus on themarkdown editor when isReply and isEdit are false', () => { + expect(wrapper.vm.$refs.markdownEditor.autofocus).toBe(false); + }); + + it('should autofocus on the markdown editor when isReply is true', () => { + wrapper = createWrapper({ props: { isReply: true } }); + expect(wrapper.vm.$refs.markdownEditor.autofocus).toBe(true); + }); + + it('should autofocus on the markdown editor when isEdit is true', () => { + wrapper = createWrapper({ props: { isEdit: true } }); + expect(wrapper.vm.$refs.markdownEditor.autofocus).toBe(true); + }); + + describe('handle errors', () => { + beforeEach(async () => { + wrapper.vm.setError(['could not submit data']); + await nextTick(); + }); + + it('should not display error box when there are no errors', async () => { + wrapper.vm.setError([]); + await nextTick(); + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + }); + + it('should display error correctly', async () => { + expect(await wrapper.findComponent(GlAlert).text()).toBe('could not submit data'); + }); + }); + + describe('handle save', () => { + const createWrapperWithNote = (props) => { + wrapper = createWrapper({ + props: { + discussionId: '1', + noteableId: '1', + internal: false, + ...props, + }, + }); + wrapper.vm.onInput('Test comment'); + }; + + beforeEach(() => { + createWrapperWithNote(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should check for sensitive tokens in the note', async () => { + const detectAndConfirmSensitiveTokens = jest.spyOn( + secretsDetection, + 'detectAndConfirmSensitiveTokens', + ); + + await wrapper.vm.handleSave(); + + expect(detectAndConfirmSensitiveTokens).toHaveBeenCalledWith({ content: 'Test comment' }); + }); + + it('should not emit the creating-note:start event when note is empty', async () => { + wrapper = createWrapper(); + await wrapper.vm.handleSave(); + expect(Boolean(wrapper.emitted('creating-note:start'))).toBe(false); + }); + + it('should clear the editor content', () => { + createWrapperWithNote(); + wrapper.vm.handleSave(); + const content = wrapper.vm.$refs.markdownEditor.value; + expect(content).toBe(''); + }); + + it('should emit the creating-note:start event with the correct data when isEdit is true', async () => { + createWrapperWithNote({ isEdit: true }); + wrapper.vm.handleSave(); + await nextTick(); + + expect(wrapper.emitted('creating-note:start')).toMatchObject([ + [ + { + body: 'Test comment', + id: 'gid://gitlab/Note/12', + }, + ], + ]); + }); + + it('should emit the creating-note:start event with the correct data when isReply is true', async () => { + createWrapperWithNote({ isReply: true }); + wrapper.vm.handleSave(); + await nextTick(); + expect(wrapper.emitted('creating-note:start')).toMatchObject([ + [ + { + body: 'Test comment', + discussionId: '1', + individualNote: false, + internal: false, + noteableId: '1', + }, + ], + ]); + }); + + it('should emit the creating-note:start event with the correct data when isReply and isEdit are false', async () => { + wrapper.vm.handleSave(); + await nextTick(); + expect(wrapper.emitted('creating-note:start')).toMatchObject([ + [ + { + body: 'Test comment', + discussionId: null, + individualNote: false, + internal: false, + noteableId: '1', + }, + ], + ]); + }); + + describe('submitting a note', () => { + it('should call apollo mutate with the correct data when isEdit is true', async () => { + createWrapperWithNote({ isEdit: true }); + await wrapper.vm.handleSave(); + expect($apollo.mutate).toHaveBeenCalledWith({ + mutation: expect.any(Object), + variables: { + input: { + body: 'Test comment', + id: 'gid://gitlab/Note/12', + }, + }, + }); + }); + + it('should call apollo mutate with the correct data when isReply is true', async () => { + createWrapperWithNote({ isReply: true }); + await wrapper.vm.handleSave(); + expect($apollo.mutate).toHaveBeenCalledWith({ + mutation: expect.any(Object), + variables: { + input: { + body: 'Test comment', + noteableId: '1', + discussionId: '1', + internal: false, + }, + }, + }); + }); + + it('should call apollo mutate with the correct data when isReply and isEdit are false', async () => { + await wrapper.vm.handleSave(); + + expect($apollo.mutate).toHaveBeenCalledWith({ + mutation: expect.any(Object), + variables: { + input: { + body: 'Test comment', + noteableId: '1', + discussionId: null, + internal: false, + }, + }, + }); + }); + + it('should not start sumitting if the user does not confirm to continue with sensitive tokens', async () => { + jest + .spyOn(secretsDetection, 'detectAndConfirmSensitiveTokens') + .mockImplementation(() => false); + + await wrapper.vm.handleSave(); + expect(Boolean(wrapper.emitted('creating-note:start'))).toBe(false); + }); + + it('should start sumitting 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') + .mockImplementation(() => true); + + await wrapper.vm.handleSave(); + expect(Boolean(wrapper.emitted('creating-note:start'))).toBe(true); + }); + }); + + describe('when there is no error while submitting', () => { + beforeEach(() => { + wrapper.vm.onInput('comment'); + $apollo.mutate.mockResolvedValue({ + data: { + updateNote: { note: { id: '1' } }, + createNote: { note: { discussion: { id: '2' } } }, + }, + }); + }); + + it('should emit the creating-note:success event with the correct data when isEdit is true', async () => { + createWrapperWithNote({ isEdit: true }); + await wrapper.vm.handleSave(); + + expect(wrapper.emitted('creating-note:success')).toStrictEqual([[{ id: '1' }]]); + }); + + it('should emit the creating-note:success event with the correct data when isEdit is false', async () => { + createWrapperWithNote({ isEdit: false }); + await wrapper.vm.handleSave(); + + expect(wrapper.emitted('creating-note:success')).toStrictEqual([[{ id: '2' }]]); + }); + + it('should set note to empty string', async () => { + await wrapper.vm.handleSave(); + expect(wrapper.vm.$refs.markdownEditor.value).toBe(''); + }); + }); + + describe('when there is an error while submitting', () => { + beforeEach(() => { + $apollo.mutate.mockRejectedValue('random error'); + }); + + it('should emit the creating-note:failed event with the correct value', async () => { + await wrapper.vm.handleSave(); + + expect(wrapper.emitted('creating-note:failed')).toStrictEqual([['random error']]); + }); + + it('should set the note to the previous value', async () => { + await wrapper.vm.handleSave(); + expect(wrapper.vm.$refs.markdownEditor.value).toBe('Test comment'); + }); + + it('should set the errors with the correct value', async () => { + await wrapper.vm.handleSave(); + const glAlert = wrapper.findComponent(GlAlert); + + expect(await glAlert.text()).toBe( + 'Your comment could not be submitted! Please check your network connection and try again.', + ); + }); + }); + }); + + describe('handle comment button and internal note check box', () => { + const submitButton = () => wrapper.findByTestId('wiki-note-comment-button'); + const internalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox); + + beforeEach(() => { + wrapper = createWrapper({ props: { canSetInternalNote: true } }); + }); + + it('should render both correctly', async () => { + expect(await submitButton().text()).toBe('Comment'); + expect(internalNoteCheckbox().exists()).toBe(true); + }); + + it('should render neither when isReply is true', () => { + wrapper = createWrapper({ props: { isReply: true } }); + expect(submitButton().exists()).toBe(false); + expect(internalNoteCheckbox().exists()).toBe(false); + }); + + it('should render neither when isEdit is true', () => { + wrapper = createWrapper({ props: { isEdit: true } }); + expect(submitButton().exists()).toBe(false); + expect(internalNoteCheckbox().exists()).toBe(false); + }); + + it('should disable submit button when editor it is empty', () => { + expect(submitButton().props('disabled')).toBe(true); + }); + + it('should not disable submit button editor when empty', async () => { + wrapper.vm.onInput('comment'); + + await nextTick(); + expect(submitButton().props('disabled')).toBe(false); + }); + + it('should disable editor when submitting', () => { + wrapper.vm.handleSave(); // not waiting for it to finish + expect(submitButton().props('disabled')).toBe(true); + }); + }); + + describe('reply and edit buttons', () => { + const saveButton = () => wrapper.findByTestId('wiki-note-save-button'); + const cancelButton = () => wrapper.findByTestId('wiki-note-cancel-button'); + + beforeEach(() => { + wrapper = createWrapper({ props: { isEdit: true } }); + }); + + it('should render both save and cancel with correct text buttons when isEdit is true', async () => { + expect(await saveButton().text()).toBe('Save comment'); + expect(await cancelButton().text()).toBe('Cancel'); + }); + + it('should render both save and cancel with correct text buttons when isReply is true', async () => { + wrapper = createWrapper({ props: { isReply: true, isEdit: false } }); + expect(await saveButton().text()).toBe('Reply'); + expect(await cancelButton().text()).toBe('Cancel'); + }); + + it('should not render either button when isEdit and isReply are false', () => { + wrapper = createWrapper({ props: { isReply: false, isEdit: false } }); + expect(saveButton().exists()).toBe(false); + expect(cancelButton().exists()).toBe(false); + }); + + it('should be disabled when editor it is empty', () => { + expect(saveButton().props('disabled')).toBe(true); + }); + + it('should not be disabled editor when empty', async () => { + wrapper.vm.onInput('comment'); + await nextTick(); + expect(saveButton().props('disabled')).toBe(false); + }); + + it('should disable editor when submitting', () => { + wrapper.vm.handleSave(); + expect(saveButton().props('disabled')).toBe(true); + }); + }); + + describe('handleCancel', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should emit cancel event when note is empty', async () => { + await wrapper.vm.handleCancel(); + expect(Boolean(wrapper.emitted('cancel'))).toBe(true); + }); + + describe('when note is not empty', () => { + const createWrapperWithNote = (props) => { + wrapper = createWrapper({ + props, + }); + + wrapper.vm.onInput('Test comment'); + }; + + beforeEach(() => { + createWrapperWithNote(); + }); + + it('should confirm if the user wants to cancel with the correct text, when isEdit is true', async () => { + createWrapperWithNote({ isEdit: true }); + + const confirmActionSpy = jest + .spyOn(confirmViaGLModal, 'confirmAction') + .mockImplementation(() => false); + + await wrapper.vm.handleCancel(); + expect(confirmActionSpy).toHaveBeenCalledWith( + 'Are you sure you want to cancel editing this comment?', + { + primaryBtnText: 'Discard Changes', + cancelBtnText: 'continue editing', + }, + ); + }); + + it('should confirm if the user wants to cancel with the correct text, when isReply is true', async () => { + createWrapperWithNote({ isReply: true }); + + const confirmActionSpy = jest + .spyOn(confirmViaGLModal, 'confirmAction') + .mockImplementation(() => false); + + await wrapper.vm.handleCancel(); + + expect(confirmActionSpy).toHaveBeenCalledWith( + 'Are you sure you want to cancel creating this comment?', + { + primaryBtnText: 'Discard Changes', + cancelBtnText: 'continue creating', + }, + ); + }); + + it('should emit cancel if user confirms to cancel', async () => { + jest.spyOn(confirmViaGLModal, 'confirmAction').mockImplementation(() => true); + await wrapper.vm.handleCancel(); + + expect(Boolean(wrapper.emitted('cancel'))).toBe(true); + }); + + it('should not emit cancel if user does not confirm to cancel', async () => { + jest.spyOn(confirmViaGLModal, 'confirmAction').mockImplementation(() => false); + await wrapper.vm.handleCancel(); + + expect(Boolean(wrapper.emitted('cancel'))).toBe(false); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/wiki_discussion_locked_spec.js b/spec/frontend/pages/shared/wikis/notes/components/wiki_discussion_locked_spec.js new file mode 100644 index 00000000000000..af8941889b1535 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/wiki_discussion_locked_spec.js @@ -0,0 +1,91 @@ +import { GlLink, GlIcon } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WikiDiscussionLocked from '~/pages/shared/wikis/wiki_notes/components/wiki_discussion_locked.vue'; + +describe('WikiDiscussionLocked', () => { + let wrapper; + + const createWrapper = (provideData) => + shallowMountExtended(WikiDiscussionLocked, { + provide: { + isContainerArchived: false, + containerType: 'Project', + lockedWikiDocsPath: 'exampledocsurl1.com', + archivedProjectDocsPath: 'exampledocsurl2.com', + ...provideData, + }, + }); + + describe('renders correctly', () => { + const shouldRenderLockIcon = () => { + expect(wrapper.findComponent(GlIcon).props('name')).toBe('lock'); + }; + + const shouldRenderLinkIconCorrectly = async (docsPath) => { + const link = wrapper.findComponent(GlLink); + await nextTick(); + expect(await link.text()).toBe('Learn more'); + expect(link.element.getAttribute('href')).toBe(docsPath); + }; + + describe('when isContainerArchived is false', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render lock icon', () => { + shouldRenderLockIcon(); + }); + + it('should render locked discussion warning', async () => { + expect(await wrapper.text()).toContain( + 'The discussion in this Wiki is locked. Only project members can comment.', + ); + }); + + it('should not render archived project warning', async () => { + expect(await wrapper.text()).not.toContain( + 'This project is archived and cannot be commented on.', + ); + }); + + it('should render learn more in gl-link component correctly', async () => { + await shouldRenderLinkIconCorrectly('exampledocsurl1.com'); + }); + }); + + describe('when isContainerArchived is true', () => { + beforeEach(() => { + wrapper = createWrapper({ isContainerArchived: true }); + }); + it('should render lock icon', () => { + shouldRenderLockIcon(); + }); + + it('should not render locked discussion warning', async () => { + expect(await wrapper.text()).not.toContain( + 'The discussion in this Wiki is locked. Only project members can comment.', + ); + }); + + it('should render archived project warning by default', async () => { + expect(await wrapper.text()).toContain( + 'This project is archived and cannot be commented on.', + ); + }); + + it('should render archived group warning when containerType is wiki', async () => { + wrapper = createWrapper({ isContainerArchived: true, containerType: 'group' }); + + expect(await wrapper.text()).toContain( + 'This group has been scheduled for deletion and cannot be commented on.', + ); + }); + + it('should render learn more in gl-link component correctly', async () => { + await shouldRenderLinkIconCorrectly('exampledocsurl2.com'); + }); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/wiki_discussion_spec.js b/spec/frontend/pages/shared/wikis/notes/components/wiki_discussion_spec.js new file mode 100644 index 00000000000000..6a6b1a0431f884 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/wiki_discussion_spec.js @@ -0,0 +1,212 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WikiDiscussion from '~/pages/shared/wikis/wiki_notes/components/wiki_discussion.vue'; +import WikiNote from '~/pages/shared/wikis/wiki_notes/components/wiki_note.vue'; +import PlaceholderNote from '~/pages/shared/wikis/wiki_notes/components/placeholder_note.vue'; +import DiscussionReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import WikiDiscussionsSignedOut from '~/pages/shared/wikis/wiki_notes/components/wiki_discussions_signed_out.vue'; +import WikiCommentForm from '~/pages/shared/wikis/wiki_notes/components/wiki_comment_form.vue'; +import * as autosave from '~/lib/utils/autosave'; +import { currentUserData, note, noteableId, noteableType } from '../mock_data'; + +describe('WikiDiscussion', () => { + let wrapper; + + const createWrapper = ({ props, provideData = { userData: currentUserData } } = {}) => + shallowMountExtended(WikiDiscussion, { + propsData: { + discussion: [note], + noteableId, + ...props, + }, + provide: { + noteableType, + currentUserData, + ...provideData, + }, + }); + + const noteFooter = () => wrapper.findByTestId('wiki-note-footer'); + + beforeEach(() => { + wrapper = createWrapper(); + }); + + describe('renders correctly', () => { + it('should render wiki note correctly', () => { + expect(wrapper.findComponent(WikiNote).props('note')).toMatchObject(note); + }); + + it('should not render note footer when there is no reply and replying is false', () => { + // there is only one note in discussion array so there is no reply + expect(wrapper.findByTestId('wiki-note-footer').exists()).toBe(false); + }); + + it('should render note footer when isReplying is true', async () => { + wrapper.vm.toggleReplying(true); + await nextTick(); + expect(wrapper.findByTestId('wiki-note-footer').exists()).toBe(true); + }); + + describe('when there are replies', () => { + beforeEach(() => { + wrapper = createWrapper({ + props: { + discussion: [ + note, + { + ...note, + body: 'another example note', + bodyHtml: '

another example note

', + }, + ], + }, + }); + }); + + it("should set 'replyNote' prop to true when rendering replies", () => { + expect(noteFooter().findComponent(WikiNote).props('replyNote')).toBe(true); + }); + + it('should render reply in note footer when there is at least 1 reply', () => { + expect(noteFooter().findComponent(WikiNote).props('note')).toMatchObject({ + ...note, + body: 'another example note', + bodyHtml: '

another example note

', + }); + }); + + it('should render placeholder correctly note when "placeholderNote" is set', async () => { + wrapper.vm.setPlaceHolderNote({ + body: 'another example note', + }); + await nextTick(); + + const placeholderNote = noteFooter().findComponent(PlaceholderNote); + expect(placeholderNote.props('note')).toStrictEqual({ + body: 'another example note', + }); + }); + }); + }); + + describe('when user is not signed in', () => { + beforeEach(() => { + wrapper = createWrapper({ + props: { discussion: [note, note] }, + provideData: { currentUserData: null }, + }); + }); + + it('should render wiki discussion signed out component', () => { + expect(noteFooter().findComponent(WikiDiscussionsSignedOut).exists()).toBe(true); + }); + + it('should not render reply form placeholder', () => { + expect(noteFooter().findComponent(DiscussionReplyPlaceholder).exists()).toBe(false); + }); + + it('should not render reply form', () => { + expect(noteFooter().findComponent(WikiCommentForm).exists()).toBe(false); + }); + }); + + describe('component functions properly when user is signed in', () => { + beforeEach(() => { + wrapper = createWrapper({ props: { discussion: [note, note] } }); + }); + + it('should call clearDraft whenever toggle reply is called with a value of false', () => { + const clearDraftSpy = jest.spyOn(autosave, 'clearDraft'); + + wrapper.vm.toggleReplying(false); + + expect(clearDraftSpy).toHaveBeenCalledTimes(1); + }); + + it('should not render wiki discussion signed out component', () => { + expect(noteFooter().findComponent(WikiDiscussionsSignedOut).exists()).toBe(false); + }); + + it('should render reply form placeholder when isReplying is false', () => { + // isReplying is set to false by default + expect(noteFooter().findComponent(DiscussionReplyPlaceholder).exists()).toBe(true); + }); + + it('should render reply form when isReplying is true', async () => { + wrapper.vm.toggleReplying(true); + await nextTick(); + expect(Boolean(wrapper.vm.$refs.commentForm)).toBe(true); + }); + + it("should render replyForm when 'reply' event is fired from wiki note", async () => { + const wikiNote = wrapper.findComponent(WikiNote); + wikiNote.vm.$emit('reply'); + await nextTick(); + + expect(Boolean(wrapper.vm.$refs.commentForm)).toBe(true); + }); + + it('should render reply form when focus event is fired from discussion reply placeholder', async () => { + const replyPlaceholder = wrapper.findComponent(DiscussionReplyPlaceholder); + replyPlaceholder.vm.$emit('focus'); + + await nextTick(); + expect(Boolean(wrapper.vm.$refs.commentForm)).toBe(true); + }); + }); + + describe('reply form', () => { + let replyForm; + beforeEach(async () => { + wrapper.vm.toggleReplying(true); + await nextTick(); + replyForm = wrapper.vm.$refs.commentForm; + }); + + it('should unrender relpyForm when cancel event is fired', async () => { + replyForm.$emit('cancel'); + await nextTick(); + + expect(Boolean(wrapper.vm.$refs.commentForm)).toBe(false); + }); + + it('should set placeholder correctly when creating-note:start event is fired', async () => { + replyForm.$emit('creating-note:start', { + body: 'another example note', + bodyHtml: '

another example note

', + }); + + await nextTick(); + const placeholderNote = wrapper.findComponent(PlaceholderNote).props('note'); + expect(placeholderNote).toMatchObject({ + body: 'another example note', + bodyHtml: '

another example note

', + }); + }); + + it('should remove placeholder note when creating-note:done event is fired', async () => { + replyForm.$emit('creating-note:done'); + + await nextTick(); + const placeholderNote = wrapper.findComponent(PlaceholderNote); + expect(placeholderNote.exists()).toBe(false); + }); + + it('should remove placeholer and add new reply to replies list when creating-note:success event is fired', async () => { + const newReply = { + ...note, + id: 'gid://gitlab/DiscussionNote/1525', + }; + + replyForm.$emit('creating-note:success', { notes: { nodes: [newReply] } }); + await nextTick(); + + const reply = noteFooter().findComponent(WikiNote); + const placeholderNote = noteFooter().findComponent(PlaceholderNote); + + expect(placeholderNote.exists()).toBe(false); + expect(reply.exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/wiki_note_spec.js b/spec/frontend/pages/shared/wikis/notes/components/wiki_note_spec.js new file mode 100644 index 00000000000000..b3325caead830c --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/wiki_note_spec.js @@ -0,0 +1,378 @@ +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WikiNote from '~/pages/shared/wikis/wiki_notes/components/wiki_note.vue'; +import NoteHeader from '~/pages/shared/wikis/wiki_notes/components/note_header.vue'; +import NoteActions from '~/pages/shared/wikis/wiki_notes/components/note_actions.vue'; +import NoteBody from '~/pages/shared/wikis/wiki_notes/components/note_body.vue'; +import DeleteNoteMutation from '~/wikis/graphql/notes/delete_wiki_page_note.mutation.graphql'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import * as autosave from '~/lib/utils/autosave'; +import * as confirmViaGLModal from '~/lib/utils/confirm_via_gl_modal/confirm_action'; +import * as alert from '~/alert'; +import { noteableType, currentUserData, note, noteableId } from '../mock_data'; + +describe('WikiNote', () => { + let wrapper; + + const $apollo = { + mutate: jest.fn(), + }; + + const createWrapper = (props) => { + return shallowMountExtended(WikiNote, { + propsData: { + note, + noteableId, + ...props, + }, + mocks: { + $apollo, + }, + provide: { + noteableType, + currentUserData, + }, + stubs: { + GlAvatarLink: { + template: '
', + props: ['href', 'data-user-id', 'data-username'], + }, + GlAvatar: { + template: '', + props: ['src', 'entity-name', 'alt'], + }, + }, + }); + }; + + beforeEach(() => { + wrapper = createWrapper(); + }); + + describe('renders correctly by default', () => { + it('should render time line entry item correctly', () => { + const timelineEntryItem = wrapper.findComponent(TimelineEntryItem); + + expect(timelineEntryItem.element.classList).not.toContain( + 'gl-opacity-5', + 'gl-ponter-events-none', + 'is-editable', + 'internal-note', + ); + }); + + it('should render author avatar correctly', () => { + const avatarLink = wrapper.findComponent(GlAvatarLink); + + expect(avatarLink.props()).toMatchObject({ + href: note.author.webPath, + dataUserId: '1', + dataUsername: 'root', + }); + + const avatar = avatarLink.findComponent(GlAvatar); + + expect(avatar.props()).toMatchObject({ + alt: note.author.name, + entityName: note.author.username, + src: note.author.avatarUrl, + }); + }); + + it('renders note header correctly', () => { + const noteHeader = wrapper.findComponent(NoteHeader); + + expect(noteHeader.props()).toMatchObject({ + author: note.author, + createdAt: note.createdAt, + }); + }); + + it('renders note actions correctly', () => { + const noteActions = wrapper.findComponent(NoteActions); + + expect(noteActions.props()).toMatchObject({ + authorId: '1', + noteUrl: note.url, + showReply: false, + showEdit: false, + canReportAsAbuse: true, + }); + }); + + it('renders note body correctly', () => { + const noteBody = wrapper.findComponent(NoteBody); + + expect(noteBody.props()).toMatchObject({ + note, + noteableId, + isEditing: false, + }); + }); + }); + + describe('when user is signed in', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should emit reply when reply event is fired from note actions', () => { + const noteActions = wrapper.findComponent(NoteActions); + noteActions.vm.$emit('reply'); + + expect(Boolean(wrapper.emitted('reply'))).toBe(true); + }); + + it('should pass prop "showReply" as true to note actions when user can reply', () => { + wrapper = createWrapper({ + userPermissions: { + createNote: true, + }, + }); + + const noteActions = wrapper.findComponent(NoteActions); + expect(noteActions.props().showReply).toBe(true); + }); + + it('should pass prop "showReply" as false to note actions when user cannot reply', () => { + wrapper = createWrapper({ + userPermissions: { + createNote: false, + }, + }); + + const noteActions = wrapper.findComponent(NoteActions); + expect(noteActions.props().showReply).toBe(false); + }); + + describe('user cannot edit', () => { + it('should pass false to showEdit prop of note actions', () => { + const noteActions = wrapper.findComponent(NoteActions); + expect(noteActions.props().showEdit).toBe(false); + }); + + it('should pass false to canEdit prop of note body', () => { + const noteBody = wrapper.findComponent(NoteBody); + expect(noteBody.props().canEdit).toBe(false); + }); + }); + + describe('user can edit', () => { + const verifyEditingOrDeletingStyles = (applied = true) => { + const timelineEntryItem = wrapper.findComponent(TimelineEntryItem); + expect(timelineEntryItem.element.classList.contains('gl-opacity-5')).toBe(applied); + expect(timelineEntryItem.element.classList.contains('gl-pointer-events-none')).toBe( + applied, + ); + }; + + const verifyShowSpinner = () => { + const noteHeader = wrapper.findComponent(NoteHeader); + expect(noteHeader.props().showSpinner).toBe(true); + }; + + beforeEach(() => { + wrapper = createWrapper({ + note: { + ...note, + author: { + ...note.author, + id: 'gid://gitlab/User/70', + }, + }, + }); + }); + + it('should pass true to showEdit prop of note actions', () => { + const noteActions = wrapper.findComponent(NoteActions); + expect(noteActions.props().showEdit).toBe(true); + }); + + it('should pass true to canEdit prop of note body', () => { + const noteBody = wrapper.findComponent(NoteBody); + expect(noteBody.props().canEdit).toBe(true); + }); + + describe('when editing', () => { + beforeEach(() => { + wrapper.vm.toggleEditing(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should pass isEditing prop as true to the note body', () => { + const noteBody = wrapper.findComponent(NoteBody); + expect(noteBody.props().isEditing).toBe(true); + }); + + it('should clear draft when isEditing is set to false', () => { + const clearDraftSpy = jest.spyOn(autosave, 'clearDraft'); + wrapper.vm.toggleEditing(false); + + expect(clearDraftSpy).toHaveBeenCalled(); + }); + + it('should pass down isEditing as false when cancel:edit event is fired from note body component', async () => { + wrapper.findComponent(NoteBody).vm.$emit('cancel:edit'); + + await nextTick(); + expect(wrapper.findComponent(NoteBody).props('isEditing')).toBe(false); + }); + + it('should pass down isEditing as false when creating-note:success event is fired from note body component', async () => { + wrapper.findComponent(NoteBody).vm.$emit('creating-note:success'); + + await nextTick(); + expect(wrapper.findComponent(NoteBody).props('isEditing')).toBe(false); + }); + }); + + describe('when not editing', () => { + it('should pass down isEditing as false', () => { + expect(wrapper.findComponent(NoteBody).props('isEditing')).toBe(false); + }); + + it('should pass down isEditing true when edit event is fired from note actions', async () => { + wrapper.findComponent(NoteActions).vm.$emit('edit'); + + await nextTick(); + expect(wrapper.findComponent(NoteBody).props('isEditing')).toBe(true); + }); + }); + + describe('when updating', () => { + beforeEach(() => { + wrapper.vm.toggleUpdating(true); + }); + + it('should add opacity and disable pointer events on timeline entry item', () => { + verifyEditingOrDeletingStyles(true); + }); + + it('should show spinner on note header', () => { + verifyEditingOrDeletingStyles(true); + }); + + it('should pass isEditing down as true and remove spinner when creating-note:done event is fired from note body component', async () => { + wrapper.findComponent(NoteBody).vm.$emit('creating-note:done'); + + await nextTick(); + expect(wrapper.findComponent(NoteBody).props('isEditing')).toBe(false); + expect(wrapper.findComponent(NoteHeader).props('showSpinner')).toBe(false); + }); + }); + + describe('when not updating', () => { + it('should add editing styles when creating-note:start event is fired from note body component', async () => { + wrapper.findComponent(NoteBody).vm.$emit('creating-note:start'); + await nextTick(); + verifyEditingOrDeletingStyles(); + }); + + it('should show spinner on note header when creating-note:start event is fired from note body component', async () => { + wrapper.findComponent(NoteBody).vm.$emit('creating-note:start'); + await nextTick(); + verifyShowSpinner(); + }); + + it('shoould not show spinner on note header', () => { + const noteHeader = wrapper.findComponent(NoteHeader); + expect(noteHeader.props().showSpinner).toBe(false); + }); + + it('should remove opacity and disable pointer events on timeline entry item', () => { + verifyEditingOrDeletingStyles(false); + }); + }); + + describe('when deleting', () => { + it('should add opacity and disable pointer events on timeline entry item', async () => { + wrapper.vm.toggleDeleting(true); + await nextTick(); + + verifyEditingOrDeletingStyles(); + }); + + describe('deleteNote', () => { + beforeEach(() => { + $apollo.mutate.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should confirm with user before deleting', () => { + const confirmSpy = jest.spyOn(confirmViaGLModal, 'confirmAction'); + wrapper.vm.deleteNote(); + + expect(confirmSpy).toHaveBeenCalledWith( + 'Are you sure you want to delete this comment?', + { + primaryBtnVariant: 'danger', + primaryBtnText: 'Delete comment', + }, + ); + }); + + it('should not attempt to delete note if user does not confirm delete note action', () => { + jest.spyOn(confirmViaGLModal, 'confirmAction').mockImplementation(() => false); + + wrapper.vm.deleteNote(); + expect($apollo.mutate).not.toHaveBeenCalled(); + }); + + it('should attempt to delete note if user confirms delete note action', async () => { + jest.spyOn(confirmViaGLModal, 'confirmAction').mockImplementation(() => true); + + await wrapper.vm.deleteNote(); + expect($apollo.mutate).toHaveBeenCalledWith({ + mutation: DeleteNoteMutation, + variables: { + input: { + id: note.id, + }, + }, + }); + }); + + it('should handle error appropriately when delete note is not successful', async () => { + jest.spyOn(confirmViaGLModal, 'confirmAction').mockImplementation(() => true); + + const createAlertSpy = jest.spyOn(alert, 'createAlert'); + + $apollo.mutate.mockRejectedValue(); + + await wrapper.vm.deleteNote(); + + expect(wrapper.findComponent(TimelineEntryItem).exists()).toBe(true); + expect(createAlertSpy).toHaveBeenCalledWith({ + message: 'Something went wrong while deleting your note. Please try again.', + }); + + await nextTick(); + verifyEditingOrDeletingStyles(false); + }); + + it('should set deleted to true when delete note is successful', async () => { + jest.spyOn(confirmViaGLModal, 'confirmAction').mockImplementation(() => true); + + $apollo.mutate.mockResolvedValue(); + + await wrapper.vm.deleteNote(); + expect(wrapper.findComponent(TimelineEntryItem).exists()).toBe(false); + }); + }); + }); + + describe('when not deleting', () => { + it('should not apply deleting styles', () => { + verifyEditingOrDeletingStyles(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/wiki_notes_app_spec.js b/spec/frontend/pages/shared/wikis/notes/components/wiki_notes_app_spec.js new file mode 100644 index 00000000000000..6ca33265f32c9c --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/wiki_notes_app_spec.js @@ -0,0 +1,233 @@ +import { GlAlert } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WikiNotesApp from '~/pages/shared/wikis/wiki_notes/components/wiki_notes_app.vue'; +import WikiCommentForm from '~/pages/shared/wikis/wiki_notes/components/wiki_comment_form.vue'; +import PlaceholderNote from '~/pages/shared/wikis/wiki_notes/components/placeholder_note.vue'; +import SkeletonNote from '~/vue_shared/components/notes/skeleton_note.vue'; +import WikiDiscussion from '~/pages/shared/wikis/wiki_notes/components/wiki_discussion.vue'; +import WikiPageQuery from '~/wikis/graphql/wiki_page.query.graphql'; +import { note, noteableId } from '../mock_data'; + +describe('WikiNotesApp', () => { + let wrapper; + + const $apollo = { + queries: { + wikiPage: { + loading: false, + refetch: jest.fn().mockResolvedValue({}), + }, + }, + }; + + const createWrapper = ({ provideData = { containerType: 'project' } } = {}) => + shallowMountExtended(WikiNotesApp, { + provide: { + pageInfo: { + slug: 'home', + }, + containerId: noteableId, + noteCount: 5, + ...provideData, + }, + mocks: { + $apollo, + }, + }); + + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render skeleton notes before content loads', () => { + wrapper = createWrapper(); + const skeletonNotes = wrapper.findAllComponents(SkeletonNote); + + expect(skeletonNotes.length).toBe(5); + }); + + it('should render Comment Form correctly', () => { + const commentForm = wrapper.findComponent(WikiCommentForm); + + expect(commentForm.props()).toMatchObject({ + noteableId: '', + noteId: noteableId, + }); + }); + + it('should not render placeholder note by default', () => { + const placeholderNote = wrapper.findComponent(PlaceholderNote); + expect(placeholderNote.exists()).toBe(false); + }); + + it('should render placeholder note correctly when set', async () => { + wrapper.vm.setPlaceHolderNote({ body: 'a placeholder' }); + await nextTick(); + + const placeholderNote = wrapper.findComponent(PlaceholderNote); + + expect(placeholderNote.props('note')).toMatchObject({ body: 'a placeholder' }); + }); + + describe('when there is an error while fetching discussions', () => { + beforeEach(() => { + wrapper.vm.$options.apollo.wikiPage.error.call(wrapper.vm); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should render error message correctly', async () => { + const errorAlert = wrapper.findComponent(GlAlert); + expect(await errorAlert.text()).toBe( + 'Something went wrong while fetching comments. Please refresh the page.', + ); + }); + + it('should render retry text correctly', async () => { + const errorAlert = wrapper.findComponent(GlAlert); + expect(await errorAlert.props('primaryButtonText')).toBe('Retry'); + }); + + it('should not render any discussions', () => { + const wikiDiscussions = wrapper.findAllComponents(WikiDiscussion); + expect(wikiDiscussions.length).toBe(0); + }); + + it('should not render any skeleton notes', () => { + const skeletonNotes = wrapper.findAllComponents(SkeletonNote); + expect(skeletonNotes.length).toBe(0); + }); + + it('should attempt to fetch Discussions when retry button is clicked', async () => { + const errorAlert = wrapper.findComponent(GlAlert); + + await errorAlert.vm.$emit('primaryAction'); + expect(wrapper.vm.$apollo.queries.wikiPage.refetch).toHaveBeenCalled(); + }); + }); + + describe('when there are no errors while fetching discussions', () => { + beforeEach(() => { + const mockData = { + wikiPage: { + id: 'gid://gitlab/WikiPage/1', + discussions: { + nodes: [ + { id: 1, notes: { nodes: [{ body: 'Discussion 1' }] } }, + { id: 2, notes: { nodes: [{ body: 'Discussion 2' }] } }, + ], + }, + }, + }; + + wrapper.vm.$options.apollo.wikiPage.result.call(wrapper.vm, { data: mockData }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should render discussions correctly', () => { + const wikiDiscussions = wrapper.findAllComponents(WikiDiscussion); + + expect(wikiDiscussions.length).toBe(2); + expect(wikiDiscussions.at(0).props()).toMatchObject({ + discussion: [{ body: 'Discussion 1' }], + noteableId: 'gid://gitlab/WikiPage/1', + }); + expect(wikiDiscussions.at(1).props()).toMatchObject({ + discussion: [{ body: 'Discussion 2' }], + noteableId: 'gid://gitlab/WikiPage/1', + }); + }); + + it('should not render error alert', () => { + const errorAlert = wrapper.findComponent(GlAlert); + expect(errorAlert.exists()).toBe(false); + }); + }); + + describe('when fetching discussions', () => { + const setUpAndReturnVariables = (containerType) => { + wrapper = createWrapper({ provideData: { containerType } }); + + const variablesSpy = jest.spyOn(WikiNotesApp.apollo.wikiPage, 'variables'); + WikiNotesApp.apollo.wikiPage.variables.call(wrapper.vm); + + expect(wrapper.vm.$options.apollo.wikiPage.query).toBe(WikiPageQuery); + return variablesSpy.mock.results[0].value; + }; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should set variable data when containerType is group', () => { + const variables = setUpAndReturnVariables('group'); + expect(variables).toMatchObject({ slug: 'home', namespaceId: 'gid://gitlab/Group/7' }); + }); + + it('should set variable data when containerType is project', () => { + const variables = setUpAndReturnVariables('project'); + + expect(variables).toMatchObject({ slug: 'home', projectId: 'gid://gitlab/Project/7' }); + }); + }); + + describe('wiki comment form', () => { + it('should setPlaceHolder correctly when "creating-note:start" is called', async () => { + const commentForm = wrapper.findComponent(WikiCommentForm); + + commentForm.vm.$emit('creating-note:start', { body: 'example placeholder' }); + await nextTick(); + + const placeholderNote = wrapper.findComponent(PlaceholderNote); + expect(placeholderNote.props('note')).toMatchObject({ body: 'example placeholder' }); + }); + + it('should removePlaceholder when "creating-note:done" is called', async () => { + wrapper.vm.setPlaceHolderNote({ body: 'example placeholder' }); + const commentForm = wrapper.findComponent(WikiCommentForm); + commentForm.vm.$emit('creating-note:done'); + await nextTick(); + + expect(wrapper.vm.placeholderNote).toMatchObject({}); + }); + + it('shouldupdateDiscussions when "creating-note:success" is called', async () => { + const mockData = { + wikiPage: { + id: 'gid://gitlab/WikiPage/1', + discussions: { + nodes: [{ id: 1, notes: { nodes: [{ body: 'Discussion 1' }] } }], + }, + }, + }; + wrapper.vm.$options.apollo.wikiPage.result.call(wrapper.vm, { data: mockData }); + await nextTick(); + + const newDiscussion = { + id: '2', + notes: { + nodes: [ + { + ...note, + id: 2, + body: 'New Comment', + }, + ], + }, + }; + const commentForm = wrapper.findComponent(WikiCommentForm); + commentForm.vm.$emit('creating-note:success', newDiscussion); + await nextTick(); + + const discussions = wrapper.findAllComponents(WikiDiscussion); + expect(discussions.length).toBe(2); + expect(discussions.at(1).props('discussion')).toMatchObject(newDiscussion.notes.nodes); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/components/wiki_signed_out_spec.js b/spec/frontend/pages/shared/wikis/notes/components/wiki_signed_out_spec.js new file mode 100644 index 00000000000000..af663946bb45f7 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/components/wiki_signed_out_spec.js @@ -0,0 +1,55 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WikiDiscussionsSignedOut from '~/pages/shared/wikis/wiki_notes/components/wiki_discussions_signed_out.vue'; + +describe('WikiSignedOut', () => { + let wrapper; + + const createWrapper = ({ props } = {}) => + shallowMountExtended(WikiDiscussionsSignedOut, { + propsData: { + isReply: false, + ...props, + }, + provide: { + registerPath: '/register', + signInPath: '/signin', + }, + stubs: { + GlSprintf, + GlLink, + }, + }); + + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render the content correctly when reply is false', () => { + expect(wrapper.element.textContent).toBe('Please register or sign in to add a comment.'); + }); + + it('should render content correctly when reply is true', async () => { + wrapper = createWrapper({ + props: { + isReply: true, + }, + }); + + await nextTick(); + expect(wrapper.element.textContent).toBe('Please register or sign in to reply.'); + }); + + it('should render correct link for signin', () => { + const link = wrapper.findByText('sign in'); + + expect(link.attributes('href')).toBe('/signin'); + }); + + it('should render correct link for registration', () => { + const link = wrapper.findByText('register'); + + expect(link.attributes('href')).toBe('/register'); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/notes/mock_data.js b/spec/frontend/pages/shared/wikis/notes/mock_data.js new file mode 100644 index 00000000000000..2e8ecdeca11d14 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/mock_data.js @@ -0,0 +1,95 @@ +export const pageInfo = { + lastCommitSha: '5232645eff8b69ca6a76d79f0d15c0829dc9a137', + persisted: true, + title: 'home', + content: + "# Welcome to Our Wiki!\r\n\r\nThis wiki is a collaborative resource for information about [Project Name/Topic]. \r\n\r\n**Here you'll find:**\r\n\r\n* **Getting Started:**\r\n * [Installation Guide](/installation)\r\n * [Quick Start Tutorial](/quickstart)\r\n * [FAQ](/faq)\r\n* **User Guides:**\r\n * [Basic Usage](/basic-usage)\r\n * [Advanced Features](/advanced-features)\r\n * [Troubleshooting](/troubleshooting)\r\n* **Developer Documentation:**\r\n * [API Reference](/api)\r\n * [Contributing Guidelines](/contributing)\r\n * [Code Style Guide](/code-style)\r\n\r\n**Navigation:**\r\n\r\n* Use the sidebar to browse pages.\r\n* Use the search bar to find specific topics.\r\n\r\n**Contributing:**\r\n\r\nWe encourage you to contribute to this wiki! If you find any errors, have suggestions for improvements, or want to add new content, please feel free to [submit a pull request](/contributing).\r\n\r\n**Let's build a comprehensive knowledge base together!**\r\n\r\n---\r\n\r\n**Recent Updates:**\r\n\r\n* **[Date]:** Added information about [new feature/update].\r\n* **[Date]:** Updated the [page name] page.\r\n* **[Date]:** Fixed a typo on the [page name] page.\r\n\r\n---\r\n\r\n**Contact:**\r\n\r\n* **Email:** [email address]\r\n* **Website:** [website address]", + frontMatter: {}, + format: 'markdown', + uploadsPath: 'http://127.0.0.1:3000/api/v4/projects/7/wikis/attachments', + slug: 'home', + path: '/flightjs/Flight/-/wikis/home', + wikiPath: '/flightjs/Flight/-/wikis/home', + helpPath: '/help/user/project/wiki/index.md', + markdownHelpPath: '/help/user/markdown.md', + markdownPreviewPath: '/flightjs/Flight/-/wikis/home/preview_markdown', + createPath: '/flightjs/Flight/-/wikis', +}; +const registerPath = '/users/sign_up?redirect_to_referer=yes'; +const signInPath = '/users/sign_in?redirect_to_referer=yes'; +export const noteableType = 'Wiki'; +export const currentUserData = { + id: 70, + username: 'test_user1', + name: 'Tester1', + state: 'active', + locked: false, + avatar_url: + 'https://www.gravatar.com/avatar/87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674?s=80&d=identicon', + web_url: 'http://127.0.0.1:3000/test_user1', + show_status: false, + path: '/test_user1', + user_preference: { + issue_notes_filter: 0, + merge_request_notes_filter: 0, + notes_filters: { + 'Show all activity': 0, + 'Show comments only': 1, + 'Show history only': 2, + }, + default_notes_filter: 0, + epic_notes_filter: 0, + }, +}; +const markdownPreviewPath = '/flightjs/Flight/-/preview_markdown'; +const markdownDocsPath = '/help/user/markdown.md'; +const isContainerArchived = false; + +export const note = { + __typename: 'Note', + id: 'gid://gitlab/DiscussionNote/1524', + author: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/0a39b28b2f7a0822118ef3ea2454128ccb1b7c36e34fb1b3665c353ef58e95da?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + webPath: '/root', + }, + body: 'an example note', + bodyHtml: '

an example note

', + createdAt: '2024-11-10T14:19:58Z', + lastEditedAt: '2024-11-10T14:19:58Z', + url: 'http://127.0.0.1:3000/flightjs/Flight/-/wikis/home#note_1524', + userPermissions: { + __typename: 'NotePermissions', + adminNote: false, + awardEmoji: true, + readNote: true, + createNote: true, + resolveNote: false, + repositionNote: false, + }, + discussion: { + __typename: 'Discussion', + id: 'gid://gitlab/Discussion/d3146d41c7bec5bdad8ecc9f41c5f9121cd19f56', + resolved: false, + resolvable: true, + resolvedBy: null, + }, +}; + +export const noteableId = '7'; + +export const wikiCommentFormProvideData = { + pageInfo, + registerPath, + signInPath, + currentUserData, + markdownPreviewPath, + noteableType, + markdownDocsPath, + isContainerArchived, +}; diff --git a/spec/frontend/pages/shared/wikis/notes/utils_spec.js b/spec/frontend/pages/shared/wikis/notes/utils_spec.js new file mode 100644 index 00000000000000..436507e34bda7d --- /dev/null +++ b/spec/frontend/pages/shared/wikis/notes/utils_spec.js @@ -0,0 +1,71 @@ +import { + createNoteErrorMessages, + getIdFromGid, + getAutosaveKey, +} from '~/pages/shared/wikis/wiki_notes/utils'; +import { COMMENT_FORM } from '~/notes/i18n'; +import { sprintf } from '~/locale'; +import * as utils from '~/graphql_shared/utils'; + +describe('createNoteErrorMessages', () => { + it('should return the correct error message by default', () => { + const actualMessage = createNoteErrorMessages()[0]; + + const expectedMessage = COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK; + + expect(actualMessage).toBe(expectedMessage); + }); + + it('should return the correct error message when the err is a graphql error', () => { + const err = { + graphQLErrors: [{ message: 'GraphQL error' }], + }; + const actualMessage = createNoteErrorMessages(err)[0]; + + const expectedMessage = sprintf( + COMMENT_FORM.error, + { reason: 'An unexpected error occurred trying to submit your comment. Please try again.' }, + false, + ); + + expect(actualMessage).toBe(expectedMessage); + }); +}); + +describe('getIdFromGid', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each` + gid | expectedId + ${'gid://gitlab/User/7'} | ${'7'} + ${'gid://gitlab/Project/9'} | ${'9'} + ${'gid://gitlab/Group/3'} | ${'3'} + `('should return the id when the input is a gid', ({ gid, expectedId }) => { + jest.spyOn(utils, 'isGid').mockReturnValue(true); + jest.spyOn(utils, 'parseGid').mockReturnValue({ id: expectedId }); + + expect(getIdFromGid(gid)).toBe(expectedId); + }); + + it.each` + value + ${'7'} + ${'not id'} + ${'string'} + `('should return the input when it is not a gid', ({ value }) => { + jest.spyOn(utils, 'isGid').mockReturnValue(false); + expect(getIdFromGid(value)).toBe(value); + }); +}); + +describe('getAutosaveKey', () => { + it.each` + noteableType | noteId | expectedKey + ${'Issue'} | ${'1'} | ${'Note/Issue/1'} + ${'MergeRequest'} | ${'2'} | ${'Note/MergeRequest/2'} + `('should return the correct key', ({ noteableType, noteId, expectedKey }) => { + expect(getAutosaveKey(noteableType, noteId)).toBe(expectedKey); + }); +}); -- GitLab