diff options
Diffstat (limited to 'spec/frontend/static_site_editor/rich_content_editor/services/renderers')
12 files changed, 556 insertions, 0 deletions
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js new file mode 100644 index 00000000000..ef3ff052cb2 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js @@ -0,0 +1,88 @@ +import { + buildTextToken, + buildUneditableOpenTokens, + buildUneditableCloseToken, + buildUneditableCloseTokens, + buildUneditableBlockTokens, + buildUneditableInlineTokens, + buildUneditableHtmlAsTextTokens, +} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; + +import { + originInlineToken, + originToken, + uneditableOpenTokens, + uneditableCloseToken, + uneditableCloseTokens, + uneditableBlockTokens, + uneditableInlineTokens, + uneditableTokens, +} from './mock_data'; + +describe('Build Uneditable Token renderer helper', () => { + describe('buildTextToken', () => { + it('returns an object literal representing a text token', () => { + const text = originToken.content; + expect(buildTextToken(text)).toStrictEqual(originToken); + }); + }); + + describe('buildUneditableOpenTokens', () => { + it('returns a 2-item array of tokens with the originToken appended to an open token', () => { + const result = buildUneditableOpenTokens(originToken); + + expect(result).toHaveLength(2); + expect(result).toStrictEqual(uneditableOpenTokens); + }); + }); + + describe('buildUneditableCloseToken', () => { + it('returns an object literal representing the uneditable close token', () => { + expect(buildUneditableCloseToken()).toStrictEqual(uneditableCloseToken); + }); + }); + + describe('buildUneditableCloseTokens', () => { + it('returns a 2-item array of tokens with the originToken prepended to a close token', () => { + const result = buildUneditableCloseTokens(originToken); + + expect(result).toHaveLength(2); + expect(result).toStrictEqual(uneditableCloseTokens); + }); + }); + + describe('buildUneditableBlockTokens', () => { + it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => { + const result = buildUneditableBlockTokens(originToken); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableTokens); + }); + }); + + describe('buildUneditableInlineTokens', () => { + it('returns a 3-item array of tokens with the originInlineToken wrapped in the middle of inline tokens', () => { + const result = buildUneditableInlineTokens(originInlineToken); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableInlineTokens); + }); + }); + + describe('buildUneditableHtmlAsTextTokens', () => { + it('returns a 3-item array of tokens with the htmlBlockNode wrapped as a text token in the middle of block tokens', () => { + const htmlBlockNode = { + type: 'htmlBlock', + literal: '<div data-tomark-pass ><h1>Some header</h1><p>Some paragraph</p></div>', + }; + const result = buildUneditableHtmlAsTextTokens(htmlBlockNode); + const { type, content } = result[1]; + + expect(type).toBe('text'); + expect(content).not.toMatch(/ data-tomark-pass /); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableBlockTokens); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js new file mode 100644 index 00000000000..407072fb596 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js @@ -0,0 +1,54 @@ +// Node spec helpers + +export const buildMockTextNode = (literal) => ({ literal, type: 'text' }); + +export const normalTextNode = buildMockTextNode('This is just normal text.'); + +// Token spec helpers + +const buildMockUneditableOpenToken = (type) => { + return { + type: 'openTag', + tagName: type, + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }; +}; + +const buildMockTextToken = (content) => { + return { + type: 'text', + tagName: null, + content, + }; +}; + +const buildMockUneditableCloseToken = (type) => ({ type: 'closeTag', tagName: type }); + +export const originToken = buildMockTextToken('{:.no_toc .hidden-md .hidden-lg}'); +const uneditableOpenToken = buildMockUneditableOpenToken('div'); +export const uneditableOpenTokens = [uneditableOpenToken, originToken]; +export const uneditableCloseToken = buildMockUneditableCloseToken('div'); +export const uneditableCloseTokens = [originToken, uneditableCloseToken]; +export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken]; + +export const originInlineToken = { + type: 'text', + content: '<i>Inline</i> content', +}; + +export const uneditableInlineTokens = [ + buildMockUneditableOpenToken('a'), + originInlineToken, + buildMockUneditableCloseToken('a'), +]; + +export const uneditableBlockTokens = [ + uneditableOpenToken, + buildMockTextToken('<div><h1>Some header</h1><p>Some paragraph</p></div>'), + uneditableCloseToken, +]; + +export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}'; diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js new file mode 100644 index 00000000000..6d96dd3bbca --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js @@ -0,0 +1,25 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition'; +import { attributeDefinition } from './mock_data'; + +describe('rich_content_editor/renderers/render_attribute_definition', () => { + describe('canRender', () => { + it.each` + input | result + ${{ literal: attributeDefinition }} | ${true} + ${{ literal: `FOO${attributeDefinition}` }} | ${false} + ${{ literal: `${attributeDefinition}BAR` }} | ${false} + ${{ literal: 'foobar' }} | ${false} + `('returns $result when input is $input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); + }); + }); + + describe('render', () => { + it('returns an empty HTML comment', () => { + expect(renderer.render()).toEqual({ + type: 'html', + content: '<!-- sse-attribute-definition -->', + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js new file mode 100644 index 00000000000..29e2b5b3b16 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js @@ -0,0 +1,24 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text'; +import { renderUneditableLeaf } from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const embeddedRubyTextNode = buildMockTextNode('<%= partial("some/path") %>'); + +describe('Render Embedded Ruby Text renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has embedded ruby syntax', () => { + expect(renderer.canRender(embeddedRubyTextNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks embedded ruby syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + it('should delegate rendering to the renderUneditableLeaf util', () => { + expect(renderer.render).toBe(renderUneditableLeaf); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js new file mode 100644 index 00000000000..0fda847b688 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js @@ -0,0 +1,33 @@ +import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline'; + +import { normalTextNode } from './mock_data'; + +const fontAwesomeInlineHtmlNode = { + firstChild: null, + literal: '<i class="far fa-paper-plane" id="biz-tech-icons">', + type: 'html', +}; + +describe('Render Font Awesome Inline HTML renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has font awesome inline html syntax', () => { + expect(renderer.canRender(fontAwesomeInlineHtmlNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks font awesome inline html syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + it('should return uneditable inline tokens', () => { + const token = { type: 'text', tagName: null, content: fontAwesomeInlineHtmlNode.literal }; + const context = { origin: () => token }; + + expect(renderer.render(fontAwesomeInlineHtmlNode, context)).toStrictEqual( + buildUneditableInlineTokens(token), + ); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js new file mode 100644 index 00000000000..cf4a90885df --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js @@ -0,0 +1,12 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_heading'; +import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; + +describe('rich_content_editor/renderers/render_heading', () => { + it('canRender delegates to renderUtils.willAlwaysRender', () => { + expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); + }); + + it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { + expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js new file mode 100644 index 00000000000..9c937ac22f4 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js @@ -0,0 +1,37 @@ +import { buildUneditableHtmlAsTextTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_html_block'; + +describe('rich_content_editor/services/renderers/render_html_block', () => { + const htmlBlockNode = { + literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', + type: 'htmlBlock', + }; + + describe('canRender', () => { + it.each` + input | result + ${htmlBlockNode} | ${true} + ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true} + ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false} + ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false} + `('returns $result when input=$input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); + }); + }); + + describe('render', () => { + const htmlBlockNodeToMark = { + firstChild: null, + literal: '<div data-to-mark ></div>', + type: 'htmlBlock', + }; + + it.each` + node + ${htmlBlockNode} + ${htmlBlockNodeToMark} + `('should return uneditable tokens wrapping the $node as a token', ({ node }) => { + expect(renderer.render(node)).toStrictEqual(buildUneditableHtmlAsTextTokens(node)); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js new file mode 100644 index 00000000000..15fb2c3a430 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js @@ -0,0 +1,55 @@ +import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const mockTextStart = 'Majority example '; +const mockTextMiddle = '[environment terraform plans][terraform]'; +const mockTextEnd = '.'; +const identifierInstanceStartTextNode = buildMockTextNode(mockTextStart); +const identifierInstanceEndTextNode = buildMockTextNode(mockTextEnd); + +describe('Render Identifier Instance Text renderer', () => { + describe('canRender', () => { + it.each` + node | target + ${normalTextNode} | ${false} + ${identifierInstanceStartTextNode} | ${false} + ${identifierInstanceEndTextNode} | ${false} + ${buildMockTextNode(mockTextMiddle)} | ${true} + ${buildMockTextNode('Minority example [environment terraform plans][]')} | ${true} + ${buildMockTextNode('Minority example [environment terraform plans]')} | ${true} + `( + 'should return $target when the $node validates against identifier instance syntax', + ({ node, target }) => { + expect(renderer.canRender(node)).toBe(target); + }, + ); + }); + + describe('render', () => { + it.each` + start | middle | end + ${mockTextStart} | ${mockTextMiddle} | ${mockTextEnd} + ${mockTextStart} | ${'[environment terraform plans][]'} | ${mockTextEnd} + ${mockTextStart} | ${'[environment terraform plans]'} | ${mockTextEnd} + `( + 'should return inline editable, uneditable, and editable tokens in sequence', + ({ start, middle, end }) => { + const buildMockTextToken = (content) => ({ type: 'text', tagName: null, content }); + + const startToken = buildMockTextToken(start); + const middleToken = buildMockTextToken(middle); + const endToken = buildMockTextToken(end); + + const content = `${start}${middle}${end}`; + const contentToken = buildMockTextToken(content); + const contentNode = buildMockTextNode(content); + const context = { origin: jest.fn().mockReturnValueOnce(contentToken) }; + expect(renderer.render(contentNode, context)).toStrictEqual( + [startToken, buildUneditableInlineTokens(middleToken), endToken].flat(), + ); + }, + ); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js new file mode 100644 index 00000000000..6a2b89a8dcf --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -0,0 +1,84 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph'; + +import { buildMockTextNode } from './mock_data'; + +const buildMockParagraphNode = (literal) => { + return { + firstChild: buildMockTextNode(literal), + type: 'paragraph', + }; +}; + +const normalParagraphNode = buildMockParagraphNode( + 'This is just normal paragraph. It has multiple sentences.', +); +const identifierParagraphNode = buildMockParagraphNode( + `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, +); + +describe('rich_content_editor/renderers_render_identifier_paragraph', () => { + describe('canRender', () => { + it.each` + node | paragraph | target + ${identifierParagraphNode} | ${'[Some text]: https://link.com'} | ${true} + ${normalParagraphNode} | ${'Normal non-identifier text. Another sentence.'} | ${false} + `( + 'should return $target when the $node matches $paragraph syntax', + ({ node, paragraph, target }) => { + const context = { + entering: true, + getChildrenText: jest.fn().mockReturnValueOnce(paragraph), + }; + + expect(renderer.canRender(node, context)).toBe(target); + }, + ); + }); + + describe('render', () => { + let context; + let result; + + beforeEach(() => { + const node = { + firstChild: { + type: 'text', + literal: '[Some text]: https://link.com', + next: { + type: 'linebreak', + next: { + type: 'text', + literal: '[identifier]: http://example1.com "title"', + }, + }, + }, + }; + context = { skipChildren: jest.fn() }; + result = renderer.render(node, context); + }); + + it('renders the reference definitions as a code block', () => { + expect(result).toEqual([ + { + type: 'openTag', + tagName: 'pre', + classNames: ['code-block', 'language-markdown'], + attributes: { + 'data-sse-reference-definition': true, + }, + }, + { type: 'openTag', tagName: 'code' }, + { + type: 'text', + content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"', + }, + { type: 'closeTag', tagName: 'code' }, + { type: 'closeTag', tagName: 'pre' }, + ]); + }); + + it('skips the reference definition node children from rendering', () => { + expect(context.skipChildren).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js new file mode 100644 index 00000000000..1e8e62b9dd2 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js @@ -0,0 +1,12 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_list_item'; +import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; + +describe('rich_content_editor/renderers/render_list_item', () => { + it('canRender delegates to renderUtils.willAlwaysRender', () => { + expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); + }); + + it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { + expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js new file mode 100644 index 00000000000..d8d1e6ff295 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js @@ -0,0 +1,23 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_softbreak'; + +describe('Render softbreak renderer', () => { + describe('canRender', () => { + it.each` + node | parentType | result + ${{ parent: { type: 'emph' } }} | ${'emph'} | ${true} + ${{ parent: { type: 'strong' } }} | ${'strong'} | ${true} + ${{ parent: { type: 'paragraph' } }} | ${'paragraph'} | ${false} + `('returns $result when node parent type is $parentType ', ({ node, result }) => { + expect(renderer.canRender(node)).toBe(result); + }); + }); + + describe('render', () => { + it('returns text node with a break line', () => { + expect(renderer.render()).toEqual({ + type: 'text', + content: ' ', + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js new file mode 100644 index 00000000000..49b8936a9f7 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js @@ -0,0 +1,109 @@ +import { + buildUneditableBlockTokens, + buildUneditableOpenTokens, +} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import { + renderUneditableLeaf, + renderUneditableBranch, + renderWithAttributeDefinitions, + willAlwaysRender, +} from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; + +import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data'; + +describe('rich_content_editor/renderers/render_utils', () => { + describe('renderUneditableLeaf', () => { + it('should return uneditable block tokens around an origin token', () => { + const context = { origin: jest.fn().mockReturnValueOnce(originToken) }; + const result = renderUneditableLeaf({}, context); + + expect(result).toStrictEqual(buildUneditableBlockTokens(originToken)); + }); + }); + + describe('renderUneditableBranch', () => { + let origin; + + beforeEach(() => { + origin = jest.fn().mockReturnValueOnce(originToken); + }); + + it('should return uneditable block open token followed by the origin token when entering', () => { + const context = { entering: true, origin }; + const result = renderUneditableBranch({}, context); + + expect(result).toStrictEqual(buildUneditableOpenTokens(originToken)); + }); + + it('should return uneditable block closing token when exiting', () => { + const context = { entering: false, origin }; + const result = renderUneditableBranch({}, context); + + expect(result).toStrictEqual(uneditableCloseToken); + }); + }); + + describe('willAlwaysRender', () => { + it('always returns true', () => { + expect(willAlwaysRender()).toBe(true); + }); + }); + + describe('renderWithAttributeDefinitions', () => { + let openTagToken; + let closeTagToken; + let node; + const attributes = { + 'data-attribute-definition': attributeDefinition, + }; + + beforeEach(() => { + openTagToken = { type: 'openTag' }; + closeTagToken = { type: 'closeTag' }; + node = { + next: { + firstChild: { + literal: attributeDefinition, + }, + }, + }; + }); + + describe('when token type is openTag', () => { + it('attaches attributes when attributes exist in the node’s next sibling', () => { + const context = { origin: () => openTagToken }; + + expect(renderWithAttributeDefinitions(node, context)).toEqual({ + ...openTagToken, + attributes, + }); + }); + + it('attaches attributes when attributes exist in the node’s children', () => { + const context = { origin: () => openTagToken }; + node = { + firstChild: { + firstChild: { + next: { + next: { + literal: attributeDefinition, + }, + }, + }, + }, + }; + + expect(renderWithAttributeDefinitions(node, context)).toEqual({ + ...openTagToken, + attributes, + }); + }); + }); + + it('does not attach attributes when token type is "closeTag"', () => { + const context = { origin: () => closeTagToken }; + + expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken); + }); + }); +}); |