diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
commit | b76ae638462ab0f673e5915986070518dd3f9ad3 (patch) | |
tree | bdab0533383b52873be0ec0eb4d3c66598ff8b91 /spec/frontend/content_editor | |
parent | 434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff) | |
download | gitlab-ce-b76ae638462ab0f673e5915986070518dd3f9ad3.tar.gz |
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'spec/frontend/content_editor')
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. |