summaryrefslogtreecommitdiff
path: root/spec/frontend/content_editor
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/content_editor')
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap2
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap17
-rw-r--r--spec/frontend/content_editor/components/content_editor_error_spec.js54
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js166
-rw-r--r--spec/frontend/content_editor/components/editor_state_observer_spec.js75
-rw-r--r--spec/frontend/content_editor/components/formatting_bubble_menu_spec.js80
-rw-r--r--spec/frontend/content_editor/components/toolbar_button_spec.js46
-rw-r--r--spec/frontend/content_editor/components/toolbar_image_button_spec.js22
-rw-r--r--spec/frontend/content_editor/components/toolbar_link_button_spec.js52
-rw-r--r--spec/frontend/content_editor/components/toolbar_table_button_spec.js34
-rw-r--r--spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js18
-rw-r--r--spec/frontend/content_editor/components/top_toolbar_spec.js35
-rw-r--r--spec/frontend/content_editor/extensions/attachment_spec.js235
-rw-r--r--spec/frontend/content_editor/extensions/code_block_highlight_spec.js3
-rw-r--r--spec/frontend/content_editor/extensions/emoji_spec.js57
-rw-r--r--spec/frontend/content_editor/extensions/hard_break_spec.js2
-rw-r--r--spec/frontend/content_editor/extensions/image_spec.js193
-rw-r--r--spec/frontend/content_editor/extensions/inline_diff_spec.js27
-rw-r--r--spec/frontend/content_editor/markdown_processing_spec.js2
-rw-r--r--spec/frontend/content_editor/services/build_serializer_config_spec.js38
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js68
-rw-r--r--spec/frontend/content_editor/services/create_content_editor_spec.js12
-rw-r--r--spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js8
-rw-r--r--spec/frontend/content_editor/services/upload_helpers_spec.js (renamed from spec/frontend/content_editor/services/upload_file_spec.js)4
-rw-r--r--spec/frontend/content_editor/test_utils.js7
25 files changed, 884 insertions, 373 deletions
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
index 35c02911e27..e508cddd6f9 100644
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
+++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`content_editor/components/toolbar_button displays tertiary, small button with a provided label and icon 1`] = `
-"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-mx-2 gl-button btn-default-tertiary btn-icon\\">
+"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-button btn-default-tertiary btn-icon\\">
<!---->
<gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub>
<!---->
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 e56c37b0dc9..3c88c05a4b4 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
@@ -26,8 +26,21 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen
</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>
diff --git a/spec/frontend/content_editor/components/content_editor_error_spec.js b/spec/frontend/content_editor/components/content_editor_error_spec.js
new file mode 100644
index 00000000000..8723fb5a338
--- /dev/null
+++ b/spec/frontend/content_editor/components/content_editor_error_spec.js
@@ -0,0 +1,54 @@
+import { GlAlert } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ContentEditorError from '~/content_editor/components/content_editor_error.vue';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
+import { createTestEditor, emitEditorEvent } from '../test_utils';
+
+describe('content_editor/components/content_editor_error', () => {
+ let wrapper;
+ let tiptapEditor;
+
+ const findErrorAlert = () => wrapper.findComponent(GlAlert);
+
+ const createWrapper = async () => {
+ tiptapEditor = createTestEditor();
+
+ wrapper = shallowMountExtended(ContentEditorError, {
+ provide: {
+ tiptapEditor,
+ },
+ stubs: {
+ EditorStateObserver,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders error when content editor emits an error event', async () => {
+ const error = 'error message';
+
+ createWrapper();
+
+ await emitEditorEvent({ tiptapEditor, event: 'error', params: { error } });
+
+ expect(findErrorAlert().text()).toBe(error);
+ });
+
+ it('allows dismissing the error', async () => {
+ const error = 'error message';
+
+ createWrapper();
+
+ await emitEditorEvent({ tiptapEditor, event: 'error', params: { error } });
+
+ findErrorAlert().vm.$emit('dismiss');
+
+ await nextTick();
+
+ 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 563e80e04c1..d516baf6f0f 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -1,91 +1,175 @@
-import { GlAlert } from '@gitlab/ui';
+import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent } 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 ContentEditorError from '~/content_editor/components/content_editor_error.vue';
+import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
-import { createContentEditor } from '~/content_editor/services/create_content_editor';
+import {
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+} from '~/content_editor/constants';
+import { emitEditorEvent } from '../test_utils';
+
+jest.mock('~/emoji');
describe('ContentEditor', () => {
let wrapper;
- let editor;
+ let contentEditor;
+ let renderMarkdown;
+ const uploadsPath = '/uploads';
const findEditorElement = () => wrapper.findByTestId('content-editor');
- const findErrorAlert = () => wrapper.findComponent(GlAlert);
+ const findEditorContent = () => wrapper.findComponent(EditorContent);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const createWrapper = (propsData = {}) => {
+ renderMarkdown = jest.fn();
- const createWrapper = async (contentEditor) => {
wrapper = shallowMountExtended(ContentEditor, {
propsData: {
- contentEditor,
+ renderMarkdown,
+ uploadsPath,
+ ...propsData,
+ },
+ stubs: {
+ EditorStateObserver,
+ ContentEditorProvider,
+ },
+ listeners: {
+ initialized(editor) {
+ contentEditor = editor;
+ },
},
});
};
- beforeEach(() => {
- editor = createContentEditor({ renderMarkdown: () => true });
- });
-
afterEach(() => {
wrapper.destroy();
});
- it('renders editor content component and attaches editor instance', () => {
- createWrapper(editor);
+ it('triggers initialized event and provides contentEditor instance as event data', () => {
+ createWrapper();
- const editorContent = wrapper.findComponent(EditorContent);
+ expect(contentEditor).not.toBeFalsy();
+ });
+
+ it('renders EditorContent component and provides tiptapEditor instance', () => {
+ createWrapper();
+
+ const editorContent = findEditorContent();
- expect(editorContent.props().editor).toBe(editor.tiptapEditor);
+ expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor);
expect(editorContent.classes()).toContain('md');
});
- it('renders top toolbar component and attaches editor instance', () => {
- createWrapper(editor);
+ it('renders ContentEditorProvider component', () => {
+ createWrapper();
- expect(wrapper.findComponent(TopToolbar).props().contentEditor).toBe(editor);
+ expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true);
});
- it.each`
- isFocused | classes
- ${true} | ${['md-area', 'is-focused']}
- ${false} | ${['md-area']}
- `(
- 'has $classes class selectors when tiptapEditor.isFocused = $isFocused',
- ({ isFocused, classes }) => {
- editor.tiptapEditor.isFocused = isFocused;
- createWrapper(editor);
+ it('renders top toolbar component', () => {
+ createWrapper();
+
+ expect(wrapper.findComponent(TopToolbar).exists()).toBe(true);
+ });
- expect(findEditorElement().classes()).toStrictEqual(classes);
- },
- );
+ it('adds is-focused class when focus event is emitted', async () => {
+ createWrapper();
- it('adds isFocused class when tiptapEditor is focused', () => {
- editor.tiptapEditor.isFocused = true;
- createWrapper(editor);
+ await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
expect(findEditorElement().classes()).toContain('is-focused');
});
- describe('displaying error', () => {
- const error = 'Content Editor error';
+ it('removes is-focused class when blur event is emitted', async () => {
+ createWrapper();
+
+ await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
+ await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' });
+
+ expect(findEditorElement().classes()).not.toContain('is-focused');
+ });
+
+ it('emits change event when document is updated', async () => {
+ createWrapper();
+
+ await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' });
+
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ empty: contentEditor.empty,
+ },
+ ],
+ ]);
+ });
+
+ it('renders content_editor_error component', () => {
+ createWrapper();
+
+ expect(wrapper.findComponent(ContentEditorError).exists()).toBe(true);
+ });
+ describe('when loading content', () => {
beforeEach(async () => {
- createWrapper(editor);
+ createWrapper();
- editor.tiptapEditor.emit('error', error);
+ contentEditor.emit(LOADING_CONTENT_EVENT);
await nextTick();
});
- it('displays error notifications from the tiptap editor', () => {
- expect(findErrorAlert().text()).toBe(error);
+ it('displays loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
});
- it('allows dismissing an error alert', async () => {
- findErrorAlert().vm.$emit('dismiss');
+ it('hides EditorContent component', () => {
+ expect(findEditorContent().exists()).toBe(false);
+ });
+ });
+
+ describe('when loading content succeeds', () => {
+ beforeEach(async () => {
+ createWrapper();
+
+ contentEditor.emit(LOADING_CONTENT_EVENT);
+ await nextTick();
+ contentEditor.emit(LOADING_SUCCESS_EVENT);
+ await nextTick();
+ });
+
+ it('hides loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ it('displays EditorContent component', () => {
+ expect(findEditorContent().exists()).toBe(true);
+ });
+ });
+
+ describe('when loading content fails', () => {
+ const error = 'error';
+
+ beforeEach(async () => {
+ createWrapper();
+
+ contentEditor.emit(LOADING_CONTENT_EVENT);
+ await nextTick();
+ contentEditor.emit(LOADING_ERROR_EVENT, error);
await nextTick();
+ });
+
+ it('hides loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
- expect(findErrorAlert().exists()).toBe(false);
+ it('displays EditorContent component', () => {
+ expect(findEditorContent().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
new file mode 100644
index 00000000000..5e4bb348e1f
--- /dev/null
+++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js
@@ -0,0 +1,75 @@
+import { shallowMount } from '@vue/test-utils';
+import { each } from 'lodash';
+import EditorStateObserver, {
+ tiptapToComponentMap,
+} from '~/content_editor/components/editor_state_observer.vue';
+import { createTestEditor } from '../test_utils';
+
+describe('content_editor/components/editor_state_observer', () => {
+ let tiptapEditor;
+ let wrapper;
+ let onDocUpdateListener;
+ let onSelectionUpdateListener;
+ let onTransactionListener;
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor();
+ jest.spyOn(tiptapEditor, 'on');
+ };
+
+ const buildWrapper = () => {
+ wrapper = shallowMount(EditorStateObserver, {
+ provide: { tiptapEditor },
+ listeners: {
+ docUpdate: onDocUpdateListener,
+ selectionUpdate: onSelectionUpdateListener,
+ transaction: onTransactionListener,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ onDocUpdateListener = jest.fn();
+ onSelectionUpdateListener = jest.fn();
+ onTransactionListener = jest.fn();
+ buildEditor();
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when editor content changes', () => {
+ it('emits update, selectionUpdate, and transaction events', () => {
+ const content = '<p>My paragraph</p>';
+
+ tiptapEditor.commands.insertContent(content);
+
+ expect(onDocUpdateListener).toHaveBeenCalledWith(
+ expect.objectContaining({ editor: tiptapEditor }),
+ );
+ expect(onSelectionUpdateListener).toHaveBeenCalledWith(
+ expect.objectContaining({ editor: tiptapEditor }),
+ );
+ expect(onSelectionUpdateListener).toHaveBeenCalledWith(
+ expect.objectContaining({ editor: tiptapEditor }),
+ );
+ });
+ });
+
+ describe('when component is destroyed', () => {
+ it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
+ jest.spyOn(tiptapEditor, 'off');
+
+ wrapper.destroy();
+
+ each(tiptapToComponentMap, (_, tiptapEvent) => {
+ expect(tiptapEditor.off).toHaveBeenCalledWith(
+ tiptapEvent,
+ tiptapEditor.on.mock.calls.find(([eventName]) => eventName === tiptapEvent)[1],
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
new file mode 100644
index 00000000000..e44a7fa4ddb
--- /dev/null
+++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
@@ -0,0 +1,80 @@
+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/formatting_bubble_menu.vue';
+
+import {
+ BUBBLE_MENU_TRACKING_ACTION,
+ CONTENT_EDITOR_TRACKING_LABEL,
+} from '~/content_editor/constants';
+import { createTestEditor } from '../test_utils';
+
+describe('content_editor/components/top_toolbar', () => {
+ let wrapper;
+ let trackingSpy;
+ let tiptapEditor;
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor();
+
+ jest.spyOn(tiptapEditor, 'isActive');
+ };
+
+ const buildWrapper = () => {
+ wrapper = shallowMountExtended(FormattingBubbleMenu, {
+ provide: {
+ tiptapEditor,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, null, jest.spyOn);
+ buildEditor();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders bubble menu component', () => {
+ buildWrapper();
+ const bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ expect(bubbleMenu.props().editor).toBe(tiptapEditor);
+ expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']);
+ });
+
+ describe.each`
+ testId | controlProps
+ ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'primary' }}
+ ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'primary' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'primary' }}
+ ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'primary' }}
+ `('given a $testId toolbar control', ({ testId, controlProps }) => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders the toolbar control with the provided properties', () => {
+ expect(wrapper.findByTestId(testId).exists()).toBe(true);
+
+ Object.keys(controlProps).forEach((propName) => {
+ expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]);
+ });
+ });
+
+ it('tracks the execution of toolbar controls', () => {
+ const eventData = { contentType: 'italic', value: 1 };
+ const { contentType, value } = eventData;
+
+ wrapper.findByTestId(testId).vm.$emit('execute', eventData);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, BUBBLE_MENU_TRACKING_ACTION, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property: contentType,
+ value,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js
index d848adcbff8..60263c46bdd 100644
--- a/spec/frontend/content_editor/components/toolbar_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_button_spec.js
@@ -1,7 +1,8 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
-import { createTestEditor, mockChainedCommands } from '../test_utils';
+import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_button', () => {
let wrapper;
@@ -20,9 +21,12 @@ describe('content_editor/components/toolbar_button', () => {
wrapper = shallowMount(ToolbarButton, {
stubs: {
GlButton,
+ EditorStateObserver,
},
- propsData: {
+ provide: {
tiptapEditor,
+ },
+ propsData: {
contentType: CONTENT_TYPE,
iconName: ICON_NAME,
label: LABEL,
@@ -46,19 +50,43 @@ describe('content_editor/components/toolbar_button', () => {
expect(findButton().html()).toMatchSnapshot();
});
+ it('allows customizing the variant, category, size of the button', () => {
+ const variant = 'danger';
+ const category = 'secondary';
+ const size = 'medium';
+
+ buildWrapper({
+ variant,
+ category,
+ size,
+ });
+
+ expect(findButton().props()).toMatchObject({
+ variant,
+ category,
+ size,
+ });
+ });
+
it.each`
editorState | outcomeDescription | outcome
${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true}
${{ isActive: false, isFocused: true }} | ${'button is not active'} | ${false}
${{ isActive: true, isFocused: false }} | ${'button is not active '} | ${false}
- `('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => {
- tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive);
- tiptapEditor.isFocused = editorState.isFocused;
- buildWrapper();
+ `(
+ '$outcomeDescription when when editor state is $editorState',
+ async ({ editorState, outcome }) => {
+ tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive);
+ tiptapEditor.isFocused = editorState.isFocused;
- expect(findButton().classes().includes('active')).toBe(outcome);
- expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
- });
+ buildWrapper();
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(findButton().classes().includes('active')).toBe(outcome);
+ expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
+ },
+ );
describe('when button is clicked', () => {
it('executes the content type command when executeCommand = true', async () => {
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 701dcf83476..dab7e67d7c5 100644
--- a/spec/frontend/content_editor/components/toolbar_image_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_image_button_spec.js
@@ -1,7 +1,8 @@
import { GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue';
-import { configure as configureImageExtension } from '~/content_editor/extensions/image';
+import Attachment from '~/content_editor/extensions/attachment';
+import Image from '~/content_editor/extensions/image';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_image_button', () => {
@@ -10,7 +11,7 @@ describe('content_editor/components/toolbar_image_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarImageButton, {
- propsData: {
+ provide: {
tiptapEditor: editor,
},
});
@@ -29,13 +30,14 @@ describe('content_editor/components/toolbar_image_button', () => {
};
beforeEach(() => {
- const { tiptapExtension: Image } = configureImageExtension({
- renderMarkdown: jest.fn(),
- uploadsPath: '/uploads/',
- });
-
editor = createTestEditor({
- extensions: [Image],
+ extensions: [
+ Image,
+ Attachment.configure({
+ renderMarkdown: jest.fn(),
+ uploadsPath: '/uploads/',
+ }),
+ ],
});
buildWrapper();
@@ -64,13 +66,13 @@ describe('content_editor/components/toolbar_image_button', () => {
});
it('uploads the selected image when file input changes', async () => {
- const commands = mockChainedCommands(editor, ['focus', 'uploadImage', 'run']);
+ const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
const file = new File(['foo'], 'foo.png', { type: 'image/png' });
await selectFile(file);
expect(commands.focus).toHaveBeenCalled();
- expect(commands.uploadImage).toHaveBeenCalledWith({ file });
+ expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]);
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 576a2912f72..0cf488260bd 100644
--- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js
@@ -1,9 +1,9 @@
-import { GlDropdown, GlDropdownDivider, GlButton, GlFormInputGroup } from '@gitlab/ui';
+import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
-import { tiptapExtension as Link } from '~/content_editor/extensions/link';
+import Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
-import { createTestEditor, mockChainedCommands } from '../test_utils';
+import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
jest.mock('~/content_editor/services/utils');
@@ -13,21 +13,26 @@ describe('content_editor/components/toolbar_link_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarLinkButton, {
- propsData: {
+ provide: {
tiptapEditor: editor,
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
const findApplyLinkButton = () => wrapper.findComponent(GlButton);
const findRemoveLinkButton = () => wrapper.findByText('Remove link');
+ const selectFile = async (file) => {
+ const input = wrapper.find({ ref: 'fileSelector' });
+
+ // override the property definition because `input.files` isn't directly modifyable
+ Object.defineProperty(input.element, 'files', { value: [file], writable: true });
+ await input.trigger('change');
+ };
+
beforeEach(() => {
- editor = createTestEditor({
- extensions: [Link],
- });
+ editor = createTestEditor();
});
afterEach(() => {
@@ -45,14 +50,19 @@ describe('content_editor/components/toolbar_link_button', () => {
beforeEach(async () => {
jest.spyOn(editor, 'isActive').mockReturnValueOnce(true);
buildWrapper();
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
});
it('sets dropdown as active when link extension is active', () => {
expect(findDropdown().props('toggleClass')).toEqual({ active: true });
});
+ it('does not display the upload file option', () => {
+ expect(wrapper.findByText('Upload file').exists()).toBe(false);
+ });
+
it('displays a remove link dropdown option', () => {
- expect(findDropdownDivider().exists()).toBe(true);
expect(wrapper.findByText('Remove link').exists()).toBe(true);
});
@@ -90,7 +100,7 @@ describe('content_editor/components/toolbar_link_button', () => {
href: '/username/my-project/uploads/abcdefgh133535/my-file.zip',
});
- await editor.emit('selectionUpdate', { editor });
+ await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip');
});
@@ -100,14 +110,14 @@ describe('content_editor/components/toolbar_link_button', () => {
href: 'https://gitlab.com',
});
- await editor.emit('selectionUpdate', { editor });
+ await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
expect(findLinkURLInput().element.value).toEqual('https://gitlab.com');
});
});
});
- describe('when there is not an active link', () => {
+ describe('when there is no active link', () => {
beforeEach(() => {
jest.spyOn(editor, 'isActive');
editor.isActive.mockReturnValueOnce(false);
@@ -118,8 +128,11 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(findDropdown().props('toggleClass')).toEqual({ active: false });
});
+ it('displays the upload file option', () => {
+ expect(wrapper.findByText('Upload file').exists()).toBe(true);
+ });
+
it('does not display a remove link dropdown option', () => {
- expect(findDropdownDivider().exists()).toBe(false);
expect(wrapper.findByText('Remove link').exists()).toBe(false);
});
@@ -138,6 +151,19 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
});
+
+ it('uploads the selected image when file input changes', async () => {
+ const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
+ const file = new File(['foo'], 'foo.png', { type: 'image/png' });
+
+ await selectFile(file);
+
+ expect(commands.focus).toHaveBeenCalled();
+ expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
+ expect(commands.run).toHaveBeenCalled();
+
+ expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
+ });
});
describe('when the user displays the dropdown', () => {
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 237b2848246..056e5e04e1f 100644
--- a/spec/frontend/content_editor/components/toolbar_table_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
@@ -1,10 +1,6 @@
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 { tiptapExtension as Table } from '~/content_editor/extensions/table';
-import { tiptapExtension as TableCell } from '~/content_editor/extensions/table_cell';
-import { tiptapExtension as TableHeader } from '~/content_editor/extensions/table_header';
-import { tiptapExtension as TableRow } from '~/content_editor/extensions/table_row';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_table_button', () => {
@@ -13,7 +9,7 @@ describe('content_editor/components/toolbar_table_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarTableButton, {
- propsData: {
+ provide: {
tiptapEditor: editor,
},
});
@@ -23,9 +19,7 @@ describe('content_editor/components/toolbar_table_button', () => {
const getNumButtons = () => findDropdown().findAllComponents(GlButton).length;
beforeEach(() => {
- editor = createTestEditor({
- extensions: [Table, TableCell, TableRow, TableHeader],
- });
+ editor = createTestEditor();
buildWrapper();
});
@@ -35,17 +29,17 @@ describe('content_editor/components/toolbar_table_button', () => {
wrapper.destroy();
});
- it('renders a grid of 3x3 buttons to create a table', () => {
- expect(getNumButtons()).toBe(9); // 3 x 3
+ it('renders a grid of 5x5 buttons to create a table', () => {
+ expect(getNumButtons()).toBe(25); // 5x5
});
describe.each`
row | col | numButtons | tableSize
- ${1} | ${2} | ${9} | ${'1x2'}
- ${2} | ${2} | ${9} | ${'2x2'}
- ${2} | ${3} | ${12} | ${'2x3'}
- ${3} | ${2} | ${12} | ${'3x2'}
- ${3} | ${3} | ${16} | ${'3x3'}
+ ${3} | ${4} | ${25} | ${'3x4'}
+ ${4} | ${4} | ${25} | ${'4x4'}
+ ${4} | ${5} | ${30} | ${'4x5'}
+ ${5} | ${4} | ${30} | ${'5x4'}
+ ${5} | ${5} | ${36} | ${'5x5'}
`('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => {
describe('on mouse over', () => {
beforeEach(async () => {
@@ -56,9 +50,7 @@ describe('content_editor/components/toolbar_table_button', () => {
it('marks all rows and cols before it as active', () => {
const prevRow = Math.max(1, row - 1);
const prevCol = Math.max(1, col - 1);
- expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass(
- 'gl-bg-blue-50!',
- );
+ expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass('active');
});
it('shows a help text indicating the size of the table being inserted', () => {
@@ -95,8 +87,8 @@ describe('content_editor/components/toolbar_table_button', () => {
});
});
- it('does not create more buttons than a 8x8 grid', async () => {
- for (let i = 3; i < 8; i += 1) {
+ it('does not create more buttons than a 10x10 grid', async () => {
+ for (let i = 5; i < 10; i += 1) {
expect(getNumButtons()).toBe(i * i);
// eslint-disable-next-line no-await-in-loop
@@ -104,6 +96,6 @@ describe('content_editor/components/toolbar_table_button', () => {
expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`);
}
- expect(getNumButtons()).toBe(64); // 8x8 (and not 9x9)
+ expect(getNumButtons()).toBe(100); // 10x10 (and not 11x11)
});
});
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 9a46e27404f..65c1c8c8310 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
@@ -1,11 +1,12 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
-import { tiptapExtension as Heading } from '~/content_editor/extensions/heading';
-import { createTestEditor, mockChainedCommands } from '../test_utils';
+import Heading from '~/content_editor/extensions/heading';
+import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
-describe('content_editor/components/toolbar_headings_dropdown', () => {
+describe('content_editor/components/toolbar_text_style_dropdown', () => {
let wrapper;
let tiptapEditor;
@@ -22,9 +23,12 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
stubs: {
GlDropdown,
GlDropdownItem,
+ EditorStateObserver,
},
- propsData: {
+ provide: {
tiptapEditor,
+ },
+ propsData: {
...propsData,
},
});
@@ -50,7 +54,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
describe('when there is an active item ', () => {
let activeTextStyle;
- beforeEach(() => {
+ beforeEach(async () => {
[, activeTextStyle] = TEXT_STYLE_DROPDOWN_ITEMS;
tiptapEditor.isActive.mockImplementation(
@@ -59,6 +63,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
);
buildWrapper();
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
it('displays the active text style label as the dropdown toggle text ', () => {
@@ -79,9 +84,10 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
});
describe('when there isn’t an active item', () => {
- beforeEach(() => {
+ beforeEach(async () => {
tiptapEditor.isActive.mockReturnValue(false);
buildWrapper();
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
it('sets dropdown as disabled', () => {
diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js
index 5411793cd5e..a5df3d73289 100644
--- a/spec/frontend/content_editor/components/top_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/top_toolbar_spec.js
@@ -1,39 +1,23 @@
-import { shallowMount } from '@vue/test-utils';
import { mockTracking } from 'helpers/tracking_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import {
TOOLBAR_CONTROL_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
-import { createContentEditor } from '~/content_editor/services/create_content_editor';
describe('content_editor/components/top_toolbar', () => {
let wrapper;
- let contentEditor;
let trackingSpy;
- const buildEditor = () => {
- contentEditor = createContentEditor({ renderMarkdown: () => true });
- };
const buildWrapper = () => {
- wrapper = extendedWrapper(
- shallowMount(TopToolbar, {
- propsData: {
- contentEditor,
- },
- }),
- );
+ wrapper = shallowMountExtended(TopToolbar);
};
beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
});
- beforeEach(() => {
- buildEditor();
- });
-
afterEach(() => {
wrapper.destroy();
});
@@ -58,18 +42,17 @@ describe('content_editor/components/top_toolbar', () => {
});
it('renders the toolbar control with the provided properties', () => {
- expect(wrapper.findByTestId(testId).props()).toEqual({
- ...controlProps,
- tiptapEditor: contentEditor.tiptapEditor,
+ expect(wrapper.findByTestId(testId).exists()).toBe(true);
+
+ Object.keys(controlProps).forEach((propName) => {
+ expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]);
});
});
- it.each`
- eventData
- ${{ contentType: 'bold' }}
- ${{ contentType: 'blockquote', value: 1 }}
- `('tracks the execution of toolbar controls', ({ eventData }) => {
+ it('tracks the execution of toolbar controls', () => {
+ const eventData = { contentType: 'blockquote', value: 1 };
const { contentType, value } = eventData;
+
wrapper.findByTestId(testId).vm.$emit('execute', eventData);
expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, {
diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js
new file mode 100644
index 00000000000..1334b1ddaad
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/attachment_spec.js
@@ -0,0 +1,235 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { once } from 'lodash';
+import waitForPromises from 'helpers/wait_for_promises';
+import Attachment from '~/content_editor/extensions/attachment';
+import Image from '~/content_editor/extensions/image';
+import Link from '~/content_editor/extensions/link';
+import Loading from '~/content_editor/extensions/loading';
+import httpStatus from '~/lib/utils/http_status';
+import { loadMarkdownApiResult } from '../markdown_processing_examples';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+describe('content_editor/extensions/attachment', () => {
+ let tiptapEditor;
+ let eq;
+ let doc;
+ let p;
+ let image;
+ let loading;
+ let link;
+ let renderMarkdown;
+ let mock;
+
+ const uploadsPath = '/uploads/';
+ const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
+ const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' });
+
+ beforeEach(() => {
+ renderMarkdown = jest.fn();
+
+ tiptapEditor = createTestEditor({
+ extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })],
+ });
+
+ ({
+ builders: { doc, p, image, loading, link },
+ eq,
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ loading: { markType: Loading.name },
+ image: { nodeType: Image.name },
+ link: { nodeType: Link.name },
+ },
+ }));
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.reset();
+ });
+
+ it.each`
+ eventType | propName | eventData | output
+ ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [attachmentFile] } }} | ${true}
+ ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [] } }} | ${undefined}
+ ${'drop'} | ${'handleDrop'} | ${{ dataTransfer: { files: [attachmentFile] } }} | ${true}
+ `('handles $eventType properly', ({ eventType, propName, eventData, output }) => {
+ const event = Object.assign(new Event(eventType), eventData);
+ const handled = tiptapEditor.view.someProp(propName, (eventHandler) => {
+ return eventHandler(tiptapEditor.view, event);
+ });
+
+ expect(handled).toBe(output);
+ });
+
+ describe('uploadAttachment command', () => {
+ let initialDoc;
+ beforeEach(() => {
+ initialDoc = doc(p(''));
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ });
+
+ describe('when the file has image mime type', () => {
+ const base64EncodedFile = 'data:image/png;base64,Zm9v';
+
+ beforeEach(() => {
+ renderMarkdown.mockResolvedValue(
+ loadMarkdownApiResult('project_wiki_attachment_image').body,
+ );
+ });
+
+ describe('when uploading succeeds', () => {
+ const successResponse = {
+ link: {
+ markdown: '![test-file](test-file.png)',
+ },
+ };
+
+ beforeEach(() => {
+ mock.onPost().reply(httpStatus.OK, successResponse);
+ });
+
+ it('inserts an image with src set to the encoded image file and uploading true', (done) => {
+ const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile })));
+
+ tiptapEditor.on(
+ 'update',
+ once(() => {
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ done();
+ }),
+ );
+
+ tiptapEditor.commands.uploadAttachment({ file: imageFile });
+ });
+
+ it('updates the inserted image with canonicalSrc when upload is successful', async () => {
+ const expectedDoc = doc(
+ p(
+ image({
+ canonicalSrc: 'test-file.png',
+ src: base64EncodedFile,
+ alt: 'test-file',
+ uploading: false,
+ }),
+ ),
+ );
+
+ tiptapEditor.commands.uploadAttachment({ file: imageFile });
+
+ await waitForPromises();
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+ });
+
+ describe('when uploading request fails', () => {
+ beforeEach(() => {
+ mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
+ });
+
+ it('resets the doc to orginal state', async () => {
+ const expectedDoc = doc(p(''));
+
+ tiptapEditor.commands.uploadAttachment({ file: imageFile });
+
+ await waitForPromises();
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+
+ it('emits an error event that includes an error message', (done) => {
+ tiptapEditor.commands.uploadAttachment({ file: imageFile });
+
+ tiptapEditor.on('error', ({ error }) => {
+ expect(error).toBe('An error occurred while uploading the image. Please try again.');
+ done();
+ });
+ });
+ });
+ });
+
+ describe('when the file has a zip (or any other attachment) mime type', () => {
+ const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body;
+
+ beforeEach(() => {
+ renderMarkdown.mockResolvedValue(markdownApiResult);
+ });
+
+ describe('when uploading succeeds', () => {
+ const successResponse = {
+ link: {
+ markdown: '[test-file](test-file.zip)',
+ },
+ };
+
+ beforeEach(() => {
+ mock.onPost().reply(httpStatus.OK, successResponse);
+ });
+
+ it('inserts a loading mark', (done) => {
+ const expectedDoc = doc(p(loading({ label: 'test-file' })));
+
+ tiptapEditor.on(
+ 'update',
+ once(() => {
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ done();
+ }),
+ );
+
+ tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+ });
+
+ it('updates the loading mark with a link with canonicalSrc and href attrs', async () => {
+ const [, group, project] = markdownApiResult.match(/\/(group[0-9]+)\/(project[0-9]+)\//);
+ const expectedDoc = doc(
+ p(
+ link(
+ {
+ canonicalSrc: 'test-file.zip',
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ },
+ 'test-file',
+ ),
+ ),
+ );
+
+ tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+
+ await waitForPromises();
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+ });
+
+ describe('when uploading request fails', () => {
+ beforeEach(() => {
+ mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
+ });
+
+ it('resets the doc to orginal state', async () => {
+ const expectedDoc = doc(p(''));
+
+ tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+
+ await waitForPromises();
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+
+ it('emits an error event that includes an error message', (done) => {
+ tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+
+ tiptapEditor.on('error', ({ error }) => {
+ expect(error).toBe('An error occurred while uploading the file. Please try again.');
+ done();
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
index cc695ffe241..188e6580dc6 100644
--- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
+++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
@@ -1,4 +1,4 @@
-import { tiptapExtension as CodeBlockHighlight } from '~/content_editor/extensions/code_block_highlight';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import { loadMarkdownApiResult } from '../markdown_processing_examples';
import { createTestEditor } from '../test_utils';
@@ -25,7 +25,6 @@ describe('content_editor/extensions/code_block_highlight', () => {
expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
language,
- params: language,
});
});
diff --git a/spec/frontend/content_editor/extensions/emoji_spec.js b/spec/frontend/content_editor/extensions/emoji_spec.js
new file mode 100644
index 00000000000..c1b8dc9bdbb
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/emoji_spec.js
@@ -0,0 +1,57 @@
+import { initEmojiMock } from 'helpers/emoji';
+import Emoji from '~/content_editor/extensions/emoji';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+describe('content_editor/extensions/emoji', () => {
+ let tiptapEditor;
+ let doc;
+ let p;
+ let emoji;
+ let eq;
+
+ beforeEach(async () => {
+ await initEmojiMock();
+ });
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({ extensions: [Emoji] });
+ ({
+ builders: { doc, p, emoji },
+ eq,
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ loading: { nodeType: Emoji.name },
+ },
+ }));
+ });
+
+ describe('when typing a valid emoji input rule', () => {
+ it('inserts an emoji node', () => {
+ const { view } = tiptapEditor;
+ const { selection } = view.state;
+ const expectedDoc = doc(
+ p(
+ ' ',
+ emoji({ moji: '❤', name: 'heart', title: 'heavy black heart', unicodeVersion: '1.1' }),
+ ),
+ );
+ // Triggers the event handler that input rules listen to
+ view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, ':heart:'));
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+ });
+
+ describe('when typing a invalid emoji input rule', () => {
+ it('does not insert an emoji node', () => {
+ const { view } = tiptapEditor;
+ const { selection } = view.state;
+ const invalidEmoji = ':invalid:';
+ const expectedDoc = doc(p());
+ // Triggers the event handler that input rules listen to
+ view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, invalidEmoji));
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/hard_break_spec.js b/spec/frontend/content_editor/extensions/hard_break_spec.js
index ebd58e60b0c..9e2e28b6e72 100644
--- a/spec/frontend/content_editor/extensions/hard_break_spec.js
+++ b/spec/frontend/content_editor/extensions/hard_break_spec.js
@@ -1,4 +1,4 @@
-import { tiptapExtension as HardBreak } from '~/content_editor/extensions/hard_break';
+import HardBreak from '~/content_editor/extensions/hard_break';
import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/extensions/hard_break', () => {
diff --git a/spec/frontend/content_editor/extensions/image_spec.js b/spec/frontend/content_editor/extensions/image_spec.js
deleted file mode 100644
index 922966b813a..00000000000
--- a/spec/frontend/content_editor/extensions/image_spec.js
+++ /dev/null
@@ -1,193 +0,0 @@
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
-import { once } from 'lodash';
-import waitForPromises from 'helpers/wait_for_promises';
-import * as Image from '~/content_editor/extensions/image';
-import httpStatus from '~/lib/utils/http_status';
-import { loadMarkdownApiResult } from '../markdown_processing_examples';
-import { createTestEditor, createDocBuilder } from '../test_utils';
-
-describe('content_editor/extensions/image', () => {
- let tiptapEditor;
- let eq;
- let doc;
- let p;
- let image;
- let renderMarkdown;
- let mock;
- const uploadsPath = '/uploads/';
- const validFile = new File(['foo'], 'foo.png', { type: 'image/png' });
- const invalidFile = new File(['foo'], 'bar.html', { type: 'text/html' });
-
- beforeEach(() => {
- renderMarkdown = jest
- .fn()
- .mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image').body);
-
- const { tiptapExtension } = Image.configure({ renderMarkdown, uploadsPath });
-
- tiptapEditor = createTestEditor({ extensions: [tiptapExtension] });
-
- ({
- builders: { doc, p, image },
- eq,
- } = createDocBuilder({
- tiptapEditor,
- names: { image: { nodeType: tiptapExtension.name } },
- }));
-
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.reset();
- });
-
- it.each`
- file | valid | description
- ${validFile} | ${true} | ${'handles paste event when mime type is valid'}
- ${invalidFile} | ${false} | ${'does not handle paste event when mime type is invalid'}
- `('$description', ({ file, valid }) => {
- const pasteEvent = Object.assign(new Event('paste'), {
- clipboardData: {
- files: [file],
- },
- });
- let handled;
-
- tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
- handled = eventHandler(tiptapEditor.view, pasteEvent);
- });
-
- expect(handled).toBe(valid);
- });
-
- it.each`
- file | valid | description
- ${validFile} | ${true} | ${'handles drop event when mime type is valid'}
- ${invalidFile} | ${false} | ${'does not handle drop event when mime type is invalid'}
- `('$description', ({ file, valid }) => {
- const dropEvent = Object.assign(new Event('drop'), {
- dataTransfer: {
- files: [file],
- },
- });
- let handled;
-
- tiptapEditor.view.someProp('handleDrop', (eventHandler) => {
- handled = eventHandler(tiptapEditor.view, dropEvent);
- });
-
- expect(handled).toBe(valid);
- });
-
- it('handles paste event when mime type is correct', () => {
- const pasteEvent = Object.assign(new Event('paste'), {
- clipboardData: {
- files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
- },
- });
- const handled = tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
- return eventHandler(tiptapEditor.view, pasteEvent);
- });
-
- expect(handled).toBe(true);
- });
-
- describe('uploadImage command', () => {
- describe('when file has correct mime type', () => {
- let initialDoc;
- const base64EncodedFile = 'data:image/png;base64,Zm9v';
-
- beforeEach(() => {
- initialDoc = doc(p(''));
- tiptapEditor.commands.setContent(initialDoc.toJSON());
- });
-
- describe('when uploading image succeeds', () => {
- const successResponse = {
- link: {
- markdown: '[image](/uploads/25265/image.png)',
- },
- };
-
- beforeEach(() => {
- mock.onPost().reply(httpStatus.OK, successResponse);
- });
-
- it('inserts an image with src set to the encoded image file and uploading true', (done) => {
- const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile })));
-
- tiptapEditor.on(
- 'update',
- once(() => {
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
- done();
- }),
- );
-
- tiptapEditor.commands.uploadImage({ file: validFile });
- });
-
- it('updates the inserted image with canonicalSrc when upload is successful', async () => {
- const expectedDoc = doc(
- p(
- image({
- canonicalSrc: 'test-file.png',
- src: base64EncodedFile,
- alt: 'test file',
- uploading: false,
- }),
- ),
- );
-
- tiptapEditor.commands.uploadImage({ file: validFile });
-
- await waitForPromises();
-
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
- });
- });
-
- describe('when uploading image request fails', () => {
- beforeEach(() => {
- mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
- });
-
- it('resets the doc to orginal state', async () => {
- const expectedDoc = doc(p(''));
-
- tiptapEditor.commands.uploadImage({ file: validFile });
-
- await waitForPromises();
-
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
- });
-
- it('emits an error event that includes an error message', (done) => {
- tiptapEditor.commands.uploadImage({ file: validFile });
-
- tiptapEditor.on('error', (message) => {
- expect(message).toBe('An error occurred while uploading the image. Please try again.');
- done();
- });
- });
- });
- });
-
- describe('when file does not have correct mime type', () => {
- let initialDoc;
-
- beforeEach(() => {
- initialDoc = doc(p(''));
- tiptapEditor.commands.setContent(initialDoc.toJSON());
- });
-
- it('does not start the upload image process', () => {
- tiptapEditor.commands.uploadImage({ file: invalidFile });
-
- expect(eq(tiptapEditor.state.doc, initialDoc)).toBe(true);
- });
- });
- });
-});
diff --git a/spec/frontend/content_editor/extensions/inline_diff_spec.js b/spec/frontend/content_editor/extensions/inline_diff_spec.js
new file mode 100644
index 00000000000..63cdf665e7f
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/inline_diff_spec.js
@@ -0,0 +1,27 @@
+import { inputRegexAddition, inputRegexDeletion } from '~/content_editor/extensions/inline_diff';
+
+describe('content_editor/extensions/inline_diff', () => {
+ describe.each`
+ inputRegex | description | input | matches
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+world+}'} | ${true}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+ world +}'} | ${true}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello {+ world+}'} | ${true}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello world +}'} | ${true}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello with \nnewline+}'} | ${false}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+open only'} | ${false}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'close only+}'} | ${false}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{-world-}'} | ${true}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{- world -}'} | ${true}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello {- world-}'} | ${true}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-hello world -}'} | ${true}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{+hello with \nnewline+}'} | ${false}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-open only'} | ${false}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'close only-}'} | ${false}
+ `('$description', ({ inputRegex, input, matches }) => {
+ it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => {
+ const match = new RegExp(inputRegex).test(input);
+
+ expect(match).toBe(matches);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js
index 028cd6a8271..da3f6e64db8 100644
--- a/spec/frontend/content_editor/markdown_processing_spec.js
+++ b/spec/frontend/content_editor/markdown_processing_spec.js
@@ -1,6 +1,8 @@
import { createContentEditor } from '~/content_editor';
import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples';
+jest.mock('~/emoji');
+
describe('markdown processing', () => {
// Ensure we generate same markdown that was provided to Markdown API.
it.each(loadMarkdownApiExamples())(
diff --git a/spec/frontend/content_editor/services/build_serializer_config_spec.js b/spec/frontend/content_editor/services/build_serializer_config_spec.js
deleted file mode 100644
index 532e0493830..00000000000
--- a/spec/frontend/content_editor/services/build_serializer_config_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as Blockquote from '~/content_editor/extensions/blockquote';
-import * as Bold from '~/content_editor/extensions/bold';
-import * as Dropcursor from '~/content_editor/extensions/dropcursor';
-import * as Paragraph from '~/content_editor/extensions/paragraph';
-
-import buildSerializerConfig from '~/content_editor/services/build_serializer_config';
-
-describe('content_editor/services/build_serializer_config', () => {
- describe('given one or more content editor extensions', () => {
- it('creates a serializer config that collects all extension serializers by type', () => {
- const extensions = [Bold, Blockquote, Paragraph];
- const serializerConfig = buildSerializerConfig(extensions);
-
- extensions.forEach(({ tiptapExtension, serializer }) => {
- const { name, type } = tiptapExtension;
- expect(serializerConfig[`${type}s`][name]).toBe(serializer);
- });
- });
- });
-
- describe('given an extension without serializer', () => {
- it('does not include the extension in the serializer config', () => {
- const serializerConfig = buildSerializerConfig([Dropcursor]);
-
- expect(serializerConfig.marks[Dropcursor.tiptapExtension.name]).toBe(undefined);
- expect(serializerConfig.nodes[Dropcursor.tiptapExtension.name]).toBe(undefined);
- });
- });
-
- describe('given no extensions', () => {
- it('creates an empty serializer config', () => {
- expect(buildSerializerConfig()).toStrictEqual({
- marks: {},
- nodes: {},
- });
- });
- });
-});
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
new file mode 100644
index 00000000000..e48687f1548
--- /dev/null
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -0,0 +1,68 @@
+import {
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+} from '~/content_editor/constants';
+import { ContentEditor } from '~/content_editor/services/content_editor';
+
+import { createTestEditor } from '../test_utils';
+
+describe('content_editor/services/content_editor', () => {
+ let contentEditor;
+ let serializer;
+
+ beforeEach(() => {
+ const tiptapEditor = createTestEditor();
+ jest.spyOn(tiptapEditor, 'destroy');
+
+ serializer = { deserialize: jest.fn() };
+ contentEditor = new ContentEditor({ tiptapEditor, serializer });
+ });
+
+ describe('.dispose', () => {
+ it('destroys the tiptapEditor', () => {
+ expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled();
+
+ contentEditor.dispose();
+
+ expect(contentEditor.tiptapEditor.destroy).toHaveBeenCalled();
+ });
+ });
+
+ describe('when setSerializedContent succeeds', () => {
+ beforeEach(() => {
+ serializer.deserialize.mockResolvedValueOnce('');
+ });
+
+ it('emits loadingContent and loadingSuccess event', () => {
+ let loadingContentEmitted = false;
+
+ contentEditor.on(LOADING_CONTENT_EVENT, () => {
+ loadingContentEmitted = true;
+ });
+ contentEditor.on(LOADING_SUCCESS_EVENT, () => {
+ expect(loadingContentEmitted).toBe(true);
+ });
+
+ contentEditor.setSerializedContent('**bold text**');
+ });
+ });
+
+ describe('when setSerializedContent fails', () => {
+ const error = 'error';
+
+ beforeEach(() => {
+ serializer.deserialize.mockRejectedValueOnce(error);
+ });
+
+ it('emits loadingError event', async () => {
+ contentEditor.on(LOADING_ERROR_EVENT, (e) => {
+ expect(e).toBe('error');
+ });
+
+ await expect(() => contentEditor.setSerializedContent('**bold text**')).rejects.toEqual(
+ error,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js
index b614efd954a..6b2f28b3306 100644
--- a/spec/frontend/content_editor/services/create_content_editor_spec.js
+++ b/spec/frontend/content_editor/services/create_content_editor_spec.js
@@ -2,7 +2,9 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants
import { createContentEditor } from '~/content_editor/services/create_content_editor';
import { createTestContentEditorExtension } from '../test_utils';
-describe('content_editor/services/create_editor', () => {
+jest.mock('~/emoji');
+
+describe('content_editor/services/create_content_editor', () => {
let renderMarkdown;
let editor;
const uploadsPath = '/uploads';
@@ -32,13 +34,15 @@ describe('content_editor/services/create_editor', () => {
it('allows providing external content editor extensions', async () => {
const labelReference = 'this is a ~group::editor';
+ const { tiptapExtension, serializer } = createTestContentEditorExtension();
renderMarkdown.mockReturnValueOnce(
'<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>',
);
editor = createContentEditor({
renderMarkdown,
- extensions: [createTestContentEditorExtension()],
+ extensions: [tiptapExtension],
+ serializerConfig: { nodes: { [tiptapExtension.name]: serializer } },
});
await editor.setSerializedContent(labelReference);
@@ -50,9 +54,9 @@ describe('content_editor/services/create_editor', () => {
expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
});
- it('provides uploadsPath and renderMarkdown function to Image extension', () => {
+ it('provides uploadsPath and renderMarkdown function to Attachment extension', () => {
expect(
- editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'image').options,
+ editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'attachment').options,
).toMatchObject({
uploadsPath,
renderMarkdown,
diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
index 64f3d8df6e0..afe09a75f16 100644
--- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
+++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
@@ -4,10 +4,10 @@ import {
INPUT_RULE_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
-import { tiptapExtension as BulletList } from '~/content_editor/extensions/bullet_list';
-import { tiptapExtension as CodeBlockLowlight } from '~/content_editor/extensions/code_block_highlight';
-import { tiptapExtension as Heading } from '~/content_editor/extensions/heading';
-import { tiptapExtension as ListItem } from '~/content_editor/extensions/list_item';
+import BulletList from '~/content_editor/extensions/bullet_list';
+import CodeBlockLowlight from '~/content_editor/extensions/code_block_highlight';
+import Heading from '~/content_editor/extensions/heading';
+import ListItem from '~/content_editor/extensions/list_item';
import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_rules_and_shortcuts';
import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys';
import { createTestEditor } from '../test_utils';
diff --git a/spec/frontend/content_editor/services/upload_file_spec.js b/spec/frontend/content_editor/services/upload_helpers_spec.js
index 87c5298079e..ee9333232db 100644
--- a/spec/frontend/content_editor/services/upload_file_spec.js
+++ b/spec/frontend/content_editor/services/upload_helpers_spec.js
@@ -1,9 +1,9 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { uploadFile } from '~/content_editor/services/upload_file';
+import { uploadFile } from '~/content_editor/services/upload_helpers';
import httpStatus from '~/lib/utils/http_status';
-describe('content_editor/services/upload_file', () => {
+describe('content_editor/services/upload_helpers', () => {
const uploadsPath = '/uploads';
const file = new File(['content'], 'file.txt');
// TODO: Replace with automated fixture
diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js
index 090e1d92218..b5a2abc2389 100644
--- a/spec/frontend/content_editor/test_utils.js
+++ b/spec/frontend/content_editor/test_utils.js
@@ -4,6 +4,7 @@ import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Editor } from '@tiptap/vue-2';
import { builders, eq } from 'prosemirror-test-builder';
+import { nextTick } from 'vue';
export const createDocBuilder = ({ tiptapEditor, names = {} }) => {
const docBuilders = builders(tiptapEditor.schema, {
@@ -14,6 +15,12 @@ export const createDocBuilder = ({ tiptapEditor, names = {} }) => {
return { eq, builders: docBuilders };
};
+export const emitEditorEvent = ({ tiptapEditor, event, params = {} }) => {
+ tiptapEditor.emit(event, { editor: tiptapEditor, ...params });
+
+ return nextTick();
+};
+
/**
* Creates an instance of the Tiptap Editor class
* with a minimal configuration for testing purposes.