diff options
Diffstat (limited to 'spec/frontend/content_editor/components')
-rw-r--r-- | spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap | 4 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/bubble_menus/code_block_spec.js (renamed from spec/frontend/content_editor/components/code_block_bubble_menu_spec.js) | 36 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/bubble_menus/formatting_spec.js (renamed from spec/frontend/content_editor/components/formatting_bubble_menu_spec.js) | 19 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/bubble_menus/link_spec.js | 227 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/bubble_menus/media_spec.js | 234 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/content_editor_spec.js | 2 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/toolbar_button_spec.js | 2 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/wrappers/code_block_spec.js (renamed from spec/frontend/content_editor/components/wrappers/frontmatter_spec.js) | 33 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/wrappers/media_spec.js | 69 |
9 files changed, 528 insertions, 98 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 e508cddd6f9..a63cca006da 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-button btn-default-tertiary btn-icon\\"> +exports[`content_editor/components/toolbar_button displays tertiary, medium button with a provided label and icon 1`] = ` +"<b-button-stub size=\\"md\\" 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/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js index 074c311495f..3a15ea45f40 100644 --- a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js @@ -1,14 +1,14 @@ import { BubbleMenu } from '@tiptap/vue-2'; -import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import Vue from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue'; +import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block.vue'; import eventHubFactory from '~/helpers/event_hub_factory'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; -import { createTestEditor, emitEditorEvent } from '../test_utils'; +import { createTestEditor, emitEditorEvent } from '../../test_utils'; -describe('content_editor/components/code_block_bubble_menu', () => { +describe('content_editor/components/bubble_menus/code_block', () => { let wrapper; let tiptapEditor; let bubbleMenu; @@ -52,7 +52,7 @@ describe('content_editor/components/code_block_bubble_menu', () => { await emitEditorEvent({ event: 'transaction', tiptapEditor }); expect(bubbleMenu.props('editor')).toBe(tiptapEditor); - expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); it('selects plaintext language by default', async () => { @@ -82,12 +82,26 @@ describe('content_editor/components/code_block_bubble_menu', () => { expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)'); }); - it('delete button deletes the code block', async () => { - tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + describe('copy button', () => { + it('copies the text of the code block', async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = Math.PI / 2;</pre>'); + + await wrapper.findByTestId('copy-code-block').vm.$emit('click'); - await wrapper.findComponent(GlButton).vm.$emit('click'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('var a = Math.PI / 2;'); + }); + }); - expect(tiptapEditor.getText()).toBe(''); + describe('delete button', () => { + it('deletes the code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + await wrapper.findByTestId('delete-code-block').vm.$emit('click'); + + expect(tiptapEditor.getText()).toBe(''); + }); }); describe('when opened and search is changed', () => { @@ -110,7 +124,7 @@ describe('content_editor/components/code_block_bubble_menu', () => { describe('when dropdown item is clicked', () => { beforeEach(async () => { - jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue(); + jest.spyOn(codeBlockLanguageLoader, 'loadLanguage').mockResolvedValue(); findDropdownItems().at(1).vm.$emit('click'); @@ -118,7 +132,7 @@ describe('content_editor/components/code_block_bubble_menu', () => { }); it('loads language', () => { - expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']); + expect(codeBlockLanguageLoader.loadLanguage).toHaveBeenCalledWith('java'); }); it('sets code block', () => { diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js index 192ddee78c6..6479c0ba008 100644 --- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js @@ -1,15 +1,15 @@ 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 FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue'; import { BUBBLE_MENU_TRACKING_ACTION, CONTENT_EDITOR_TRACKING_LABEL, } from '~/content_editor/constants'; -import { createTestEditor } from '../test_utils'; +import { createTestEditor } from '../../test_utils'; -describe('content_editor/components/formatting_bubble_menu', () => { +describe('content_editor/components/bubble_menus/formatting', () => { let wrapper; let trackingSpy; let tiptapEditor; @@ -42,15 +42,16 @@ describe('content_editor/components/formatting_bubble_menu', () => { const bubbleMenu = wrapper.findComponent(BubbleMenu); expect(bubbleMenu.props().editor).toBe(tiptapEditor); - expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); 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' }} + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'tertiary' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'tertiary' }} + ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'tertiary' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'tertiary' }} + ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' }, size: 'medium', category: 'tertiary' }} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); @@ -60,7 +61,7 @@ describe('content_editor/components/formatting_bubble_menu', () => { expect(wrapper.findByTestId(testId).exists()).toBe(true); Object.keys(controlProps).forEach((propName) => { - expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]); + expect(wrapper.findByTestId(testId).props(propName)).toEqual(controlProps[propName]); }); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/link_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_spec.js new file mode 100644 index 00000000000..ba6d8da9584 --- /dev/null +++ b/spec/frontend/content_editor/components/bubble_menus/link_spec.js @@ -0,0 +1,227 @@ +import { GlLink, GlForm } from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import Link from '~/content_editor/extensions/link'; +import { createTestEditor, emitEditorEvent } from '../../test_utils'; + +const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); + +describe('content_editor/components/bubble_menus/link', () => { + let wrapper; + let tiptapEditor; + let contentEditor; + let bubbleMenu; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [Link] }); + contentEditor = { resolveUrl: jest.fn() }; + eventHub = eventHubFactory(); + }; + + const buildWrapper = () => { + wrapper = mountExtended(LinkBubbleMenu, { + provide: { + tiptapEditor, + contentEditor, + eventHub, + }, + }); + }; + + const expectLinkButtonsToExist = (exist = true) => { + expect(wrapper.findComponent(GlLink).exists()).toBe(exist); + expect(wrapper.findByTestId('copy-link-url').exists()).toBe(exist); + expect(wrapper.findByTestId('edit-link').exists()).toBe(exist); + expect(wrapper.findByTestId('remove-link').exists()).toBe(exist); + }; + + beforeEach(async () => { + buildEditor(); + buildWrapper(); + + tiptapEditor + .chain() + .insertContent( + 'Download <a href="/path/to/project/-/wikis/uploads/my_file.pdf" data-canonical-src="uploads/my_file.pdf" title="Click here to download">PDF File</a>', + ) + .setTextSelection(14) // put cursor in the middle of the link + .run(); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + bubbleMenu = wrapper.findComponent(BubbleMenu); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', async () => { + expect(bubbleMenu.props('editor')).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); + }); + + it('shows a clickable link to the URL in the link node', async () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes()).toEqual( + expect.objectContaining({ + href: '/path/to/project/-/wikis/uploads/my_file.pdf', + 'aria-label': 'uploads/my_file.pdf', + title: 'uploads/my_file.pdf', + target: '_blank', + }), + ); + expect(link.text()).toBe('uploads/my_file.pdf'); + }); + + describe('copy button', () => { + it('copies the canonical link to clipboard', async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + await wrapper.findByTestId('copy-link-url').vm.$emit('click'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('uploads/my_file.pdf'); + }); + }); + + describe('remove link button', () => { + it('removes the link', async () => { + await wrapper.findByTestId('remove-link').vm.$emit('click'); + + expect(tiptapEditor.getHTML()).toBe('<p>Download PDF File</p>'); + }); + }); + + describe('for a placeholder link', () => { + beforeEach(async () => { + tiptapEditor + .chain() + .clearContent() + .insertContent('Dummy link') + .selectAll() + .setLink({ href: '' }) + .setTextSelection(4) + .run(); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }); + + it('directly opens the edit form for a placeholder link', async () => { + expectLinkButtonsToExist(false); + + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + }); + + it('removes the link on clicking apply (if no change)', async () => { + await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); + + expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>'); + }); + + it('removes the link on clicking cancel', async () => { + await wrapper.findByTestId('cancel-link').vm.$emit('click'); + + expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>'); + }); + }); + + describe('edit button', () => { + let linkHrefInput; + let linkTitleInput; + + beforeEach(async () => { + await wrapper.findByTestId('edit-link').vm.$emit('click'); + + linkHrefInput = wrapper.findByTestId('link-href'); + linkTitleInput = wrapper.findByTestId('link-title'); + }); + + it('hides the link and copy/edit/remove link buttons', async () => { + expectLinkButtonsToExist(false); + }); + + it('shows a form to edit the link', () => { + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + + expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf'); + expect(linkTitleInput.element.value).toBe('Click here to download'); + }); + + it('extends selection to select the entire link', () => { + const { from, to } = tiptapEditor.state.selection; + + expect(from).toBe(10); + expect(to).toBe(18); + }); + + it('shows the copy/edit/remove link buttons again if selection changes to another non-link and then back again to a link', async () => { + expectLinkButtonsToExist(false); + + tiptapEditor.commands.setTextSelection(3); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + tiptapEditor.commands.setTextSelection(14); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expectLinkButtonsToExist(true); + expect(wrapper.findComponent(GlForm).exists()).toBe(false); + }); + + describe('after making changes in the form and clicking apply', () => { + beforeEach(async () => { + linkHrefInput.setValue('https://google.com'); + linkTitleInput.setValue('Search Google'); + + contentEditor.resolveUrl.mockResolvedValue('https://google.com'); + + await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); + }); + + it('updates prosemirror doc with new link', async () => { + expect(tiptapEditor.getHTML()).toBe( + '<p>Download <a target="_blank" rel="noopener noreferrer nofollow" href="https://google.com" title="Search Google" canonicalsrc="https://google.com">PDF File</a></p>', + ); + }); + + it('updates the link in the bubble menu', () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes()).toEqual( + expect.objectContaining({ + href: 'https://google.com', + 'aria-label': 'https://google.com', + title: 'https://google.com', + target: '_blank', + }), + ); + expect(link.text()).toBe('https://google.com'); + }); + }); + + describe('after making changes in the form and clicking cancel', () => { + beforeEach(async () => { + linkHrefInput.setValue('https://google.com'); + linkTitleInput.setValue('Search Google'); + + await wrapper.findByTestId('cancel-link').vm.$emit('click'); + }); + + it('hides the form and shows the copy/edit/remove link buttons', () => { + expectLinkButtonsToExist(); + }); + + it('resets the form with old values of the link from prosemirror', async () => { + // click edit once again to show the form back + await wrapper.findByTestId('edit-link').vm.$emit('click'); + + linkHrefInput = wrapper.findByTestId('link-href'); + linkTitleInput = wrapper.findByTestId('link-title'); + + expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf'); + expect(linkTitleInput.element.value).toBe('Click here to download'); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/bubble_menus/media_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_spec.js new file mode 100644 index 00000000000..8839caea80e --- /dev/null +++ b/spec/frontend/content_editor/components/bubble_menus/media_spec.js @@ -0,0 +1,234 @@ +import { GlLink, GlForm } from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import Image from '~/content_editor/extensions/image'; +import Audio from '~/content_editor/extensions/audio'; +import Video from '~/content_editor/extensions/video'; +import { createTestEditor, emitEditorEvent, mockChainedCommands } from '../../test_utils'; +import { + PROJECT_WIKI_ATTACHMENT_IMAGE_HTML, + PROJECT_WIKI_ATTACHMENT_AUDIO_HTML, + PROJECT_WIKI_ATTACHMENT_VIDEO_HTML, +} from '../../test_constants'; + +const TIPTAP_IMAGE_HTML = `<p> + <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon" data-canonical-src="https://gitlab.com/favicon.png"> +</p>`; + +const TIPTAP_AUDIO_HTML = `<p> + <span class="media-container audio-container"><audio src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></audio><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span> +</p>`; + +const TIPTAP_VIDEO_HTML = `<p> + <span class="media-container video-container"><video src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></video><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span> +</p>`; + +const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); + +describe.each` + mediaType | mediaHTML | filePath | mediaOutputHTML + ${'image'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${'test-file.png'} | ${TIPTAP_IMAGE_HTML} + ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML} + ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML} +`( + 'content_editor/components/bubble_menus/media ($mediaType)', + ({ mediaType, mediaHTML, filePath, mediaOutputHTML }) => { + let wrapper; + let tiptapEditor; + let contentEditor; + let bubbleMenu; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [Image, Audio, Video] }); + contentEditor = { resolveUrl: jest.fn() }; + eventHub = eventHubFactory(); + }; + + const buildWrapper = () => { + wrapper = mountExtended(MediaBubbleMenu, { + provide: { + tiptapEditor, + contentEditor, + eventHub, + }, + }); + }; + + 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'); + }; + + const expectLinkButtonsToExist = (exist = true) => { + expect(wrapper.findComponent(GlLink).exists()).toBe(exist); + expect(wrapper.findByTestId('copy-media-src').exists()).toBe(exist); + expect(wrapper.findByTestId('edit-media').exists()).toBe(exist); + expect(wrapper.findByTestId('delete-media').exists()).toBe(exist); + }; + + beforeEach(async () => { + buildEditor(); + buildWrapper(); + + tiptapEditor + .chain() + .insertContent(mediaHTML) + .setNodeSelection(4) // select the media + .run(); + + contentEditor.resolveUrl.mockResolvedValue(`/group1/project1/-/wikis/${filePath}`); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + bubbleMenu = wrapper.findComponent(BubbleMenu); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', async () => { + expect(bubbleMenu.props('editor')).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); + }); + + it('shows a clickable link to the image', async () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes()).toEqual( + expect.objectContaining({ + href: `/group1/project1/-/wikis/${filePath}`, + 'aria-label': filePath, + title: filePath, + target: '_blank', + }), + ); + expect(link.text()).toBe(filePath); + }); + + describe('copy button', () => { + it(`copies the canonical link to the ${mediaType} to clipboard`, async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + await wrapper.findByTestId('copy-media-src').vm.$emit('click'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(filePath); + }); + }); + + describe(`remove ${mediaType} button`, () => { + it(`removes the ${mediaType}`, async () => { + await wrapper.findByTestId('delete-media').vm.$emit('click'); + + expect(tiptapEditor.getHTML()).toBe('<p>\n \n</p>'); + }); + }); + + describe(`replace ${mediaType} button`, () => { + it('uploads and replaces the selected image when file input changes', async () => { + const commands = mockChainedCommands(tiptapEditor, [ + 'focus', + 'deleteSelection', + 'uploadAttachment', + 'run', + ]); + const file = new File(['foo'], 'foo.png', { type: 'image/png' }); + + await wrapper.findByTestId('replace-media').vm.$emit('click'); + await selectFile(file); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.deleteSelection).toHaveBeenCalled(); + expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); + expect(commands.run).toHaveBeenCalled(); + }); + }); + + describe('edit button', () => { + let mediaSrcInput; + let mediaTitleInput; + let mediaAltInput; + + beforeEach(async () => { + await wrapper.findByTestId('edit-media').vm.$emit('click'); + + mediaSrcInput = wrapper.findByTestId('media-src'); + mediaTitleInput = wrapper.findByTestId('media-title'); + mediaAltInput = wrapper.findByTestId('media-alt'); + }); + + it('hides the link and copy/edit/remove link buttons', async () => { + expectLinkButtonsToExist(false); + }); + + it(`shows a form to edit the ${mediaType} src/title/alt`, () => { + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + + expect(mediaSrcInput.element.value).toBe(filePath); + expect(mediaTitleInput.element.value).toBe(''); + expect(mediaAltInput.element.value).toBe('test-file'); + }); + + describe('after making changes in the form and clicking apply', () => { + beforeEach(async () => { + mediaSrcInput.setValue('https://gitlab.com/favicon.png'); + mediaAltInput.setValue('gitlab favicon'); + mediaTitleInput.setValue('gitlab favicon'); + + contentEditor.resolveUrl.mockResolvedValue('https://gitlab.com/favicon.png'); + + await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); + }); + + it(`updates prosemirror doc with new src to the ${mediaType}`, async () => { + expect(tiptapEditor.getHTML()).toBe(mediaOutputHTML); + }); + + it(`updates the link to the ${mediaType} in the bubble menu`, () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes()).toEqual( + expect.objectContaining({ + href: 'https://gitlab.com/favicon.png', + 'aria-label': 'https://gitlab.com/favicon.png', + title: 'https://gitlab.com/favicon.png', + target: '_blank', + }), + ); + expect(link.text()).toBe('https://gitlab.com/favicon.png'); + }); + }); + + describe('after making changes in the form and clicking cancel', () => { + beforeEach(async () => { + mediaSrcInput.setValue('https://gitlab.com/favicon.png'); + mediaAltInput.setValue('gitlab favicon'); + mediaTitleInput.setValue('gitlab favicon'); + + await wrapper.findByTestId('cancel-editing-media').vm.$emit('click'); + }); + + it('hides the form and shows the copy/edit/remove link buttons', () => { + expectLinkButtonsToExist(); + }); + + it(`resets the form with old values of the ${mediaType} from prosemirror`, async () => { + // click edit once again to show the form back + await wrapper.findByTestId('edit-media').vm.$emit('click'); + + mediaSrcInput = wrapper.findByTestId('media-src'); + mediaTitleInput = wrapper.findByTestId('media-title'); + mediaAltInput = wrapper.findByTestId('media-alt'); + + expect(mediaSrcInput.element.value).toBe(filePath); + expect(mediaAltInput.element.value).toBe('test-file'); + expect(mediaTitleInput.element.value).toBe(''); + }); + }); + }); + }, +); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 73fcfeab8bc..9ee3b017831 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -4,7 +4,7 @@ import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; -import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue'; +import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import { emitEditorEvent } from '../test_utils'; diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js index ce50482302d..1f1f7b338c6 100644 --- a/spec/frontend/content_editor/components/toolbar_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -46,7 +46,7 @@ describe('content_editor/components/toolbar_button', () => { wrapper.destroy(); }); - it('displays tertiary, small button with a provided label and icon', () => { + it('displays tertiary, medium button with a provided label and icon', () => { buildWrapper(); expect(findButton().html()).toMatchSnapshot(); diff --git a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index 415f1314a36..a564959a3a6 100644 --- a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -1,20 +1,33 @@ +import { nextTick } from 'vue'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { shallowMount } from '@vue/test-utils'; -import FrontmatterWrapper from '~/content_editor/components/wrappers/frontmatter.vue'; +import CodeBlockWrapper from '~/content_editor/components/wrappers/code_block.vue'; +import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; -describe('content/components/wrappers/frontmatter', () => { +jest.mock('~/content_editor/services/code_block_language_loader'); + +describe('content/components/wrappers/code_block', () => { + const language = 'yaml'; let wrapper; + let updateAttributesFn; + + const createWrapper = async (nodeAttrs = { language }) => { + updateAttributesFn = jest.fn(); - const createWrapper = async (nodeAttrs = { language: 'yaml' }) => { - wrapper = shallowMount(FrontmatterWrapper, { + wrapper = shallowMount(CodeBlockWrapper, { propsData: { node: { attrs: nodeAttrs, }, + updateAttributes: updateAttributesFn, }, }); }; + beforeEach(() => { + codeBlockLanguageLoader.findLanguageBySyntax.mockReturnValue({ syntax: language }); + }); + afterEach(() => { wrapper.destroy(); }); @@ -38,11 +51,21 @@ describe('content/components/wrappers/frontmatter', () => { }); it('renders label indicating that code block is frontmatter', () => { - createWrapper(); + createWrapper({ isFrontmatter: true, language }); const label = wrapper.find('[data-testid="frontmatter-label"]'); expect(label.text()).toEqual('frontmatter:yaml'); expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']); }); + + it('loads code block’s syntax highlight language', async () => { + createWrapper(); + + expect(codeBlockLanguageLoader.loadLanguage).toHaveBeenCalledWith(language); + + await nextTick(); + + expect(updateAttributesFn).toHaveBeenCalledWith({ language }); + }); }); diff --git a/spec/frontend/content_editor/components/wrappers/media_spec.js b/spec/frontend/content_editor/components/wrappers/media_spec.js deleted file mode 100644 index 3e95e2f3914..00000000000 --- a/spec/frontend/content_editor/components/wrappers/media_spec.js +++ /dev/null @@ -1,69 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import MediaWrapper from '~/content_editor/components/wrappers/media.vue'; - -describe('content/components/wrappers/media', () => { - let wrapper; - - const createWrapper = async (nodeAttrs = {}) => { - wrapper = shallowMountExtended(MediaWrapper, { - propsData: { - node: { - attrs: nodeAttrs, - type: { - name: 'image', - }, - }, - }, - }); - }; - const findMedia = () => wrapper.findByTestId('media'); - 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(findMedia().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 the media tag', () => { - expect(findMedia().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 the media tag', () => { - expect(findMedia().classes()).not.toContain('gl-opacity-5'); - }); - }); -}); |