From eb5a1cd19fcffeca2704678bce12d5b860bb1ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Alc=C3=A1ntara?= <ealcantara@gitlab.com> Date: Tue, 18 Aug 2020 14:33:11 +0000 Subject: [PATCH] Initialize editor options in a service function Instead of initializing the Toast UI editor options (configuration) in a constants file, create a service function to initialize the options and call that function when the RichContentEditor component is created --- .../rich_content_editor/constants.js | 9 +-- .../rich_content_editor.vue | 17 ++--- .../services/build_custom_renderer.js | 68 ++++--------------- .../services/editor_service.js | 10 +++ .../__mocks__/@toast-ui/vue-editor/index.js | 11 +++ .../editor_service_spec.js | 24 +++++++ .../rich_content_editor_spec.js | 51 ++++++++++---- 7 files changed, 102 insertions(+), 88 deletions(-) diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js index dd1da84700170..c08659919fa5f 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js @@ -1,13 +1,11 @@ import { __ } from '~/locale'; -import { generateToolbarItem } from './services/editor_service'; -import buildCustomHTMLRenderer from './services/build_custom_renderer'; export const CUSTOM_EVENTS = { openAddImageModal: 'gl_openAddImageModal', }; /* eslint-disable @gitlab/require-i18n-strings */ -const TOOLBAR_ITEM_CONFIGS = [ +export const TOOLBAR_ITEM_CONFIGS = [ { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') }, { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') }, { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') }, @@ -30,11 +28,6 @@ const TOOLBAR_ITEM_CONFIGS = [ { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') }, ]; -export const EDITOR_OPTIONS = { - toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)), - customHTMLRenderer: buildCustomHTMLRenderer(), -}; - export const EDITOR_TYPES = { markdown: 'markdown', wysiwyg: 'wysiwyg', diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index baeb98bec7564..d96fe46522eb1 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -3,16 +3,11 @@ import 'codemirror/lib/codemirror.css'; import '@toast-ui/editor/dist/toastui-editor.css'; import AddImageModal from './modals/add_image/add_image_modal.vue'; -import { - EDITOR_OPTIONS, - EDITOR_TYPES, - EDITOR_HEIGHT, - EDITOR_PREVIEW_STYLE, - CUSTOM_EVENTS, -} from './constants'; +import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants'; import { registerHTMLToMarkdownRenderer, + getEditorOptions, addCustomEventListener, removeCustomEventListener, addImage, @@ -35,7 +30,7 @@ export default { options: { type: Object, required: false, - default: () => EDITOR_OPTIONS, + default: () => null, }, initialEditType: { type: String, @@ -65,13 +60,13 @@ export default { }; }, computed: { - editorOptions() { - return { ...EDITOR_OPTIONS, ...this.options }; - }, editorInstance() { return this.$refs.editor; }, }, + created() { + this.editorOptions = getEditorOptions(this.options); + }, beforeDestroy() { this.removeListeners(); }, diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js index b210ada412d15..a9c5d442f62e6 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js @@ -1,3 +1,4 @@ +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'; @@ -19,63 +20,20 @@ const executeRenderer = (renderers, node, context) => { return availableRenderer ? availableRenderer.render(node, context) : context.origin(); }; -const buildCustomRendererFunctions = (customRenderers, defaults) => { - const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]); - const customEntries = customTypes.map(type => { - const fn = (node, context) => executeRenderer(customRenderers[type], node, context); - return [type, fn]; - }); - - return Object.fromEntries(customEntries); -}; - -const buildCustomHTMLRenderer = ( - customRenderers = { - htmlBlock: [], - htmlInline: [], - list: [], - paragraph: [], - text: [], - softbreak: [], - }, -) => { - const defaults = { - htmlBlock(node, context) { - const allHtmlBlockRenderers = [...customRenderers.htmlBlock, ...htmlBlockRenderers]; - - return executeRenderer(allHtmlBlockRenderers, node, context); - }, - htmlInline(node, context) { - const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers]; - - return executeRenderer(allHtmlInlineRenderers, node, context); - }, - list(node, context) { - const allListRenderers = [...customRenderers.list, ...listRenderers]; - - return executeRenderer(allListRenderers, node, context); - }, - paragraph(node, context) { - const allParagraphRenderers = [...customRenderers.paragraph, ...paragraphRenderers]; - - return executeRenderer(allParagraphRenderers, node, context); - }, - text(node, context) { - const allTextRenderers = [...customRenderers.text, ...textRenderers]; - - return executeRenderer(allTextRenderers, node, context); - }, - softbreak(node, context) { - const allSoftbreakRenderers = [...customRenderers.softbreak, ...softbreakRenderers]; - - return executeRenderer(allSoftbreakRenderers, node, context); - }, +const buildCustomHTMLRenderer = customRenderers => { + const renderersByType = { + ...customRenderers, + htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock), + htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline), + list: union(listRenderers, customRenderers?.list), + paragraph: union(paragraphRenderers, customRenderers?.paragraph), + text: union(textRenderers, customRenderers?.text), + softbreak: union(softbreakRenderers, customRenderers?.softbreak), }; - return { - ...buildCustomRendererFunctions(customRenderers, defaults), - ...defaults, - }; + return mapValues(renderersByType, renderers => { + return (node, context) => executeRenderer(renderers, node, context); + }); }; export default buildCustomHTMLRenderer; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index 6436dcaae646b..51ba033dff0f7 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -1,6 +1,9 @@ import Vue from 'vue'; +import { defaults } from 'lodash'; import ToolbarItem from '../toolbar_item.vue'; import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; +import buildCustomHTMLRenderer from './build_custom_renderer'; +import { TOOLBAR_ITEM_CONFIGS } from '../constants'; const buildWrapper = propsData => { const instance = new Vue({ @@ -54,3 +57,10 @@ export const registerHTMLToMarkdownRenderer = editorApi => { renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)), }); }; + +export const getEditorOptions = externalOptions => { + return defaults({ + customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers), + toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)), + }); +}; diff --git a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js index 726ed0fa03017..9fee8e18d266f 100644 --- a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js +++ b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js @@ -17,6 +17,17 @@ export const Editor = { type: String, }, }, + created() { + const mockEditorApi = { + eventManager: { + addEventType: jest.fn(), + listen: jest.fn(), + removeEventHandler: jest.fn(), + }, + }; + + this.$emit('load', mockEditorApi); + }, render(h) { return h('div'); }, diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index 78f27c9948b24..16f60b5ff2154 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -5,10 +5,13 @@ import { registerHTMLToMarkdownRenderer, addImage, getMarkdown, + getEditorOptions, } from '~/vue_shared/components/rich_content_editor/services/editor_service'; import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; +import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); +jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer'); describe('Editor Service', () => { let mockInstance; @@ -120,4 +123,25 @@ describe('Editor Service', () => { expect(mockInstance.toMarkOptions.renderer).toBe(extendedRenderer); }); }); + + describe('getEditorOptions', () => { + const externalOptions = { + customRenderers: {}, + }; + const renderer = {}; + + beforeEach(() => { + buildCustomRenderer.mockReturnValueOnce(renderer); + }); + + it('generates a configuration object with a custom HTML renderer and toolbarItems', () => { + expect(getEditorOptions()).toHaveProp('customHTMLRenderer', renderer); + expect(getEditorOptions()).toHaveProp('toolbarItems'); + }); + + it('passes external renderers to the buildCustomRenderers function', () => { + getEditorOptions(externalOptions); + expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index b6ff6aa767c8e..3d54db7fe5cbf 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; import { - EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, @@ -14,6 +13,7 @@ import { removeCustomEventListener, addImage, registerHTMLToMarkdownRenderer, + getEditorOptions, } from '~/vue_shared/components/rich_content_editor/services/editor_service'; jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', () => ({ @@ -22,6 +22,7 @@ jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', removeCustomEventListener: jest.fn(), addImage: jest.fn(), registerHTMLToMarkdownRenderer: jest.fn(), + getEditorOptions: jest.fn(), })); describe('Rich Content Editor', () => { @@ -32,13 +33,25 @@ describe('Rich Content Editor', () => { const findEditor = () => wrapper.find({ ref: 'editor' }); const findAddImageModal = () => wrapper.find(AddImageModal); - beforeEach(() => { + const buildWrapper = () => { wrapper = shallowMount(RichContentEditor, { propsData: { content, imageRoot }, }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); describe('when content is loaded', () => { + const editorOptions = {}; + + beforeEach(() => { + getEditorOptions.mockReturnValueOnce(editorOptions); + buildWrapper(); + }); + it('renders an editor', () => { expect(findEditor().exists()).toBe(true); }); @@ -47,8 +60,8 @@ describe('Rich Content Editor', () => { expect(findEditor().props().initialValue).toBe(content); }); - it('provides the correct editor options', () => { - expect(findEditor().props().options).toEqual(EDITOR_OPTIONS); + it('provides options generated by the getEditorOptions service', () => { + expect(findEditor().props().options).toBe(editorOptions); }); it('has the correct preview style', () => { @@ -65,6 +78,10 @@ describe('Rich Content Editor', () => { }); describe('when content is changed', () => { + beforeEach(() => { + buildWrapper(); + }); + it('emits an input event with the changed content', () => { const changedMarkdown = '## Changed Markdown'; const getMarkdownMock = jest.fn().mockReturnValueOnce(changedMarkdown); @@ -77,6 +94,10 @@ describe('Rich Content Editor', () => { }); describe('when content is reset', () => { + beforeEach(() => { + buildWrapper(); + }); + it('should reset the content via setMarkdown', () => { const newContent = 'Just the body content excluding the front matter for example'; const mockInstance = { invoke: jest.fn() }; @@ -89,35 +110,33 @@ describe('Rich Content Editor', () => { }); describe('when editor is loaded', () => { - let mockEditorApi; - beforeEach(() => { - mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; - findEditor().vm.$emit('load', mockEditorApi); + buildWrapper(); }); it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { expect(addCustomEventListener).toHaveBeenCalledWith( - mockEditorApi, + wrapper.vm.editorApi, CUSTOM_EVENTS.openAddImageModal, wrapper.vm.onOpenAddImageModal, ); }); it('registers HTML to markdown renderer', () => { - expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(mockEditorApi); + expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); }); }); describe('when editor is destroyed', () => { - it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { - const mockEditorApi = { eventManager: { removeEventHandler: jest.fn() } }; + beforeEach(() => { + buildWrapper(); + }); - wrapper.vm.editorApi = mockEditorApi; + it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { wrapper.vm.$destroy(); expect(removeCustomEventListener).toHaveBeenCalledWith( - mockEditorApi, + wrapper.vm.editorApi, CUSTOM_EVENTS.openAddImageModal, wrapper.vm.onOpenAddImageModal, ); @@ -125,6 +144,10 @@ describe('Rich Content Editor', () => { }); describe('add image modal', () => { + beforeEach(() => { + buildWrapper(); + }); + it('renders an addImageModal component', () => { expect(findAddImageModal().exists()).toBe(true); }); -- GitLab