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