diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index fb7f5124e6d689f3039bc7d07c108d001df4e6a7..fcf89b1d54e6bff69bb4cda63beb5dbffc6e50d5 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 ba7322b753059eed762e6bac99269db5837c4858..384aa841a5cfc56d96eaf166c5651cd3735556d5 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 9c469ff5353161bd616e2e14546a2e651425af89..7b788b1a07878be61ec4a4a6ffae28722b6df0bc 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 0000000000000000000000000000000000000000..764150a983868be9c01e1f2fe45e2f7013404c47 --- /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 0000000000000000000000000000000000000000..5e5d9494bf1730b150b5de4e021637e89facaeaa --- /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 0000000000000000000000000000000000000000..6b271cd588167ecd984c45b324ec1f9885e8e994 --- /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 0000000000000000000000000000000000000000..457b9e2ed00682e3dcec5dda7d30fa7c7d6027a2 --- /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 0000000000000000000000000000000000000000..54eb86e14432e7201a80c8574669ea34f549a6e8 --- /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 0000000000000000000000000000000000000000..868fca2f54dc2d21b6871435db2ce61fe3b16706 --- /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 0000000000000000000000000000000000000000..89966550cb7c2b1bfda48a748a5eacfc7658b804 --- /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 0000000000000000000000000000000000000000..8b80c46f5e42aad05a04ed603028558fb2da5647 --- /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 0000000000000000000000000000000000000000..2ac97bb4f5e18302c0ba51fdb10004efa9a2109f --- /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 0000000000000000000000000000000000000000..3a1cfd0e5b2246e26a92c7fc7cff20a05ddecf86 --- /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 0000000000000000000000000000000000000000..30eb40186f75dbfbd3d4f67a864532b6366fd262 --- /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 0000000000000000000000000000000000000000..e7aa8d3aa6e8c8eb58f14e5934294fde944eb18c --- /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 0000000000000000000000000000000000000000..3f263c7ac9ee9320f00d91a44c25fe6de2c4f6df --- /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 0000000000000000000000000000000000000000..6a24134932a73813f79fd3f7c65b5fd7c932bc70 --- /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 dfdee05977f23f0e420cb9a04491a7f0f6caa4b6..106fc54f8308f6e4ddd5e75e4fe4adf99b5d483f 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 20317a8230b518e2809cec3c1b5a321aa927231a..a7ee6f2bd1ccfdbd3a4a731fb42d55061f60d845 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 2dc61e496f13b79167e5a1554844f12b1d16de9f..3b5e63860907d492451d6fda3439d8479a0f3d73 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 0000000000000000000000000000000000000000..c9bc247c80cec78c5632aa34ce75b5fe4d409bc6 --- /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 0000000000000000000000000000000000000000..aa324c143dd31b7505efbf1a773c590a0fbd40f3 --- /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 0000000000000000000000000000000000000000..7cbb3d29f672ac1fca1358fc9668ba71eb87c57b --- /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 0000000000000000000000000000000000000000..8bd17806a5492a7db511fdf16a098b3a53673c6b --- /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 0000000000000000000000000000000000000000..7646d1d7cc07043778c067a2f43fd48e9519b6ab --- /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 0000000000000000000000000000000000000000..78313abaaa75279b302017456f0866c276599534 --- /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 0000000000000000000000000000000000000000..af8941889b15354fdfeed7484228d5a4b92f2186 --- /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 0000000000000000000000000000000000000000..6a6b1a0431f884adf9c2858d23d66e9cb7e4fc80 --- /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 0000000000000000000000000000000000000000..b3325caead830cf2001814da448a6c359f3222e2 --- /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 0000000000000000000000000000000000000000..6ca33265f32c9c1f0c62613db18bbdb3495821c5 --- /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 0000000000000000000000000000000000000000..af663946bb45f748f2a3b7205584e9001618faf3 --- /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 0000000000000000000000000000000000000000..2e8ecdeca11d1465ef0cd97cc2ea0808f179e662 --- /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 0000000000000000000000000000000000000000..436507e34bda7d922d94a42c3fdbd4323a01018b --- /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); + }); +});