diff options
Diffstat (limited to 'spec/frontend/content_editor/components')
12 files changed, 472 insertions, 129 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, { |