diff options
Diffstat (limited to 'spec/frontend/content_editor/components')
8 files changed, 357 insertions, 54 deletions
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 59c4190ad3a..563e80e04c1 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,5 +1,7 @@ +import { GlAlert } from '@gitlab/ui'; import { EditorContent } from '@tiptap/vue-2'; -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditor from '~/content_editor/components/content_editor.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import { createContentEditor } from '~/content_editor/services/create_content_editor'; @@ -8,8 +10,11 @@ describe('ContentEditor', () => { let wrapper; let editor; + const findEditorElement = () => wrapper.findByTestId('content-editor'); + const findErrorAlert = () => wrapper.findComponent(GlAlert); + const createWrapper = async (contentEditor) => { - wrapper = shallowMount(ContentEditor, { + wrapper = shallowMountExtended(ContentEditor, { propsData: { contentEditor, }, @@ -49,7 +54,7 @@ describe('ContentEditor', () => { editor.tiptapEditor.isFocused = isFocused; createWrapper(editor); - expect(wrapper.classes()).toStrictEqual(classes); + expect(findEditorElement().classes()).toStrictEqual(classes); }, ); @@ -57,6 +62,30 @@ describe('ContentEditor', () => { editor.tiptapEditor.isFocused = true; createWrapper(editor); - expect(wrapper.classes()).toContain('is-focused'); + expect(findEditorElement().classes()).toContain('is-focused'); + }); + + describe('displaying error', () => { + const error = 'Content Editor error'; + + beforeEach(async () => { + createWrapper(editor); + + editor.tiptapEditor.emit('error', error); + + await nextTick(); + }); + + it('displays error notifications from the tiptap editor', () => { + expect(findErrorAlert().text()).toBe(error); + }); + + it('allows dismissing an error alert', async () => { + findErrorAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findErrorAlert().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js index a49efa34017..d848adcbff8 100644 --- a/spec/frontend/content_editor/components/toolbar_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -1,33 +1,17 @@ import { GlButton } from '@gitlab/ui'; -import { Extension } from '@tiptap/core'; import { shallowMount } from '@vue/test-utils'; import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; -import { createContentEditor } from '~/content_editor/services/create_content_editor'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/toolbar_button', () => { let wrapper; let tiptapEditor; - let toggleFooSpy; const CONTENT_TYPE = 'bold'; const ICON_NAME = 'bold'; const LABEL = 'Bold'; const buildEditor = () => { - toggleFooSpy = jest.fn(); - tiptapEditor = createContentEditor({ - extensions: [ - { - tiptapExtension: Extension.create({ - addCommands() { - return { - toggleFoo: () => toggleFooSpy, - }; - }, - }), - }, - ], - renderMarkdown: () => true, - }).tiptapEditor; + tiptapEditor = createTestEditor(); jest.spyOn(tiptapEditor, 'isActive'); }; @@ -78,20 +62,28 @@ describe('content_editor/components/toolbar_button', () => { describe('when button is clicked', () => { it('executes the content type command when executeCommand = true', async () => { - buildWrapper({ editorCommand: 'toggleFoo' }); + const editorCommand = 'toggleFoo'; + const mockCommands = mockChainedCommands(tiptapEditor, [editorCommand, 'focus', 'run']); + + buildWrapper({ editorCommand }); await findButton().trigger('click'); - expect(toggleFooSpy).toHaveBeenCalled(); + expect(mockCommands[editorCommand]).toHaveBeenCalled(); + expect(mockCommands.focus).toHaveBeenCalled(); + expect(mockCommands.run).toHaveBeenCalled(); expect(wrapper.emitted().execute).toHaveLength(1); }); it('does not executes the content type command when executeCommand = false', async () => { + const editorCommand = 'toggleFoo'; + const mockCommands = mockChainedCommands(tiptapEditor, [editorCommand, 'run']); + buildWrapper(); await findButton().trigger('click'); - expect(toggleFooSpy).not.toHaveBeenCalled(); + expect(mockCommands[editorCommand]).not.toHaveBeenCalled(); expect(wrapper.emitted().execute).toHaveLength(1); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_image_button_spec.js b/spec/frontend/content_editor/components/toolbar_image_button_spec.js new file mode 100644 index 00000000000..701dcf83476 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_image_button_spec.js @@ -0,0 +1,78 @@ +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 { createTestEditor, mockChainedCommands } from '../test_utils'; + +describe('content_editor/components/toolbar_image_button', () => { + let wrapper; + let editor; + + const buildWrapper = () => { + wrapper = mountExtended(ToolbarImageButton, { + propsData: { + tiptapEditor: editor, + }, + }); + }; + + const findImageURLInput = () => + wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); + const findApplyImageButton = () => wrapper.findComponent(GlButton); + + 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(() => { + const { tiptapExtension: Image } = configureImageExtension({ + renderMarkdown: jest.fn(), + uploadsPath: '/uploads/', + }); + + editor = createTestEditor({ + extensions: [Image], + }); + + buildWrapper(); + }); + + afterEach(() => { + editor.destroy(); + wrapper.destroy(); + }); + + it('sets the image to the value in the URL input when "Insert" button is clicked', async () => { + const commands = mockChainedCommands(editor, ['focus', 'setImage', 'run']); + + await findImageURLInput().setValue('https://example.com/img.jpg'); + await findApplyImageButton().trigger('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.setImage).toHaveBeenCalledWith({ + alt: 'img', + src: 'https://example.com/img.jpg', + canonicalSrc: 'https://example.com/img.jpg', + }); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'url' }]); + }); + + it('uploads the selected image when file input changes', async () => { + const commands = mockChainedCommands(editor, ['focus', 'uploadImage', '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.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 812e769c891..576a2912f72 100644 --- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownDivider, GlFormInputGroup, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownDivider, 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'; @@ -16,9 +16,6 @@ describe('content_editor/components/toolbar_link_button', () => { propsData: { tiptapEditor: editor, }, - stubs: { - GlFormInputGroup, - }, }); }; const findDropdown = () => wrapper.findComponent(GlDropdown); @@ -45,9 +42,8 @@ describe('content_editor/components/toolbar_link_button', () => { }); describe('when there is an active link', () => { - beforeEach(() => { - jest.spyOn(editor, 'isActive'); - editor.isActive.mockReturnValueOnce(true); + beforeEach(async () => { + jest.spyOn(editor, 'isActive').mockReturnValueOnce(true); buildWrapper(); }); @@ -78,8 +74,36 @@ describe('content_editor/components/toolbar_link_button', () => { expect(commands.focus).toHaveBeenCalled(); expect(commands.unsetLink).toHaveBeenCalled(); - expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' }); + expect(commands.setLink).toHaveBeenCalledWith({ + href: 'https://example', + canonicalSrc: 'https://example', + }); expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); + }); + + describe('on selection update', () => { + it('updates link input box with canonical-src if present', async () => { + jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({ + canonicalSrc: 'uploads/my-file.zip', + href: '/username/my-project/uploads/abcdefgh133535/my-file.zip', + }); + + await editor.emit('selectionUpdate', { editor }); + + expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip'); + }); + + it('updates link input box with link href otherwise', async () => { + jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({ + href: 'https://gitlab.com', + }); + + await editor.emit('selectionUpdate', { editor }); + + expect(findLinkURLInput().element.value).toEqual('https://gitlab.com'); + }); }); }); @@ -106,8 +130,13 @@ describe('content_editor/components/toolbar_link_button', () => { await findApplyLinkButton().trigger('click'); expect(commands.focus).toHaveBeenCalled(); - expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' }); + expect(commands.setLink).toHaveBeenCalledWith({ + href: 'https://example', + canonicalSrc: 'https://example', + }); expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js new file mode 100644 index 00000000000..237b2848246 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js @@ -0,0 +1,109 @@ +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', () => { + let wrapper; + let editor; + + const buildWrapper = () => { + wrapper = mountExtended(ToolbarTableButton, { + propsData: { + tiptapEditor: editor, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const getNumButtons = () => findDropdown().findAllComponents(GlButton).length; + + beforeEach(() => { + editor = createTestEditor({ + extensions: [Table, TableCell, TableRow, TableHeader], + }); + + buildWrapper(); + }); + + afterEach(() => { + editor.destroy(); + wrapper.destroy(); + }); + + it('renders a grid of 3x3 buttons to create a table', () => { + expect(getNumButtons()).toBe(9); // 3 x 3 + }); + + 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'} + `('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => { + describe('on mouse over', () => { + beforeEach(async () => { + const button = wrapper.findByTestId(`table-${row}-${col}`); + await button.trigger('mouseover'); + }); + + 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!', + ); + }); + + it('shows a help text indicating the size of the table being inserted', () => { + expect(findDropdown().element).toHaveText(`Insert a ${tableSize} table.`); + }); + + it('adds another row and col of buttons to create a bigger table', () => { + expect(getNumButtons()).toBe(numButtons); + }); + }); + + describe('on click', () => { + let commands; + + beforeEach(async () => { + commands = mockChainedCommands(editor, ['focus', 'insertTable', 'run']); + + const button = wrapper.findByTestId(`table-${row}-${col}`); + await button.trigger('mouseover'); + await button.trigger('click'); + }); + + it('inserts a table with $tableSize rows and cols', () => { + expect(commands.focus).toHaveBeenCalled(); + expect(commands.insertTable).toHaveBeenCalledWith({ + rows: row, + cols: col, + withHeaderRow: true, + }); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute).toHaveLength(1); + }); + }); + }); + + it('does not create more buttons than a 8x8 grid', async () => { + for (let i = 3; i < 8; i += 1) { + expect(getNumButtons()).toBe(i * i); + + // eslint-disable-next-line no-await-in-loop + await wrapper.findByTestId(`table-${i}-${i}`).trigger('mouseover'); + expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`); + } + + expect(getNumButtons()).toBe(64); // 8x8 (and not 9x9) + }); +}); 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 8c54f6bb8bb..9a46e27404f 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 @@ -2,21 +2,16 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; -import { createTestContentEditorExtension, createTestEditor } from '../test_utils'; +import { tiptapExtension as Heading } from '~/content_editor/extensions/heading'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/toolbar_headings_dropdown', () => { let wrapper; let tiptapEditor; - let commandMocks; const buildEditor = () => { - const testExtension = createTestContentEditorExtension({ - commands: TEXT_STYLE_DROPDOWN_ITEMS.map((item) => item.editorCommand), - }); - - commandMocks = testExtension.commandMocks; tiptapEditor = createTestEditor({ - extensions: [testExtension.tiptapExtension], + extensions: [Heading], }); jest.spyOn(tiptapEditor, 'isActive'); @@ -104,9 +99,12 @@ describe('content_editor/components/toolbar_headings_dropdown', () => { TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => { const { editorCommand, commandParams } = textStyle; + const commands = mockChainedCommands(tiptapEditor, [editorCommand, 'focus', 'run']); wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click'); - expect(commandMocks[editorCommand]).toHaveBeenCalledWith(commandParams || {}); + expect(commands[editorCommand]).toHaveBeenCalledWith(commandParams || {}); + expect(commands.focus).toHaveBeenCalled(); + expect(commands.run).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index 0d55fa730ae..5411793cd5e 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -39,17 +39,19 @@ describe('content_editor/components/top_toolbar', () => { }); describe.each` - testId | controlProps - ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} - ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} - ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} - ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} - ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} - ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} - ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} - ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} - ${'text-styles'} | ${{}} - ${'link'} | ${{}} + testId | controlProps + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} + ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} + ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} + ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} + ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} + ${'horizontal-rule'} | ${{ contentType: 'horizontalRule', iconName: 'dash', label: 'Add a horizontal rule', editorCommand: 'setHorizontalRule' }} + ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} + ${'text-styles'} | ${{}} + ${'link'} | ${{}} + ${'image'} | ${{}} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); diff --git a/spec/frontend/content_editor/components/wrappers/image_spec.js b/spec/frontend/content_editor/components/wrappers/image_spec.js new file mode 100644 index 00000000000..7b057f9cabc --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/image_spec.js @@ -0,0 +1,66 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ImageWrapper from '~/content_editor/components/wrappers/image.vue'; + +describe('content/components/wrappers/image', () => { + let wrapper; + + const createWrapper = async (nodeAttrs = {}) => { + wrapper = shallowMountExtended(ImageWrapper, { + propsData: { + node: { + attrs: nodeAttrs, + }, + }, + }); + }; + const findImage = () => wrapper.findByTestId('image'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-wrapper with display-inline-block class', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-display-inline-block'); + }); + + it('renders an image that displays the node src', () => { + const src = 'foobar.png'; + + createWrapper({ src }); + + expect(findImage().attributes().src).toBe(src); + }); + + describe('when uploading', () => { + beforeEach(() => { + createWrapper({ uploading: true }); + }); + + it('renders a gl-loading-icon component', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('adds gl-opacity-5 class selector to image', () => { + expect(findImage().classes()).toContain('gl-opacity-5'); + }); + }); + + describe('when not uploading', () => { + beforeEach(() => { + createWrapper({ uploading: false }); + }); + + it('does not render a gl-loading-icon component', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('does not add gl-opacity-5 class selector to image', () => { + expect(findImage().classes()).not.toContain('gl-opacity-5'); + }); + }); +}); |