Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/content_editor')
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap62
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js126
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js (renamed from spec/frontend/content_editor/components/bubble_menus/code_block_spec.js)8
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js (renamed from spec/frontend/content_editor/components/bubble_menus/formatting_spec.js)11
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js (renamed from spec/frontend/content_editor/components/bubble_menus/link_spec.js)136
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js (renamed from spec/frontend/content_editor/components/bubble_menus/media_spec.js)13
-rw-r--r--spec/frontend/content_editor/components/content_editor_alert_spec.js25
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js213
-rw-r--r--spec/frontend/content_editor/components/editor_state_observer_spec.js26
-rw-r--r--spec/frontend/content_editor/components/loading_indicator_spec.js46
-rw-r--r--spec/frontend/content_editor/components/toolbar_image_button_spec.js21
-rw-r--r--spec/frontend/content_editor/components/toolbar_link_button_spec.js18
-rw-r--r--spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js17
-rw-r--r--spec/frontend/content_editor/components/toolbar_table_button_spec.js14
-rw-r--r--spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js4
-rw-r--r--spec/frontend/content_editor/components/wrappers/code_block_spec.js6
-rw-r--r--spec/frontend/content_editor/extensions/paste_markdown_spec.js21
-rw-r--r--spec/frontend/content_editor/remark_markdown_processing_spec.js73
-rw-r--r--spec/frontend/content_editor/render_html_and_json_for_all_examples.js6
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js95
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js21
21 files changed, 689 insertions, 273 deletions
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
index b54f7cf17c8..6ad8a9de8d3 100644
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
+++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
@@ -1,49 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`content_editor/components/toolbar_link_button renders dropdown component 1`] = `
-"<div class=\\"dropdown b-dropdown gl-new-dropdown btn-group\\" aria-label=\\"Insert link\\" title=\\"Insert link\\">
- <!----><button aria-haspopup=\\"true\\" aria-expanded=\\"false\\" type=\\"button\\" class=\\"btn dropdown-toggle btn-default btn-sm gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only\\">
- <!----> <svg data-testid=\\"link-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"dropdown-icon gl-icon s16\\">
- <use href=\\"#link\\"></use>
- </svg> <span class=\\"gl-new-dropdown-button-text\\"></span> <svg data-testid=\\"chevron-down-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"gl-button-icon dropdown-chevron gl-icon s16\\">
- <use href=\\"#chevron-down\\"></use>
- </svg></button>
- <ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\">
- <div class=\\"gl-new-dropdown-inner\\">
+"<div title=\\"Insert link\\" lazy=\\"\\">
+ <li role=\\"presentation\\" class=\\"gl-px-3!\\">
+ <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\">
+ <div role=\\"group\\" class=\\"input-group\\" placeholder=\\"Link URL\\">
+ <!---->
+ <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\">
+ <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\">
+ <!---->
+ <!----> <span class=\\"gl-button-text\\">Apply</span></button></div>
+ <!---->
+ </div>
+ </form>
+ </li>
+ <li role=\\"presentation\\" class=\\"gl-new-dropdown-divider\\">
+ <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\">
+ </li>
+ <li role=\\"presentation\\" class=\\"gl-new-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\">
+ <!---->
<!---->
<!---->
- <div class=\\"gl-new-dropdown-contents\\">
+ <div class=\\"gl-new-dropdown-item-text-wrapper\\">
+ <p class=\\"gl-new-dropdown-item-text-primary\\">
+ Upload file
+ </p>
<!---->
- <li role=\\"presentation\\" class=\\"gl-px-3!\\">
- <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\">
- <div role=\\"group\\" class=\\"input-group\\" placeholder=\\"Link URL\\">
- <!---->
- <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\">
- <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\">
- <!---->
- <!----> <span class=\\"gl-button-text\\">Apply</span></button></div>
- <!---->
- </div>
- </form>
- </li>
- <li role=\\"presentation\\" class=\\"gl-new-dropdown-divider\\">
- <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\">
- </li>
- <li role=\\"presentation\\" class=\\"gl-new-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\">
- <!---->
- <!---->
- <!---->
- <div class=\\"gl-new-dropdown-item-text-wrapper\\">
- <p class=\\"gl-new-dropdown-item-text-primary\\">
- Upload file
- </p>
- <!---->
- </div>
- <!---->
- </button></li> <input type=\\"file\\" name=\\"content_editor_attachment\\" class=\\"gl-display-none\\">
</div>
<!---->
- </div>
- </ul>
+ </button></li>
</div>"
`;
diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js
new file mode 100644
index 00000000000..0700cf5d529
--- /dev/null
+++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js
@@ -0,0 +1,126 @@
+import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
+import { createTestEditor } from '../../test_utils';
+
+jest.mock('@tiptap/extension-bubble-menu');
+
+describe('content_editor/components/bubble_menus/bubble_menu', () => {
+ let wrapper;
+ let tiptapEditor;
+ const pluginKey = 'key';
+ const shouldShow = jest.fn();
+ const tippyOptions = { placement: 'bottom' };
+ const pluginInitializationResult = {};
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor();
+ };
+
+ const createWrapper = (propsData = {}) => {
+ wrapper = shallowMountExtended(BubbleMenu, {
+ provide: {
+ tiptapEditor,
+ },
+ propsData: {
+ pluginKey,
+ shouldShow,
+ tippyOptions,
+ ...propsData,
+ },
+ slots: {
+ default: '<div>menu content</div>',
+ },
+ });
+ };
+
+ const setupMocks = () => {
+ BubbleMenuPlugin.mockReturnValueOnce(pluginInitializationResult);
+ jest.spyOn(tiptapEditor, 'registerPlugin').mockImplementationOnce(() => true);
+ };
+
+ const invokeTippyEvent = (eventName, eventArgs) => {
+ const pluginConfig = BubbleMenuPlugin.mock.calls[0][0];
+
+ pluginConfig.tippyOptions[eventName](eventArgs);
+ };
+
+ beforeEach(() => {
+ buildEditor();
+ setupMocks();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('initializes BubbleMenuPlugin', async () => {
+ createWrapper({});
+
+ await nextTick();
+
+ expect(BubbleMenuPlugin).toHaveBeenCalledWith({
+ pluginKey,
+ editor: tiptapEditor,
+ shouldShow,
+ element: wrapper.vm.$el,
+ tippyOptions: expect.objectContaining({
+ onHidden: expect.any(Function),
+ onShow: expect.any(Function),
+ ...tippyOptions,
+ }),
+ });
+
+ expect(tiptapEditor.registerPlugin).toHaveBeenCalledWith(pluginInitializationResult);
+ });
+
+ it('does not render default slot by default', async () => {
+ createWrapper({});
+
+ await nextTick();
+
+ expect(wrapper.text()).not.toContain('menu content');
+ });
+
+ describe('when onShow event handler is invoked', () => {
+ const onShowArgs = {};
+
+ beforeEach(async () => {
+ createWrapper({});
+
+ await nextTick();
+
+ invokeTippyEvent('onShow', onShowArgs);
+ });
+
+ it('displays the menu content', () => {
+ expect(wrapper.text()).toContain('menu content');
+ });
+
+ it('emits show event', () => {
+ expect(wrapper.emitted('show')).toEqual([[onShowArgs]]);
+ });
+ });
+
+ describe('when onHidden event handler is invoked', () => {
+ const onHiddenArgs = {};
+
+ beforeEach(async () => {
+ createWrapper({});
+
+ await nextTick();
+
+ invokeTippyEvent('onShow', onHiddenArgs);
+ invokeTippyEvent('onHidden', onHiddenArgs);
+ });
+
+ it('displays the menu content', () => {
+ expect(wrapper.text()).not.toContain('menu content');
+ });
+
+ it('emits show event', () => {
+ expect(wrapper.emitted('hidden')).toEqual([[onHiddenArgs]]);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js
index 154035a46ed..378b11f4ae9 100644
--- a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js
@@ -1,4 +1,3 @@
-import { BubbleMenu } from '@tiptap/vue-2';
import {
GlDropdown,
GlDropdownForm,
@@ -9,8 +8,9 @@ import {
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
-import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block.vue';
+import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
+import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import Diagram from '~/content_editor/extensions/diagram';
import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader';
@@ -18,7 +18,7 @@ import { createTestEditor, emitEditorEvent } from '../../test_utils';
const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() });
-describe('content_editor/components/bubble_menus/code_block', () => {
+describe('content_editor/components/bubble_menus/code_block_bubble_menu', () => {
let wrapper;
let tiptapEditor;
let contentEditor;
@@ -40,6 +40,7 @@ describe('content_editor/components/bubble_menus/code_block', () => {
},
stubs: {
GlDropdownItem: stubComponent(GlDropdownItem),
+ BubbleMenu: stubComponent(BubbleMenu),
},
});
};
@@ -73,7 +74,6 @@ describe('content_editor/components/bubble_menus/code_block', () => {
await emitEditorEvent({ event: 'transaction', tiptapEditor });
- expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
});
diff --git a/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js
index 1e2f58d9e40..cce17176129 100644
--- a/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js
@@ -1,7 +1,8 @@
-import { BubbleMenu } from '@tiptap/vue-2';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue';
+import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting_bubble_menu.vue';
+import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
+import { stubComponent } from 'helpers/stub_component';
import {
BUBBLE_MENU_TRACKING_ACTION,
@@ -9,7 +10,7 @@ import {
} from '~/content_editor/constants';
import { createTestEditor } from '../../test_utils';
-describe('content_editor/components/bubble_menus/formatting', () => {
+describe('content_editor/components/bubble_menus/formatting_bubble_menu', () => {
let wrapper;
let trackingSpy;
let tiptapEditor;
@@ -25,6 +26,9 @@ describe('content_editor/components/bubble_menus/formatting', () => {
provide: {
tiptapEditor,
},
+ stubs: {
+ BubbleMenu: stubComponent(BubbleMenu),
+ },
});
};
@@ -41,7 +45,6 @@ describe('content_editor/components/bubble_menus/formatting', () => {
buildWrapper();
const bubbleMenu = wrapper.findComponent(BubbleMenu);
- expect(bubbleMenu.props().editor).toBe(tiptapEditor);
expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
});
diff --git a/spec/frontend/content_editor/components/bubble_menus/link_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js
index 93204deb68c..9aa9c6483f4 100644
--- a/spec/frontend/content_editor/components/bubble_menus/link_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js
@@ -1,18 +1,20 @@
import { GlLink, GlForm } from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
+import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link.vue';
+import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
+import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
+import { stubComponent } from 'helpers/stub_component';
import Link from '~/content_editor/extensions/link';
-import { createTestEditor, emitEditorEvent } from '../../test_utils';
+import { createTestEditor } from '../../test_utils';
const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() });
-describe('content_editor/components/bubble_menus/link', () => {
+describe('content_editor/components/bubble_menus/link_bubble_menu', () => {
let wrapper;
let tiptapEditor;
let contentEditor;
- let bubbleMenu;
let eventHub;
const buildEditor = () => {
@@ -28,9 +30,28 @@ describe('content_editor/components/bubble_menus/link', () => {
contentEditor,
eventHub,
},
+ stubs: {
+ BubbleMenu: stubComponent(BubbleMenu),
+ },
});
};
+ const showMenu = () => {
+ wrapper.findComponent(BubbleMenu).vm.$emit('show');
+ return nextTick();
+ };
+
+ const buildWrapperAndDisplayMenu = () => {
+ buildWrapper();
+
+ return showMenu();
+ };
+
+ const findBubbleMenu = () => wrapper.findComponent(BubbleMenu);
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
+ const findEditLinkButton = () => wrapper.findByTestId('edit-link');
+
const expectLinkButtonsToExist = (exist = true) => {
expect(wrapper.findComponent(GlLink).exists()).toBe(exist);
expect(wrapper.findByTestId('copy-link-url').exists()).toBe(exist);
@@ -40,7 +61,6 @@ describe('content_editor/components/bubble_menus/link', () => {
beforeEach(async () => {
buildEditor();
- buildWrapper();
tiptapEditor
.chain()
@@ -49,10 +69,6 @@ describe('content_editor/components/bubble_menus/link', () => {
)
.setTextSelection(14) // put cursor in the middle of the link
.run();
-
- await emitEditorEvent({ event: 'transaction', tiptapEditor });
-
- bubbleMenu = wrapper.findComponent(BubbleMenu);
});
afterEach(() => {
@@ -60,13 +76,15 @@ describe('content_editor/components/bubble_menus/link', () => {
});
it('renders bubble menu component', async () => {
- expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
- expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
+ await buildWrapperAndDisplayMenu();
+
+ expect(findBubbleMenu().classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
});
it('shows a clickable link to the URL in the link node', async () => {
- const link = wrapper.findComponent(GlLink);
- expect(link.attributes()).toEqual(
+ await buildWrapperAndDisplayMenu();
+
+ expect(findLink().attributes()).toEqual(
expect.objectContaining({
href: '/path/to/project/-/wikis/uploads/my_file.pdf',
'aria-label': 'uploads/my_file.pdf',
@@ -74,11 +92,82 @@ describe('content_editor/components/bubble_menus/link', () => {
target: '_blank',
}),
);
- expect(link.text()).toBe('uploads/my_file.pdf');
+ expect(findLink().text()).toBe('uploads/my_file.pdf');
+ });
+
+ it('updates the bubble menu state when @selectionUpdate event is triggered', async () => {
+ const linkUrl = 'https://gitlab.com';
+
+ await buildWrapperAndDisplayMenu();
+
+ expect(findLink().attributes()).toEqual(
+ expect.objectContaining({
+ href: '/path/to/project/-/wikis/uploads/my_file.pdf',
+ }),
+ );
+
+ tiptapEditor
+ .chain()
+ .setContent(
+ `Link to <a href="${linkUrl}" data-canonical-src="${linkUrl}" title="Click here to download">GitLab</a>`,
+ )
+ .setTextSelection(11)
+ .run();
+
+ findEditorStateObserver().vm.$emit('selectionUpdate');
+
+ await nextTick();
+
+ expect(findLink().attributes()).toEqual(
+ expect.objectContaining({
+ href: linkUrl,
+ }),
+ );
+ });
+
+ describe('when the selection changes within the same link', () => {
+ it('does not update the bubble menu state', async () => {
+ await buildWrapperAndDisplayMenu();
+
+ await findEditLinkButton().trigger('click');
+
+ expect(wrapper.findComponent(GlForm).exists()).toBe(true);
+
+ tiptapEditor.commands.setTextSelection(13);
+
+ findEditorStateObserver().vm.$emit('selectionUpdate');
+
+ await nextTick();
+
+ expect(wrapper.findComponent(GlForm).exists()).toBe(true);
+ });
+ });
+
+ it('cleans bubble menu state when hidden event is triggered', async () => {
+ await buildWrapperAndDisplayMenu();
+
+ expect(findLink().attributes()).toEqual(
+ expect.objectContaining({
+ href: '/path/to/project/-/wikis/uploads/my_file.pdf',
+ }),
+ );
+
+ findBubbleMenu().vm.$emit('hidden');
+
+ await nextTick();
+
+ expect(findLink().attributes()).toEqual(
+ expect.objectContaining({
+ href: '#',
+ }),
+ );
+ expect(findLink().text()).toEqual('');
});
describe('copy button', () => {
it('copies the canonical link to clipboard', async () => {
+ await buildWrapperAndDisplayMenu();
+
jest.spyOn(navigator.clipboard, 'writeText');
await wrapper.findByTestId('copy-link-url').vm.$emit('click');
@@ -89,6 +178,7 @@ describe('content_editor/components/bubble_menus/link', () => {
describe('remove link button', () => {
it('removes the link', async () => {
+ await buildWrapperAndDisplayMenu();
await wrapper.findByTestId('remove-link').vm.$emit('click');
expect(tiptapEditor.getHTML()).toBe('<p>Download PDF File</p>');
@@ -106,7 +196,7 @@ describe('content_editor/components/bubble_menus/link', () => {
.setTextSelection(4)
.run();
- await emitEditorEvent({ event: 'transaction', tiptapEditor });
+ await buildWrapperAndDisplayMenu();
});
it('directly opens the edit form for a placeholder link', async () => {
@@ -133,6 +223,7 @@ describe('content_editor/components/bubble_menus/link', () => {
let linkTitleInput;
beforeEach(async () => {
+ await buildWrapperAndDisplayMenu();
await wrapper.findByTestId('edit-link').vm.$emit('click');
linkHrefInput = wrapper.findByTestId('link-href');
@@ -157,19 +248,6 @@ describe('content_editor/components/bubble_menus/link', () => {
expect(to).toBe(18);
});
- it('shows the copy/edit/remove link buttons again if selection changes to another non-link and then back again to a link', async () => {
- expectLinkButtonsToExist(false);
-
- tiptapEditor.commands.setTextSelection(3);
- await emitEditorEvent({ event: 'transaction', tiptapEditor });
-
- tiptapEditor.commands.setTextSelection(14);
- await emitEditorEvent({ event: 'transaction', tiptapEditor });
-
- expectLinkButtonsToExist(true);
- expect(wrapper.findComponent(GlForm).exists()).toBe(false);
- });
-
describe('after making changes in the form and clicking apply', () => {
beforeEach(async () => {
linkHrefInput.setValue('https://google.com');
diff --git a/spec/frontend/content_editor/components/bubble_menus/media_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js
index fada4f06743..13c6495ac41 100644
--- a/spec/frontend/content_editor/components/bubble_menus/media_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js
@@ -1,7 +1,8 @@
import { GlLink, GlForm } from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media.vue';
+import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
+import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue';
+import { stubComponent } from 'helpers/stub_component';
import eventHubFactory from '~/helpers/event_hub_factory';
import Image from '~/content_editor/extensions/image';
import Audio from '~/content_editor/extensions/audio';
@@ -33,7 +34,7 @@ describe.each`
${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML}
${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML}
`(
- 'content_editor/components/bubble_menus/media ($mediaType)',
+ 'content_editor/components/bubble_menus/media_bubble_menu ($mediaType)',
({ mediaType, mediaHTML, filePath, mediaOutputHTML }) => {
let wrapper;
let tiptapEditor;
@@ -54,11 +55,14 @@ describe.each`
contentEditor,
eventHub,
},
+ stubs: {
+ BubbleMenu: stubComponent(BubbleMenu),
+ },
});
};
const selectFile = async (file) => {
- const input = wrapper.find({ ref: 'fileSelector' });
+ const input = wrapper.findComponent({ ref: 'fileSelector' });
// override the property definition because `input.files` isn't directly modifyable
Object.defineProperty(input.element, 'files', { value: [file], writable: true });
@@ -94,7 +98,6 @@ describe.each`
});
it('renders bubble menu component', async () => {
- expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
});
diff --git a/spec/frontend/content_editor/components/content_editor_alert_spec.js b/spec/frontend/content_editor/components/content_editor_alert_spec.js
index 12484cb13c6..ee9ead8f8a7 100644
--- a/spec/frontend/content_editor/components/content_editor_alert_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js
@@ -51,6 +51,16 @@ describe('content_editor/components/content_editor_alert', () => {
},
);
+ it('does not show primary action by default', async () => {
+ const message = 'error message';
+
+ createWrapper();
+ eventHub.$emit(ALERT_EVENT, { message });
+ await nextTick();
+
+ expect(findErrorAlert().attributes().primaryButtonText).toBeUndefined();
+ });
+
it('allows dismissing the error', async () => {
const message = 'error message';
@@ -62,4 +72,19 @@ describe('content_editor/components/content_editor_alert', () => {
expect(findErrorAlert().exists()).toBe(false);
});
+
+ it('allows dismissing the error with a primary action button', async () => {
+ const message = 'error message';
+ const actionLabel = 'Retry';
+ const action = jest.fn();
+
+ createWrapper();
+ eventHub.$emit(ALERT_EVENT, { message, action, actionLabel });
+ await nextTick();
+ findErrorAlert().vm.$emit('primaryAction');
+ await nextTick();
+
+ expect(action).toHaveBeenCalled();
+ expect(findErrorAlert().exists()).toBe(false);
+ });
});
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 0ba2672100b..ae52cb05eaf 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -1,136 +1,227 @@
-import { EditorContent } from '@tiptap/vue-2';
+import { GlAlert } from '@gitlab/ui';
+import { EditorContent, Editor } from '@tiptap/vue-2';
+import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
-import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue';
+import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting_bubble_menu.vue';
+import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue';
+import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue';
+import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
-import { emitEditorEvent } from '../test_utils';
+import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/emoji');
describe('ContentEditor', () => {
let wrapper;
- let contentEditor;
let renderMarkdown;
const uploadsPath = '/uploads';
const findEditorElement = () => wrapper.findByTestId('content-editor');
const findEditorContent = () => wrapper.findComponent(EditorContent);
const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
- const createWrapper = (propsData = {}) => {
- renderMarkdown = jest.fn();
-
+ const findLoadingIndicator = () => wrapper.findComponent(LoadingIndicator);
+ const findContentEditorAlert = () => wrapper.findComponent(ContentEditorAlert);
+ const createWrapper = ({ markdown } = {}) => {
wrapper = shallowMountExtended(ContentEditor, {
propsData: {
renderMarkdown,
uploadsPath,
- ...propsData,
+ markdown,
},
stubs: {
EditorStateObserver,
ContentEditorProvider,
- },
- listeners: {
- initialized(editor) {
- contentEditor = editor;
- },
+ ContentEditorAlert,
},
});
};
+ beforeEach(() => {
+ renderMarkdown = jest.fn();
+ });
+
afterEach(() => {
wrapper.destroy();
});
- it('triggers initialized event and provides contentEditor instance as event data', () => {
+ it('triggers initialized event', () => {
createWrapper();
- expect(contentEditor).not.toBeFalsy();
+ expect(wrapper.emitted('initialized')).toHaveLength(1);
});
- it('renders EditorContent component and provides tiptapEditor instance', () => {
- createWrapper();
+ it('renders EditorContent component and provides tiptapEditor instance', async () => {
+ const markdown = 'hello world';
+
+ createWrapper({ markdown });
+
+ renderMarkdown.mockResolvedValueOnce(markdown);
+
+ await nextTick();
const editorContent = findEditorContent();
- expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor);
+ expect(editorContent.props().editor).toBeInstanceOf(Editor);
expect(editorContent.classes()).toContain('md');
});
- it('renders ContentEditorProvider component', () => {
- createWrapper();
+ it('renders ContentEditorProvider component', async () => {
+ await createWrapper();
expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true);
});
- it('renders top toolbar component', () => {
- createWrapper();
+ it('renders top toolbar component', async () => {
+ await createWrapper();
expect(wrapper.findComponent(TopToolbar).exists()).toBe(true);
});
- it('adds is-focused class when focus event is emitted', async () => {
- createWrapper();
+ describe('when setting initial content', () => {
+ it('displays loading indicator', async () => {
+ createWrapper();
- await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
+ await nextTick();
- expect(findEditorElement().classes()).toContain('is-focused');
- });
+ expect(findLoadingIndicator().exists()).toBe(true);
+ });
- it('removes is-focused class when blur event is emitted', async () => {
- createWrapper();
+ it('emits loading event', async () => {
+ createWrapper();
- await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
- await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' });
+ await nextTick();
- expect(findEditorElement().classes()).not.toContain('is-focused');
- });
+ expect(wrapper.emitted('loading')).toHaveLength(1);
+ });
- it('emits change event when document is updated', async () => {
- createWrapper();
+ describe('succeeds', () => {
+ beforeEach(async () => {
+ renderMarkdown.mockResolvedValueOnce('hello world');
- await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' });
+ createWrapper({ markddown: 'hello world' });
+ await nextTick();
+ });
- expect(wrapper.emitted('change')).toEqual([
- [
- {
- empty: contentEditor.empty,
- },
- ],
- ]);
- });
+ it('hides loading indicator', async () => {
+ await nextTick();
+ expect(findLoadingIndicator().exists()).toBe(false);
+ });
- it('renders content_editor_alert component', () => {
- createWrapper();
+ it('emits loadingSuccess event', () => {
+ expect(wrapper.emitted('loadingSuccess')).toHaveLength(1);
+ });
+ });
+
+ describe('fails', () => {
+ beforeEach(async () => {
+ renderMarkdown.mockRejectedValueOnce(new Error());
+
+ createWrapper({ markddown: 'hello world' });
+ await nextTick();
+ });
+
+ it('sets the content editor as read only when loading content fails', async () => {
+ await nextTick();
- expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true);
+ expect(findEditorContent().props().editor.isEditable).toBe(false);
+ });
+
+ it('hides loading indicator', async () => {
+ await nextTick();
+
+ expect(findLoadingIndicator().exists()).toBe(false);
+ });
+
+ it('emits loadingError event', () => {
+ expect(wrapper.emitted('loadingError')).toHaveLength(1);
+ });
+
+ it('displays error alert indicating that the content editor failed to load', () => {
+ expect(findContentEditorAlert().text()).toContain(
+ 'An error occurred while trying to render the content editor. Please try again.',
+ );
+ });
+
+ describe('when clicking the retry button in the loading error alert and loading succeeds', () => {
+ beforeEach(async () => {
+ renderMarkdown.mockResolvedValueOnce('hello markdown');
+ await wrapper.findComponent(GlAlert).vm.$emit('primaryAction');
+ });
+
+ it('hides the loading error alert', () => {
+ expect(findContentEditorAlert().text()).toBe('');
+ });
+
+ it('sets the content editor as writable', async () => {
+ await nextTick();
+
+ expect(findEditorContent().props().editor.isEditable).toBe(true);
+ });
+ });
+ });
});
- it('renders loading indicator component', () => {
- createWrapper();
+ describe('when focused event is emitted', () => {
+ beforeEach(async () => {
+ createWrapper();
+
+ findEditorStateObserver().vm.$emit('focus');
+
+ await nextTick();
+ });
- expect(wrapper.findComponent(LoadingIndicator).exists()).toBe(true);
+ it('adds is-focused class when focus event is emitted', () => {
+ expect(findEditorElement().classes()).toContain('is-focused');
+ });
+
+ it('removes is-focused class when blur event is emitted', async () => {
+ findEditorStateObserver().vm.$emit('blur');
+
+ await nextTick();
+
+ expect(findEditorElement().classes()).not.toContain('is-focused');
+ });
});
- it('renders formatting bubble menu', () => {
- createWrapper();
+ describe('when editorStateObserver emits docUpdate event', () => {
+ it('emits change event with the latest markdown', async () => {
+ const markdown = 'Loaded content';
- expect(wrapper.findComponent(FormattingBubbleMenu).exists()).toBe(true);
+ renderMarkdown.mockResolvedValueOnce(markdown);
+
+ createWrapper({ markdown: 'initial content' });
+
+ await nextTick();
+ await waitForPromises();
+
+ findEditorStateObserver().vm.$emit('docUpdate');
+
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ markdown,
+ changed: false,
+ empty: false,
+ },
+ ],
+ ]);
+ });
});
it.each`
- event
- ${'loading'}
- ${'loadingSuccess'}
- ${'loadingError'}
- `('broadcasts $event event triggered by editor-state-observer component', ({ event }) => {
+ name | component
+ ${'formatting'} | ${FormattingBubbleMenu}
+ ${'link'} | ${LinkBubbleMenu}
+ ${'media'} | ${MediaBubbleMenu}
+ ${'codeBlock'} | ${CodeBlockBubbleMenu}
+ `('renders formatting bubble menu', ({ component }) => {
createWrapper();
- findEditorStateObserver().vm.$emit(event);
-
- expect(wrapper.emitted(event)).toHaveLength(1);
+ expect(wrapper.findComponent(component).exists()).toBe(true);
});
});
diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js
index 51a594a606b..e8c2d8c8793 100644
--- a/spec/frontend/content_editor/components/editor_state_observer_spec.js
+++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js
@@ -4,12 +4,7 @@ import EditorStateObserver, {
tiptapToComponentMap,
} from '~/content_editor/components/editor_state_observer.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
-import {
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
- ALERT_EVENT,
-} from '~/content_editor/constants';
+import { ALERT_EVENT } from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/editor_state_observer', () => {
@@ -18,9 +13,6 @@ describe('content_editor/components/editor_state_observer', () => {
let onDocUpdateListener;
let onSelectionUpdateListener;
let onTransactionListener;
- let onLoadingContentListener;
- let onLoadingSuccessListener;
- let onLoadingErrorListener;
let onAlertListener;
let eventHub;
@@ -38,9 +30,6 @@ describe('content_editor/components/editor_state_observer', () => {
selectionUpdate: onSelectionUpdateListener,
transaction: onTransactionListener,
[ALERT_EVENT]: onAlertListener,
- [LOADING_CONTENT_EVENT]: onLoadingContentListener,
- [LOADING_SUCCESS_EVENT]: onLoadingSuccessListener,
- [LOADING_ERROR_EVENT]: onLoadingErrorListener,
},
});
};
@@ -50,9 +39,6 @@ describe('content_editor/components/editor_state_observer', () => {
onSelectionUpdateListener = jest.fn();
onTransactionListener = jest.fn();
onAlertListener = jest.fn();
- onLoadingSuccessListener = jest.fn();
- onLoadingContentListener = jest.fn();
- onLoadingErrorListener = jest.fn();
buildEditor();
});
@@ -81,11 +67,8 @@ describe('content_editor/components/editor_state_observer', () => {
});
it.each`
- event | listener
- ${ALERT_EVENT} | ${() => onAlertListener}
- ${LOADING_CONTENT_EVENT} | ${() => onLoadingContentListener}
- ${LOADING_SUCCESS_EVENT} | ${() => onLoadingSuccessListener}
- ${LOADING_ERROR_EVENT} | ${() => onLoadingErrorListener}
+ event | listener
+ ${ALERT_EVENT} | ${() => onAlertListener}
`('listens to $event event in the eventBus object', ({ event, listener }) => {
const args = {};
@@ -114,9 +97,6 @@ describe('content_editor/components/editor_state_observer', () => {
it.each`
event
${ALERT_EVENT}
- ${LOADING_CONTENT_EVENT}
- ${LOADING_SUCCESS_EVENT}
- ${LOADING_ERROR_EVENT}
`('removes $event event hook from eventHub', ({ event }) => {
jest.spyOn(eventHub, '$off');
jest.spyOn(eventHub, '$on');
diff --git a/spec/frontend/content_editor/components/loading_indicator_spec.js b/spec/frontend/content_editor/components/loading_indicator_spec.js
index e4fb09b70a4..0065103d01b 100644
--- a/spec/frontend/content_editor/components/loading_indicator_spec.js
+++ b/spec/frontend/content_editor/components/loading_indicator_spec.js
@@ -1,18 +1,10 @@
import { GlLoadingIcon } from '@gitlab/ui';
-import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
-import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
-import {
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
-} from '~/content_editor/constants';
describe('content_editor/components/loading_indicator', () => {
let wrapper;
- const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const createWrapper = () => {
@@ -24,48 +16,12 @@ describe('content_editor/components/loading_indicator', () => {
});
describe('when loading content', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createWrapper();
-
- findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
-
- await nextTick();
});
it('displays loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
-
- describe('when loading content succeeds', () => {
- beforeEach(async () => {
- createWrapper();
-
- findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
- await nextTick();
- findEditorStateObserver().vm.$emit(LOADING_SUCCESS_EVENT);
- await nextTick();
- });
-
- it('hides loading indicator', () => {
- expect(findLoadingIcon().exists()).toBe(false);
- });
- });
-
- describe('when loading content fails', () => {
- const error = 'error';
-
- beforeEach(async () => {
- createWrapper();
-
- findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
- await nextTick();
- findEditorStateObserver().vm.$emit(LOADING_ERROR_EVENT, error);
- await nextTick();
- });
-
- it('hides loading indicator', () => {
- expect(findLoadingIcon().exists()).toBe(false);
- });
- });
});
diff --git a/spec/frontend/content_editor/components/toolbar_image_button_spec.js b/spec/frontend/content_editor/components/toolbar_image_button_spec.js
index dab7e67d7c5..5473d43f5a1 100644
--- a/spec/frontend/content_editor/components/toolbar_image_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_image_button_spec.js
@@ -1,8 +1,9 @@
-import { GlButton, GlFormInputGroup } from '@gitlab/ui';
+import { GlButton, GlFormInputGroup, GlDropdown } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue';
import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image';
+import { stubComponent } from 'helpers/stub_component';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_image_button', () => {
@@ -14,15 +15,19 @@ describe('content_editor/components/toolbar_image_button', () => {
provide: {
tiptapEditor: editor,
},
+ stubs: {
+ GlDropdown: stubComponent(GlDropdown),
+ },
});
};
const findImageURLInput = () =>
wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
const findApplyImageButton = () => wrapper.findComponent(GlButton);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
const selectFile = async (file) => {
- const input = wrapper.find({ ref: 'fileSelector' });
+ const input = wrapper.findComponent({ ref: 'fileSelector' });
// override the property definition because `input.files` isn't directly modifyable
Object.defineProperty(input.element, 'files', { value: [file], writable: true });
@@ -77,4 +82,16 @@ describe('content_editor/components/toolbar_image_button', () => {
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]);
});
+
+ describe('a11y tests', () => {
+ it('sets text, title, and text-sr-only properties to the table button dropdown', () => {
+ buildWrapper();
+
+ expect(findDropdown().props()).toMatchObject({
+ text: 'Insert image',
+ textSrOnly: true,
+ });
+ expect(findDropdown().attributes('title')).toBe('Insert image');
+ });
+ });
});
diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js
index fc26a9da471..40e859e96af 100644
--- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js
@@ -4,6 +4,7 @@ import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.v
import eventHubFactory from '~/helpers/event_hub_factory';
import Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
+import { stubComponent } from 'helpers/stub_component';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
jest.mock('~/content_editor/services/utils');
@@ -18,6 +19,9 @@ describe('content_editor/components/toolbar_link_button', () => {
tiptapEditor: editor,
eventHub: eventHubFactory(),
},
+ stubs: {
+ GlDropdown: stubComponent(GlDropdown),
+ },
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
@@ -26,7 +30,7 @@ describe('content_editor/components/toolbar_link_button', () => {
const findRemoveLinkButton = () => wrapper.findByText('Remove link');
const selectFile = async (file) => {
- const input = wrapper.find({ ref: 'fileSelector' });
+ const input = wrapper.findComponent({ ref: 'fileSelector' });
// override the property definition because `input.files` isn't directly modifyable
Object.defineProperty(input.element, 'files', { value: [file], writable: true });
@@ -205,4 +209,16 @@ describe('content_editor/components/toolbar_link_button', () => {
});
});
});
+
+ describe('a11y tests', () => {
+ it('sets text, title, and text-sr-only properties to the table button dropdown', () => {
+ buildWrapper();
+
+ expect(findDropdown().props()).toMatchObject({
+ text: 'Insert link',
+ textSrOnly: true,
+ });
+ expect(findDropdown().attributes('title')).toBe('Insert link');
+ });
+ });
});
diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
index 62fec8d4e72..a23f8370adf 100644
--- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
@@ -1,8 +1,10 @@
+import { GlDropdown } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarMoreDropdown from '~/content_editor/components/toolbar_more_dropdown.vue';
import Diagram from '~/content_editor/extensions/diagram';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
import eventHubFactory from '~/helpers/event_hub_factory';
+import { stubComponent } from 'helpers/stub_component';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_more_dropdown', () => {
@@ -23,10 +25,15 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
tiptapEditor,
eventHub,
},
+ stubs: {
+ GlDropdown: stubComponent(GlDropdown),
+ },
propsData,
});
};
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+
beforeEach(() => {
buildEditor();
buildWrapper();
@@ -67,4 +74,14 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
expect(wrapper.emitted('execute')).toEqual([[{ contentType }]]);
});
});
+
+ describe('a11y tests', () => {
+ it('sets text, title, and text-sr-only properties to the table button dropdown', () => {
+ expect(findDropdown().props()).toMatchObject({
+ text: 'More',
+ textSrOnly: true,
+ });
+ expect(findDropdown().attributes('title')).toBe('More');
+ });
+ });
});
diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
index 056e5e04e1f..aa4604661e5 100644
--- a/spec/frontend/content_editor/components/toolbar_table_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
@@ -1,6 +1,7 @@
import { GlDropdown, GlButton } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue';
+import { stubComponent } from 'helpers/stub_component';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_table_button', () => {
@@ -12,6 +13,9 @@ describe('content_editor/components/toolbar_table_button', () => {
provide: {
tiptapEditor: editor,
},
+ stubs: {
+ GlDropdown: stubComponent(GlDropdown),
+ },
});
};
@@ -98,4 +102,14 @@ describe('content_editor/components/toolbar_table_button', () => {
expect(getNumButtons()).toBe(100); // 10x10 (and not 11x11)
});
+
+ describe('a11y tests', () => {
+ it('sets text, title, and text-sr-only properties to the table button dropdown', () => {
+ expect(findDropdown().props()).toMatchObject({
+ text: 'Insert table',
+ textSrOnly: true,
+ });
+ expect(findDropdown().attributes('title')).toBe('Insert table');
+ });
+ });
});
diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
index 608be1bd693..3ebb305afbf 100644
--- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
@@ -53,7 +53,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
});
});
- describe('when there is an active item ', () => {
+ describe('when there is an active item', () => {
let activeTextStyle;
beforeEach(async () => {
@@ -68,7 +68,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
- it('displays the active text style label as the dropdown toggle text ', () => {
+ it('displays the active text style label as the dropdown toggle text', () => {
expect(findDropdown().props().text).toBe(activeTextStyle.label);
});
diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
index 17a365e12bb..a5ef19fb8e8 100644
--- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
@@ -104,7 +104,7 @@ describe('content/components/wrappers/code_block', () => {
it('does not render a preview if showPreview: false', async () => {
createWrapper({ language: 'plantuml', isDiagram: true, showPreview: false });
- expect(wrapper.find({ ref: 'diagramContainer' }).exists()).toBe(false);
+ expect(wrapper.findComponent({ ref: 'diagramContainer' }).exists()).toBe(false);
});
it('does not update preview when diagram is not active', async () => {
@@ -134,7 +134,7 @@ describe('content/components/wrappers/code_block', () => {
await nextTick();
expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram');
- expect(wrapper.find(SandboxedMermaid).exists()).toBe(false);
+ expect(wrapper.findComponent(SandboxedMermaid).exists()).toBe(false);
});
it('renders an iframe with preview for a mermaid diagram', async () => {
@@ -143,7 +143,7 @@ describe('content/components/wrappers/code_block', () => {
await emitEditorEvent({ event: 'transaction', tiptapEditor });
await nextTick();
- expect(wrapper.find(SandboxedMermaid).props('source')).toBe('');
+ expect(wrapper.findComponent(SandboxedMermaid).props('source')).toBe('');
expect(wrapper.find('img').exists()).toBe(false);
});
});
diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
index 53efda6aee2..30e798e8817 100644
--- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js
+++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
@@ -5,12 +5,7 @@ import Frontmatter from '~/content_editor/extensions/frontmatter';
import Bold from '~/content_editor/extensions/bold';
import { VARIANT_DANGER } from '~/flash';
import eventHubFactory from '~/helpers/event_hub_factory';
-import {
- ALERT_EVENT,
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
-} from '~/content_editor/constants';
+import { ALERT_EVENT } from '~/content_editor/constants';
import waitForPromises from 'helpers/wait_for_promises';
import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils';
@@ -115,13 +110,6 @@ describe('content_editor/extensions/paste_markdown', () => {
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
});
-
- it(`triggers ${LOADING_SUCCESS_EVENT}`, async () => {
- await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
-
- expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_CONTENT_EVENT);
- expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_SUCCESS_EVENT);
- });
});
describe('when rendering markdown fails', () => {
@@ -129,13 +117,6 @@ describe('content_editor/extensions/paste_markdown', () => {
renderMarkdown.mockRejectedValueOnce();
});
- it(`triggers ${LOADING_ERROR_EVENT} event`, async () => {
- await triggerPasteEventHandler(buildClipboardEvent());
- await waitForPromises();
-
- expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_ERROR_EVENT);
- });
-
it(`triggers ${ALERT_EVENT} event`, async () => {
await triggerPasteEventHandler(buildClipboardEvent());
await waitForPromises();
diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js
index 7ae0a7c13c1..bc43af9bd8b 100644
--- a/spec/frontend/content_editor/remark_markdown_processing_spec.js
+++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js
@@ -1,8 +1,10 @@
+import Audio from '~/content_editor/extensions/audio';
import Bold from '~/content_editor/extensions/bold';
import Blockquote from '~/content_editor/extensions/blockquote';
import BulletList from '~/content_editor/extensions/bullet_list';
import Code from '~/content_editor/extensions/code';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import Diagram from '~/content_editor/extensions/diagram';
import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
import FootnoteReference from '~/content_editor/extensions/footnote_reference';
import Frontmatter from '~/content_editor/extensions/frontmatter';
@@ -21,22 +23,27 @@ import Sourcemap from '~/content_editor/extensions/sourcemap';
import Strike from '~/content_editor/extensions/strike';
import Table from '~/content_editor/extensions/table';
import TableHeader from '~/content_editor/extensions/table_header';
+import TableOfContents from '~/content_editor/extensions/table_of_contents';
import TableRow from '~/content_editor/extensions/table_row';
import TableCell from '~/content_editor/extensions/table_cell';
import TaskList from '~/content_editor/extensions/task_list';
import TaskItem from '~/content_editor/extensions/task_item';
+import Video from '~/content_editor/extensions/video';
import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
import markdownSerializer from '~/content_editor/services/markdown_serializer';
+import { SAFE_VIDEO_EXT, SAFE_AUDIO_EXT, DIAGRAM_LANGUAGES } from '~/content_editor/constants';
import { createTestEditor, createDocBuilder } from './test_utils';
const tiptapEditor = createTestEditor({
extensions: [
+ Audio,
Blockquote,
Bold,
BulletList,
Code,
CodeBlockHighlight,
+ Diagram,
FootnoteDefinition,
FootnoteReference,
Frontmatter,
@@ -55,8 +62,10 @@ const tiptapEditor = createTestEditor({
TableRow,
TableHeader,
TableCell,
+ TableOfContents,
TaskList,
TaskItem,
+ Video,
...HTMLNodes,
],
});
@@ -65,12 +74,14 @@ const {
builders: {
doc,
paragraph,
+ audio,
bold,
blockquote,
bulletList,
code,
codeBlock,
div,
+ diagram,
footnoteDefinition,
footnoteReference,
frontmatter,
@@ -89,17 +100,21 @@ const {
tableRow,
tableHeader,
tableCell,
+ tableOfContents,
taskItem,
taskList,
+ video,
},
} = createDocBuilder({
tiptapEditor,
names: {
+ audio: { nodeType: Audio.name },
blockquote: { nodeType: Blockquote.name },
bold: { markType: Bold.name },
bulletList: { nodeType: BulletList.name },
code: { markType: Code.name },
codeBlock: { nodeType: CodeBlockHighlight.name },
+ diagram: { nodeType: Diagram.name },
footnoteDefinition: { nodeType: FootnoteDefinition.name },
footnoteReference: { nodeType: FootnoteReference.name },
frontmatter: { nodeType: Frontmatter.name },
@@ -118,8 +133,10 @@ const {
tableCell: { nodeType: TableCell.name },
tableHeader: { nodeType: TableHeader.name },
tableRow: { nodeType: TableRow.name },
+ tableOfContents: { nodeType: TableOfContents.name },
taskItem: { nodeType: TaskItem.name },
taskList: { nodeType: TaskList.name },
+ video: { nodeType: Video.name },
...HTMLNodes.reduce(
(builders, htmlNode) => ({
...builders,
@@ -1233,6 +1250,62 @@ title: 'layout'
),
),
},
+ ...SAFE_AUDIO_EXT.map((extension) => {
+ const src = `http://test.host/video.${extension}`;
+ const markdown = `![audio](${src})`;
+
+ return {
+ markdown,
+ expectedDoc: doc(
+ paragraph(
+ source(markdown),
+ audio({
+ ...source(markdown),
+ canonicalSrc: src,
+ src,
+ alt: 'audio',
+ }),
+ ),
+ ),
+ };
+ }),
+ ...SAFE_VIDEO_EXT.map((extension) => {
+ const src = `http://test.host/video.${extension}`;
+ const markdown = `![video](${src})`;
+
+ return {
+ markdown,
+ expectedDoc: doc(
+ paragraph(
+ source(markdown),
+ video({
+ ...source(markdown),
+ canonicalSrc: src,
+ src,
+ alt: 'video',
+ }),
+ ),
+ ),
+ };
+ }),
+ ...DIAGRAM_LANGUAGES.map((language) => {
+ const markdown = `\`\`\`${language}
+content
+\`\`\``;
+
+ return {
+ markdown,
+ expectedDoc: doc(diagram({ ...source(markdown), language }, 'content')),
+ };
+ }),
+ {
+ markdown: '[[_TOC_]]',
+ expectedDoc: doc(tableOfContents(source('[[_TOC_]]'))),
+ },
+ {
+ markdown: '[TOC]',
+ expectedDoc: doc(tableOfContents(source('[TOC]'))),
+ },
];
const runOnly = examples.find((example) => example.only === true);
diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
index 4a57c7b1942..bd48b7fdd23 100644
--- a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
+++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
@@ -1,6 +1,7 @@
import { DOMSerializer } from 'prosemirror-model';
// TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js
// See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan
+import Audio from '~/content_editor/extensions/audio';
import Blockquote from '~/content_editor/extensions/blockquote';
import Bold from '~/content_editor/extensions/bold';
import BulletList from '~/content_editor/extensions/bullet_list';
@@ -33,13 +34,16 @@ import Table from '~/content_editor/extensions/table';
import TableCell from '~/content_editor/extensions/table_cell';
import TableHeader from '~/content_editor/extensions/table_header';
import TableRow from '~/content_editor/extensions/table_row';
+import TableOfContents from '~/content_editor/extensions/table_of_contents';
import TaskItem from '~/content_editor/extensions/task_item';
import TaskList from '~/content_editor/extensions/task_list';
+import Video from '~/content_editor/extensions/video';
import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
import { createTestEditor } from 'jest/content_editor/test_utils';
const tiptapEditor = createTestEditor({
extensions: [
+ Audio,
Blockquote,
Bold,
BulletList,
@@ -72,8 +76,10 @@ const tiptapEditor = createTestEditor({
TableCell,
TableHeader,
TableRow,
+ TableOfContents,
TaskItem,
TaskList,
+ Video,
],
});
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
index a3553e612ca..6175cbdd3d4 100644
--- a/spec/frontend/content_editor/services/content_editor_spec.js
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -1,8 +1,3 @@
-import {
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
-} from '~/content_editor/constants';
import { ContentEditor } from '~/content_editor/services/content_editor';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, createDocBuilder } from '../test_utils';
@@ -14,6 +9,7 @@ describe('content_editor/services/content_editor', () => {
let eventHub;
let doc;
let p;
+ const testMarkdown = '**bold text**';
beforeEach(() => {
const tiptapEditor = createTestEditor();
@@ -36,6 +32,9 @@ describe('content_editor/services/content_editor', () => {
});
});
+ const testDoc = () => doc(p('document'));
+ const testEmptyDoc = () => doc();
+
describe('.dispose', () => {
it('destroys the tiptapEditor', () => {
expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled();
@@ -46,51 +45,77 @@ describe('content_editor/services/content_editor', () => {
});
});
- describe('when setSerializedContent succeeds', () => {
- let document;
- const languages = ['javascript'];
- const testMarkdown = '**bold text**';
+ describe('empty', () => {
+ it('returns true when tiptapEditor is empty', async () => {
+ deserializer.deserialize.mockResolvedValueOnce({ document: testEmptyDoc() });
+
+ await contentEditor.setSerializedContent(testMarkdown);
- beforeEach(() => {
- document = doc(p('document'));
- deserializer.deserialize.mockResolvedValueOnce({ document, languages });
+ expect(contentEditor.empty).toBe(true);
});
- it('emits loadingContent and loadingSuccess event in the eventHub', () => {
- let loadingContentEmitted = false;
+ it('returns false when tiptapEditor is not empty', async () => {
+ deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
- eventHub.$on(LOADING_CONTENT_EVENT, () => {
- loadingContentEmitted = true;
- });
- eventHub.$on(LOADING_SUCCESS_EVENT, () => {
- expect(loadingContentEmitted).toBe(true);
- });
+ await contentEditor.setSerializedContent(testMarkdown);
- contentEditor.setSerializedContent(testMarkdown);
+ expect(contentEditor.empty).toBe(false);
});
+ });
- it('sets the deserialized document in the tiptap editor object', async () => {
- await contentEditor.setSerializedContent(testMarkdown);
+ describe('editable', () => {
+ it('returns true when tiptapEditor is editable', async () => {
+ contentEditor.setEditable(true);
- expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
+ expect(contentEditor.editable).toBe(true);
+ });
+
+ it('returns false when tiptapEditor is readonly', async () => {
+ contentEditor.setEditable(false);
+
+ expect(contentEditor.editable).toBe(false);
});
});
- describe('when setSerializedContent fails', () => {
- const error = 'error';
+ describe('changed', () => {
+ it('returns true when the initial document changes', async () => {
+ deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
+
+ await contentEditor.setSerializedContent(testMarkdown);
+
+ contentEditor.tiptapEditor.commands.insertContent(' new content');
+
+ expect(contentEditor.changed).toBe(true);
+ });
+
+ it('returns false when the initial document hasn’t changed', async () => {
+ deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
+
+ await contentEditor.setSerializedContent(testMarkdown);
+
+ expect(contentEditor.changed).toBe(false);
+ });
+
+ it('returns false when an initial document is not set and the document is empty', () => {
+ expect(contentEditor.changed).toBe(false);
+ });
- beforeEach(() => {
- deserializer.deserialize.mockRejectedValueOnce(error);
+ it('returns true when an initial document is not set and the document is not empty', () => {
+ contentEditor.tiptapEditor.commands.insertContent('new content');
+
+ expect(contentEditor.changed).toBe(true);
});
+ });
+
+ describe('when setSerializedContent succeeds', () => {
+ it('sets the deserialized document in the tiptap editor object', async () => {
+ const document = testDoc();
+
+ deserializer.deserialize.mockResolvedValueOnce({ document });
- it('emits loadingError event', async () => {
- eventHub.$on(LOADING_ERROR_EVENT, (e) => {
- expect(e).toBe('error');
- });
+ await contentEditor.setSerializedContent(testMarkdown);
- await expect(() => contentEditor.setSerializedContent('**bold text**')).rejects.toEqual(
- error,
- );
+ expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
});
});
});
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index 0e5281be9bf..56394c85e8b 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -1,3 +1,4 @@
+import Audio from '~/content_editor/extensions/audio';
import Blockquote from '~/content_editor/extensions/blockquote';
import Bold from '~/content_editor/extensions/bold';
import BulletList from '~/content_editor/extensions/bullet_list';
@@ -33,6 +34,7 @@ import TableHeader from '~/content_editor/extensions/table_header';
import TableRow from '~/content_editor/extensions/table_row';
import TaskItem from '~/content_editor/extensions/task_item';
import TaskList from '~/content_editor/extensions/task_list';
+import Video from '~/content_editor/extensions/video';
import markdownSerializer from '~/content_editor/services/markdown_serializer';
import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
import { createTestEditor, createDocBuilder } from '../test_utils';
@@ -41,6 +43,7 @@ jest.mock('~/emoji');
const tiptapEditor = createTestEditor({
extensions: [
+ Audio,
Blockquote,
Bold,
BulletList,
@@ -73,6 +76,7 @@ const tiptapEditor = createTestEditor({
TableRow,
TaskItem,
TaskList,
+ Video,
...HTMLMarks,
...HTMLNodes,
],
@@ -80,6 +84,7 @@ const tiptapEditor = createTestEditor({
const {
builders: {
+ audio,
doc,
blockquote,
bold,
@@ -114,6 +119,7 @@ const {
tableRow,
taskItem,
taskList,
+ video,
},
} = createDocBuilder({
tiptapEditor,
@@ -1230,6 +1236,21 @@ paragraph
);
});
+ it('serializes audio and video elements', () => {
+ expect(
+ serialize(
+ paragraph(
+ audio({ alt: 'audio', canonicalSrc: 'audio.mp3' }),
+ ' and ',
+ video({ alt: 'video', canonicalSrc: 'video.mov' }),
+ ),
+ ),
+ ).toBe(
+ `
+![audio](audio.mp3) and ![video](video.mov)`.trimLeft(),
+ );
+ });
+
const defaultEditAction = (initialContent) => {
tiptapEditor.chain().setContent(initialContent.toJSON()).insertContent(' modified').run();
};