diff options
Diffstat (limited to 'spec/frontend/content_editor')
11 files changed, 472 insertions, 8 deletions
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 8f5516545eb..178c7d749c8 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 @@ -11,14 +11,7 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen <ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\"> <div class=\\"gl-new-dropdown-inner\\"> <!----> - <div class=\\"gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5\\"> - <div class=\\"gl-display-flex\\"> - <!----> - </div> - <div class=\\"gl-display-flex\\"> - <!----> - </div> - </div> + <!----> <div class=\\"gl-new-dropdown-contents\\"> <!----> <li role=\\"presentation\\" class=\\"gl-px-3!\\"> diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index a5df3d73289..ec58877470c 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -31,6 +31,7 @@ describe('content_editor/components/top_toolbar', () => { ${'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' }} + ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }} ${'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'} | ${{}} diff --git a/spec/frontend/content_editor/components/wrappers/details_spec.js b/spec/frontend/content_editor/components/wrappers/details_spec.js new file mode 100644 index 00000000000..d746b9fa2f1 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/details_spec.js @@ -0,0 +1,40 @@ +import { NodeViewContent } from '@tiptap/vue-2'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DetailsWrapper from '~/content_editor/components/wrappers/details.vue'; + +describe('content/components/wrappers/details', () => { + let wrapper; + + const createWrapper = async () => { + wrapper = shallowMountExtended(DetailsWrapper, { + propsData: { + node: {}, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-content as a ul element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewContent).props().as).toBe('ul'); + }); + + it('is "open" by default', () => { + createWrapper(); + + expect(wrapper.findByTestId('details-toggle-icon').classes()).toContain('is-open'); + expect(wrapper.findComponent(NodeViewContent).classes()).toContain('is-open'); + }); + + it('closes the details block on clicking the details toggle icon', async () => { + createWrapper(); + + await wrapper.findByTestId('details-toggle-icon').trigger('click'); + expect(wrapper.findByTestId('details-toggle-icon').classes()).not.toContain('is-open'); + expect(wrapper.findComponent(NodeViewContent).classes()).not.toContain('is-open'); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js new file mode 100644 index 00000000000..de8f8efd260 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js @@ -0,0 +1,43 @@ +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; +import { shallowMount } from '@vue/test-utils'; +import FrontmatterWrapper from '~/content_editor/components/wrappers/frontmatter.vue'; + +describe('content/components/wrappers/frontmatter', () => { + let wrapper; + + const createWrapper = async (nodeAttrs = { language: 'yaml' }) => { + wrapper = shallowMount(FrontmatterWrapper, { + propsData: { + node: { + attrs: nodeAttrs, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-wrapper as a pre element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('pre'); + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative'); + }); + + it('renders a node-view-content as a code element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewContent).props().as).toBe('code'); + }); + + it('renders label indicating that code block is frontmatter', () => { + createWrapper(); + + 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']); + }); +}); diff --git a/spec/frontend/content_editor/extensions/color_chip_spec.js b/spec/frontend/content_editor/extensions/color_chip_spec.js new file mode 100644 index 00000000000..4bb6f344ab4 --- /dev/null +++ b/spec/frontend/content_editor/extensions/color_chip_spec.js @@ -0,0 +1,33 @@ +import ColorChip, { colorDecoratorPlugin } from '~/content_editor/extensions/color_chip'; +import Code from '~/content_editor/extensions/code'; +import { createTestEditor } from '../test_utils'; + +describe('content_editor/extensions/color_chip', () => { + let tiptapEditor; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [ColorChip, Code] }); + }); + + describe.each` + colorExpression | decorated + ${'#F00'} | ${true} + ${'rgba(0,0,0,0)'} | ${true} + ${'hsl(540,70%,50%)'} | ${true} + ${'F00'} | ${false} + ${'F00'} | ${false} + ${'gba(0,0,0,0)'} | ${false} + ${'hls(540,70%,50%)'} | ${false} + ${'red'} | ${false} + `( + 'when a code span with $colorExpression color expression is found', + ({ colorExpression, decorated }) => { + it(`${decorated ? 'adds' : 'does not add'} a color chip decorator`, () => { + tiptapEditor.commands.setContent(`<p><code>${colorExpression}</code></p>`); + const pluginState = colorDecoratorPlugin.getState(tiptapEditor.state); + + expect(pluginState.children).toHaveLength(decorated ? 3 : 0); + }); + }, + ); +}); diff --git a/spec/frontend/content_editor/extensions/details_content_spec.js b/spec/frontend/content_editor/extensions/details_content_spec.js new file mode 100644 index 00000000000..575f3bf65e4 --- /dev/null +++ b/spec/frontend/content_editor/extensions/details_content_spec.js @@ -0,0 +1,76 @@ +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/details_content', () => { + let tiptapEditor; + let doc; + let p; + let details; + let detailsContent; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Details, DetailsContent] }); + + ({ + builders: { doc, p, details, detailsContent }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, + }, + })); + }); + + describe('shortcut: Enter', () => { + it('splits a details content into two items', () => { + const initialDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + const expectedDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Enter'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('shortcut: Shift-Tab', () => { + it('lifts a details content and creates two separate details items', () => { + const initialDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + const expectedDoc = doc( + details(detailsContent(p('Summary'))), + p('Text content'), + details(detailsContent(p('Text content'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.commands.setTextSelection(20); + tiptapEditor.commands.keyboardShortcut('Shift-Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/details_spec.js b/spec/frontend/content_editor/extensions/details_spec.js new file mode 100644 index 00000000000..cd59943982f --- /dev/null +++ b/spec/frontend/content_editor/extensions/details_spec.js @@ -0,0 +1,92 @@ +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/details', () => { + let tiptapEditor; + let doc; + let p; + let details; + let detailsContent; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Details, DetailsContent] }); + + ({ + builders: { doc, p, details, detailsContent }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, + }, + })); + }); + + describe('setDetails command', () => { + describe('when current block is a paragraph', () => { + it('converts current paragraph into a details block', () => { + const initialDoc = doc(p('Text content')); + const expectedDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when current block is a details block', () => { + it('maintains the same document structure', () => { + const initialDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setDetails(); + + expect(tiptapEditor.getJSON()).toEqual(initialDoc.toJSON()); + }); + }); + }); + + describe('toggleDetails command', () => { + describe('when current block is a paragraph', () => { + it('converts current paragraph into a details block', () => { + const initialDoc = doc(p('Text content')); + const expectedDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.toggleDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when current block is a details block', () => { + it('convert details block into a paragraph', () => { + const initialDoc = doc(details(detailsContent(p('Text content')))); + const expectedDoc = doc(p('Text content')); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.toggleDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + }); + + it.each` + input | insertedNode + ${'<details>'} | ${(...args) => details(detailsContent(p(...args)))} + ${'<details'} | ${(...args) => p(...args)} + ${'details>'} | ${(...args) => p(...args)} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { view } = tiptapEditor; + const { selection } = view.state; + const expectedDoc = doc(insertedNode()); + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/math_inline_spec.js b/spec/frontend/content_editor/extensions/math_inline_spec.js new file mode 100644 index 00000000000..82eb85477de --- /dev/null +++ b/spec/frontend/content_editor/extensions/math_inline_spec.js @@ -0,0 +1,42 @@ +import MathInline from '~/content_editor/extensions/math_inline'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/math_inline', () => { + let tiptapEditor; + let doc; + let p; + let mathInline; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [MathInline] }); + + ({ + builders: { doc, p, mathInline }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { markType: MathInline.name }, + }, + })); + }); + + it.each` + input | insertedNode + ${'$`a^2`$'} | ${() => p(mathInline('a^2'))} + ${'$`a^2`'} | ${() => p('$`a^2`')} + ${'`a^2`$'} | ${() => p('`a^2`$')} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { view } = tiptapEditor; + const expectedDoc = doc(insertedNode()); + + tiptapEditor.chain().setContent(input).setTextSelection(0).run(); + + const { state } = tiptapEditor; + const { selection } = state; + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, input.length + 1, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/table_of_contents_spec.js b/spec/frontend/content_editor/extensions/table_of_contents_spec.js new file mode 100644 index 00000000000..83818899c17 --- /dev/null +++ b/spec/frontend/content_editor/extensions/table_of_contents_spec.js @@ -0,0 +1,35 @@ +import TableOfContents from '~/content_editor/extensions/table_of_contents'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/emoji', () => { + let tiptapEditor; + let builders; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [TableOfContents] }); + ({ builders } = createDocBuilder({ + tiptapEditor, + names: { tableOfContents: { nodeType: TableOfContents.name } }, + })); + }); + + it.each` + input | insertedNode + ${'[[_TOC_]]'} | ${'tableOfContents'} + ${'[TOC]'} | ${'tableOfContents'} + ${'[toc]'} | ${'p'} + ${'TOC'} | ${'p'} + ${'[_TOC_]'} | ${'p'} + ${'[[TOC]]'} | ${'p'} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { doc } = builders; + const { view } = tiptapEditor; + const { selection } = view.state; + const expectedDoc = doc(builders[insertedNode]()); + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js index b3aabfeb145..da895970289 100644 --- a/spec/frontend/content_editor/markdown_processing_examples.js +++ b/spec/frontend/content_editor/markdown_processing_examples.js @@ -1,11 +1,13 @@ import fs from 'fs'; import path from 'path'; import jsYaml from 'js-yaml'; +// eslint-disable-next-line import/no-deprecated import { getJSONFixture } from 'helpers/fixtures'; export const loadMarkdownApiResult = (testName) => { const fixturePathPrefix = `api/markdown/${testName}.json`; + // eslint-disable-next-line import/no-deprecated const fixture = getJSONFixture(fixturePathPrefix); return fixture.body || fixture.html; }; diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 6f2c908c289..33056ab9e4a 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -5,6 +5,8 @@ import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; import Division from '~/content_editor/extensions/division'; import Emoji from '~/content_editor/extensions/emoji'; import Figure from '~/content_editor/extensions/figure'; @@ -45,6 +47,8 @@ const tiptapEditor = createTestEditor({ CodeBlockHighlight, DescriptionItem, DescriptionList, + Details, + DetailsContent, Division, Emoji, Figure, @@ -78,6 +82,8 @@ const { bulletList, code, codeBlock, + details, + detailsContent, division, descriptionItem, descriptionList, @@ -110,6 +116,8 @@ const { bulletList: { nodeType: BulletList.name }, code: { markType: Code.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, division: { nodeType: Division.name }, descriptionItem: { nodeType: DescriptionItem.name }, descriptionList: { nodeType: DescriptionList.name }, @@ -588,6 +596,105 @@ A giant _owl-like_ creature. ); }); + it('correctly renders a simple details/summary', () => { + expect( + serialize( + details( + detailsContent(paragraph('this is the summary')), + detailsContent(paragraph('this content will be hidden')), + ), + ), + ).toBe( + ` +<details> +<summary>this is the summary</summary> +this content will be hidden +</details> + `.trim(), + ); + }); + + it('correctly renders details/summary with styled content', () => { + expect( + serialize( + details( + detailsContent(paragraph('this is the ', bold('summary'))), + detailsContent( + codeBlock( + { language: 'javascript' }, + 'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);', + ), + ), + detailsContent(paragraph('this content will be ', italic('hidden'))), + ), + details(detailsContent(paragraph('summary 2')), detailsContent(paragraph('content 2'))), + ), + ).toBe( + ` +<details> +<summary> + +this is the **summary** + +</summary> + +\`\`\`javascript +var a = 2; +var b = 3; +var c = a + d; + +console.log(c); +\`\`\` + +this content will be _hidden_ + +</details> +<details> +<summary>summary 2</summary> +content 2 +</details> + `.trim(), + ); + }); + + it('correctly renders nested details', () => { + expect( + serialize( + details( + detailsContent(paragraph('dream level 1')), + detailsContent( + details( + detailsContent(paragraph('dream level 2')), + detailsContent( + details( + detailsContent(paragraph('dream level 3')), + detailsContent(paragraph(italic('inception'))), + ), + ), + ), + ), + ), + ), + ).toBe( + ` +<details> +<summary>dream level 1</summary> + +<details> +<summary>dream level 2</summary> + +<details> +<summary>dream level 3</summary> + +_inception_ + +</details> +</details> +</details> + `.trim(), + ); + }); + it('correctly renders div', () => { expect( serialize( |