Commit fd135c0f authored by Enrique Alcantara's avatar Enrique Alcantara

Render attribute definitions in tooltips

Instead of treating attribute definitions as
independent entities, identify to which elements
in the markdown document these definitions are attached.

Then render those attribute definitions as tooltips
in the elements they are attached to
parent d7917641
import { union, mapValues } from 'lodash';
import renderBlockHtml from './renderers/render_html_block';
import renderKramdownList from './renderers/render_kramdown_list';
import renderKramdownText from './renderers/render_kramdown_text';
import renderHeading from './renderers/render_heading';
import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
import renderSoftbreak from './renderers/render_softbreak';
import renderAttributeDefinition from './renderers/render_attribute_definition';
import renderListItem from './renderers/render_list_item';
const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
const htmlBlockRenderers = [renderBlockHtml];
const listRenderers = [renderKramdownList];
const paragraphRenderers = [renderIdentifierParagraph];
const textRenderers = [renderKramdownText, renderIdentifierInstanceText];
const headingRenderers = [renderHeading];
const paragraphRenderers = [renderIdentifierParagraph, renderBlockHtml];
const textRenderers = [renderIdentifierInstanceText, renderAttributeDefinition];
const listItemRenderers = [renderListItem];
const softbreakRenderers = [renderSoftbreak];
const executeRenderer = (renderers, node, context) => {
......@@ -25,7 +27,8 @@ const buildCustomHTMLRenderer = customRenderers => {
...customRenderers,
htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock),
htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline),
list: union(listRenderers, customRenderers?.list),
heading: union(headingRenderers, customRenderers?.heading),
item: union(listItemRenderers, customRenderers?.listItem),
paragraph: union(paragraphRenderers, customRenderers?.paragraph),
text: union(textRenderers, customRenderers?.text),
softbreak: union(softbreakRenderers, customRenderers?.softbreak),
......
......@@ -28,6 +28,7 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
const orderedListItemNode = 'OL LI';
const emphasisNode = 'EM, I';
const strongNode = 'STRONG, B';
const headingNode = 'H1, H2, H3, H4, H5, H6';
return {
TEXT_NODE(node) {
......@@ -63,8 +64,10 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
},
[unorderedListItemNode](node, subContent) {
const baseResult = baseRenderer.convert(node, subContent);
const formatted = baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`);
const { attributeDefinition } = node.dataset;
return baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`);
return attributeDefinition ? `${formatted.trimRight()}\n${attributeDefinition}\n` : formatted;
},
[orderedListItemNode](node, subContent) {
const baseResult = baseRenderer.convert(node, subContent);
......@@ -82,6 +85,12 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
return result.replace(/^[*_]{2}/, strongSyntax).replace(/[*_]{2}$/, strongSyntax);
},
[headingNode](node, subContent) {
const result = baseRenderer.convert(node, subContent);
const { attributeDefinition } = node.dataset;
return attributeDefinition ? `${result.trimRight()}\n${attributeDefinition}\n\n` : result;
},
};
};
......
import { isAttributeDefinition } from './render_utils';
const canRender = ({ literal }) => isAttributeDefinition(literal);
const render = () => ({ type: 'html', content: '<!-- -->' });
export default { canRender, render };
import { renderWithAttributeDefinitions as render, canRender } from './render_utils';
export default { render, canRender };
import { renderUneditableBranch as render } from './render_utils';
const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC';
const canRender = node => {
let targetNode = node;
while (targetNode !== null) {
const { firstChild } = targetNode;
const isLeaf = firstChild === null;
if (isLeaf) {
if (isKramdownTOC(targetNode)) {
return true;
}
break;
}
targetNode = targetNode.firstChild;
}
return false;
};
export default { canRender, render };
import { renderUneditableLeaf as render } from './render_utils';
const kramdownRegex = /(^{:.+}$)/;
const canRender = ({ literal }) => {
return kramdownRegex.test(literal);
};
export default { canRender, render };
import { renderWithAttributeDefinitions as render, canRender } from './render_utils';
export default { render, canRender };
......@@ -4,7 +4,38 @@ import {
buildUneditableCloseToken,
} from './build_uneditable_token';
const attributeDefinitionRegexp = /(^{:.+}$)/;
export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockTokens(origin());
export const renderUneditableBranch = (_, { entering, origin }) =>
entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
export const isAttributeDefinition = text => attributeDefinitionRegexp.test(text);
const findAttributeDefinition = node => {
const literal =
node?.next?.firstChild?.literal || node?.firstChild?.firstChild?.next?.next?.literal; // for headings // for list items;
return isAttributeDefinition(literal) ? literal : null;
};
export const renderWithAttributeDefinitions = (node, context) => {
const attributes = findAttributeDefinition(node);
const origin = context.origin();
if (origin.type === 'openTag' && attributes) {
Object.assign(origin, {
attributes: {
'data-attribute-definition': attributes,
'data-toggle': 'tooltip',
'data-placement': 'left',
title: attributes,
},
});
}
return origin;
};
export const canRender = () => true;
......@@ -5,8 +5,13 @@ describe('Build Custom Renderer Service', () => {
it('should return an object with the default renderer functions when lacking arguments', () => {
expect(buildCustomHTMLRenderer()).toEqual(
expect.objectContaining({
list: expect.any(Function),
htmlBlock: expect.any(Function),
htmlInline: expect.any(Function),
heading: expect.any(Function),
item: expect.any(Function),
paragraph: expect.any(Function),
text: expect.any(Function),
softbreak: expect.any(Function),
}),
);
});
......@@ -20,8 +25,6 @@ describe('Build Custom Renderer Service', () => {
expect(buildCustomHTMLRenderer(customRenderers)).toEqual(
expect.objectContaining({
html: expect.any(Function),
list: expect.any(Function),
text: expect.any(Function),
}),
);
});
......
import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
import { attributeDefinition } from './renderers/mock_data';
describe('HTMLToMarkdownRenderer', () => {
describe('rich_content_editor/services/html_to_markdown_renderer', () => {
let baseRenderer;
let htmlToMarkdownRenderer;
const NODE = { nodeValue: 'mock_node' };
let fakeNode;
beforeEach(() => {
baseRenderer = {
......@@ -12,14 +13,16 @@ describe('HTMLToMarkdownRenderer', () => {
getSpaceControlled: jest.fn(input => `space controlled ${input}`),
convert: jest.fn(),
};
fakeNode = { nodeValue: 'mock_node', dataset: {} };
});
describe('TEXT_NODE visitor', () => {
it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => {
htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
expect(htmlToMarkdownRenderer.TEXT_NODE(NODE)).toBe(
`space controlled trimmed space collapsed ${NODE.nodeValue}`,
expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe(
`space controlled trimmed space collapsed ${fakeNode.nodeValue}`,
);
});
});
......@@ -43,8 +46,8 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(list);
expect(htmlToMarkdownRenderer['LI OL, LI UL'](NODE, list)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list);
expect(htmlToMarkdownRenderer['LI OL, LI UL'](fakeNode, list)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list);
});
});
......@@ -62,10 +65,21 @@ describe('HTMLToMarkdownRenderer', () => {
});
baseRenderer.convert.mockReturnValueOnce(listItem);
expect(htmlToMarkdownRenderer['UL LI'](NODE, listItem)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, listItem);
expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem);
},
);
it('detects attribute definitions and attaches them to the list item', () => {
const listItem = '- list item';
const result = `${listItem}\n${attributeDefinition}\n`;
fakeNode.dataset.attributeDefinition = attributeDefinition;
htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`);
expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result);
});
});
describe('OL LI visitor', () => {
......@@ -85,8 +99,8 @@ describe('HTMLToMarkdownRenderer', () => {
});
baseRenderer.convert.mockReturnValueOnce(listItem);
expect(htmlToMarkdownRenderer['OL LI'](NODE, subContent)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, subContent);
expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent);
},
);
});
......@@ -105,8 +119,8 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(input);
expect(htmlToMarkdownRenderer['STRONG, B'](NODE, input)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input);
expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input);
},
);
});
......@@ -125,9 +139,22 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(input);
expect(htmlToMarkdownRenderer['EM, I'](NODE, input)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input);
expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input);
},
);
});
describe('H1, H2, H3, H4, H5, H6 visitor', () => {
it('detects attribute definitions and attaches them to the heading', () => {
const heading = 'heading text';
const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`;
fakeNode.dataset.attributeDefinition = attributeDefinition;
htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`);
expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result);
});
});
});
......@@ -56,3 +56,5 @@ export const uneditableBlockTokens = [
},
buildMockUneditableCloseToken('div'),
];
export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}';
import renderer from '~/vue_shared/components/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: '<!-- -->' });
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_heading';
import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
describe('rich_content_editor/renderers/render_heading', () => {
it('canRender delegates to renderUtils.canRender', () => {
expect(renderer.canRender).toBe(renderUtils.canRender);
});
it('render delegates to renderUtils.renderWithAttributeDefinitions', () => {
expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions);
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list';
import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import { buildMockTextNode } from './mock_data';
const buildMockListNode = literal => {
return {
firstChild: {
firstChild: {
firstChild: buildMockTextNode(literal),
type: 'paragraph',
},
type: 'item',
},
type: 'list',
};
};
const normalListNode = buildMockListNode('Just another bullet point');
const kramdownListNode = buildMockListNode('TOC');
describe('Render Kramdown List renderer', () => {
describe('canRender', () => {
it('should return true when the argument is a special kramdown TOC ordered/unordered list', () => {
expect(renderer.canRender(kramdownListNode)).toBe(true);
});
it('should return false when the argument is a normal ordered/unordered list', () => {
expect(renderer.canRender(normalListNode)).toBe(false);
});
});
describe('render', () => {
it('should delegate rendering to the renderUneditableBranch util', () => {
expect(renderer.render).toBe(renderUneditableBranch);
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text';
import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import { buildMockTextNode, normalTextNode } from './mock_data';
const kramdownTextNode = buildMockTextNode('{:toc}');
describe('Render Kramdown Text renderer', () => {
describe('canRender', () => {
it('should return true when the argument `literal` has kramdown syntax', () => {
expect(renderer.canRender(kramdownTextNode)).toBe(true);
});
it('should return false when the argument `literal` lacks kramdown syntax', () => {
expect(renderer.canRender(normalTextNode)).toBe(false);
});
});
describe('render', () => {
it('should delegate rendering to the renderUneditableLeaf util', () => {
expect(renderer.render).toBe(renderUneditableLeaf);
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_list_item';
import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
describe('rich_content_editor/renderers/render_list_item', () => {
it('canRender delegates to renderUtils.canRender', () => {
expect(renderer.canRender).toBe(renderUtils.canRender);
});
it('render delegates to renderUtils.renderWithAttributeDefinitions', () => {
expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions);
});
});
import {
renderUneditableLeaf,
renderUneditableBranch,
renderWithAttributeDefinitions,
} from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import {
......@@ -8,9 +9,9 @@ import {
buildUneditableOpenTokens,
} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import { originToken, uneditableCloseToken } from './mock_data';
import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data';
describe('Render utils', () => {
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) };
......@@ -41,4 +42,65 @@ describe('Render utils', () => {
expect(result).toStrictEqual(uneditableCloseToken);
});
});
describe('renderWithAttributeDefinitions', () => {
let openTagToken;
let closeTagToken;
let node;
const attributes = {
'data-attribute-definition': attributeDefinition,
title: attributeDefinition,
'data-toggle': 'tooltip',
'data-placement': 'left',
};
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);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment