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 @@
+
+
+
+
+
+
+
+
+
+ {{ __('Copy link') }}
+
+
+
+
+
+ {{ $options.i18n.reportAbuse }}
+
+
+
+
+ {{ __('Delete comment') }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ archivedContainerWarning }}
+
+ {{ __('Learn more') }}
+
+
+
+
+ {{ lockedDiscussionWarning }}
+
+ {{ __('Learn more') }}
+
+
+
+
+
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 @@
+
+
+
+
{{ $options.i18n.headerText }}
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $options.i18n.loadingFailedErrText }}
+
+
+
+
+
+
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);
+ });
+});