From 90d5ac4ffc0f456f7242bbee529b6a7ad4d08c87 Mon Sep 17 00:00:00 2001 From: Jhon Vente <134975835+johnvente@users.noreply.github.com> Date: Fri, 16 Feb 2024 14:02:34 -0500 Subject: [PATCH] feat: TinyMCE plugin insert iframe (#427) --- src/editors/data/constants/tinyMCE.js | 2 + .../customTinyMcePlugins/embedIframePlugin.js | 205 +++++++++ .../embedIframePlugin.test.js | 408 ++++++++++++++++++ .../sharedComponents/TinyMceWidget/index.jsx | 1 + .../TinyMceWidget/pluginConfig.js | 3 +- 5 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.js create mode 100644 src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.test.js diff --git a/src/editors/data/constants/tinyMCE.js b/src/editors/data/constants/tinyMCE.js index f98333580..f7edbe273 100644 --- a/src/editors/data/constants/tinyMCE.js +++ b/src/editors/data/constants/tinyMCE.js @@ -52,6 +52,7 @@ export const buttons = StrictDict({ undo: 'undo', underline: 'underline', a11ycheck: 'a11ycheck', + embediframe: 'embediframe', }); export const plugins = listKeyStore([ @@ -69,6 +70,7 @@ export const plugins = listKeyStore([ 'quickbars', 'a11ychecker', 'powerpaste', + 'embediframe', ]); export const textToSpeechIcon = ''; diff --git a/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.js b/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.js new file mode 100644 index 000000000..04057ca3a --- /dev/null +++ b/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.js @@ -0,0 +1,205 @@ +function tinyMCEEmbedIframePlugin(editor) { + function openInsertIframeModal() { + const defaultConfig = { + title: 'Insert iframe', + body: { + type: 'tabpanel', + tabs: [ + { + title: 'General', + items: [ + { + type: 'input', + name: 'source', + label: 'Source URL', + multiline: false, + autofocus: true, + required: true, + }, + { + type: 'selectbox', + name: 'sizeType', + label: 'Size', + items: [ + { text: 'Inline Value', value: 'inline' }, + { text: 'Big embed', value: 'big' }, + { text: 'Small embed', value: 'small' }, + ], + }, + + { + type: 'sizeinput', + name: 'size', + label: 'Dimensions', + }, + ], + }, + { + title: 'Advanced', + items: [ + { + type: 'input', + name: 'name', + label: 'Name', + value: '', + }, + { + type: 'input', + name: 'title', + label: 'Title', + value: '', + }, + { + type: 'input', + name: 'longDescriptionURL', + label: 'Long description URL', + value: '', + }, + { + type: 'checkbox', + name: 'border', + label: 'Show iframe border', + text: 'Border', + checked: false, + }, + { + type: 'checkbox', + name: 'scrollbar', + label: 'Enable scrollbar', + text: 'Scrollbar', + checked: false, + }, + ], + }, + ], + }, + buttons: [ + { + type: 'cancel', + name: 'cancel', + text: 'Cancel', + }, + { + type: 'submit', + name: 'save', + text: 'Save', + primary: true, + }, + ], + onChange(api, field) { + const { name } = field; + const data = api.getData(); + const { sizeType, ...fields } = data; + const isSizeTypeFiled = name === 'sizeType'; + const hasCustomSize = sizeType === 'inline'; + + if (!hasCustomSize && isSizeTypeFiled) { + const { + body: { + tabs: [generalTab], + }, + } = defaultConfig; + + generalTab.items = generalTab.items.filter( + (item) => item.type !== 'sizeinput', + ); + + defaultConfig.initialData = { ...fields, sizeType }; + api.redial(defaultConfig); + } + + if (hasCustomSize && isSizeTypeFiled) { + const { + body: { + tabs: [generalTab], + }, + } = defaultConfig; + + const hasSizeInput = generalTab.items.some((item) => item.name === 'size'); + + if (!hasSizeInput) { + generalTab.items = [ + ...generalTab.items, + { + type: 'sizeinput', + name: 'size', + label: 'Dimensions', + }, + ]; + } + + defaultConfig.initialData = { ...fields, sizeType }; + api.redial(defaultConfig); + } + }, + onSubmit(api) { + const data = api.getData(); + const sizeTypes = { + small: { + height: '100px', + width: '100px', + }, + big: { + height: '800px', + width: '800px', + }, + }; + if (data.source) { + const { + size, sizeType, name, title, longDescriptionURL, border, scrollbar, + } = data; + const { width, height } = sizeTypes[sizeType] || { width: size.width, height: size.height }; + + const pxRegex = /^\d+px$/; + const widthFormat = pxRegex.test(width) ? width : '300px'; + const heightFormat = pxRegex.test(height) ? height : '300px'; + const hasScroll = scrollbar ? 'yes' : 'no'; + + let iframeCode = `'; + + iframeCode = `
'; + + editor.insertContent(iframeCode); + } + + api.close(); + }, + }; + + editor.windowManager.open(defaultConfig); + } + + // Register the button + editor.ui.registry.addButton('embediframe', { + text: 'Embed iframe', + onAction: openInsertIframeModal, + }); +} + +((tinymce) => { + if (tinymce) { + tinymce.PluginManager.add('embediframe', tinyMCEEmbedIframePlugin); + } +})(window.tinymce); + +export default tinyMCEEmbedIframePlugin; diff --git a/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.test.js b/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.test.js new file mode 100644 index 000000000..521df4819 --- /dev/null +++ b/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.test.js @@ -0,0 +1,408 @@ +import tinyMCEEmbedIframePlugin from './embedIframePlugin'; + +const editorMock = { + windowManager: { + open: jest.fn(), + }, + insertContent: jest.fn(), + ui: { + registry: { + addButton: jest.fn(), + }, + }, +}; + +describe('TinyMCE Embed IFrame Plugin', () => { + const pluginConfig = { + title: 'Insert iframe', + body: { + type: 'tabpanel', + tabs: [ + { + title: 'General', + items: [ + { + type: 'input', + name: 'source', + label: 'Source URL', + multiline: false, + autofocus: true, + required: true, + }, + { + type: 'selectbox', + name: 'sizeType', + label: 'Size', + items: [ + { text: 'Inline Value', value: 'inline' }, + { text: 'Big embed', value: 'big' }, + { text: 'Small embed', value: 'small' }, + ], + }, + { + type: 'sizeinput', + name: 'size', + label: 'Dimensions', + }, + ], + }, + { + title: 'Advanced', + items: [ + { + type: 'input', + name: 'name', + label: 'Name', + value: '', + }, + { + type: 'input', + name: 'title', + label: 'Title', + value: '', + }, + { + type: 'input', + name: 'longDescriptionURL', + label: 'Long description URL', + value: '', + }, + { + type: 'checkbox', + name: 'border', + label: 'Show iframe border', + text: 'Border', + checked: false, + }, + { + type: 'checkbox', + name: 'scrollbar', + label: 'Enable scrollbar', + text: 'Scrollbar', + checked: false, + }, + ], + }, + ], + }, + buttons: [ + { + type: 'cancel', + name: 'cancel', + text: 'Cancel', + }, + { + type: 'submit', + name: 'save', + text: 'Save', + primary: true, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('opens insert iframe modal on button action', () => { + // Invoke the plugin + tinyMCEEmbedIframePlugin(editorMock); + + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + expect(editorMock.windowManager.open).toHaveBeenCalled(); + }); + + test('opens insert iframe modal on button action validate onSubmit and OnChange function', () => { + tinyMCEEmbedIframePlugin(editorMock); + + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + expect(editorMock.windowManager.open).toHaveBeenCalledWith( + expect.objectContaining({ + onSubmit: expect.any(Function), + onChange: expect.any(Function), + }), + ); + }); + + test('opens insert iframe modal on button action validate title', () => { + tinyMCEEmbedIframePlugin(editorMock); + + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + expect(editorMock.windowManager.open).toHaveBeenCalled(); + expect(editorMock.windowManager.open).toHaveBeenCalledWith( + expect.objectContaining({ + title: pluginConfig.title, + }), + ); + }); + + test('opens insert iframe modal on button action validate buttons', () => { + tinyMCEEmbedIframePlugin(editorMock); + + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + expect(editorMock.windowManager.open).toHaveBeenCalled(); + expect(editorMock.windowManager.open).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: pluginConfig.buttons, + }), + ); + }); + + test('opens insert iframe modal on button action validate tabs', () => { + tinyMCEEmbedIframePlugin(editorMock); + + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + const [generalTab, advancedTab] = pluginConfig.body.tabs; + + expect(editorMock.windowManager.open).toHaveBeenCalled(); + expect(editorMock.windowManager.open).toHaveBeenCalledWith( + expect.objectContaining({ + body: { type: 'tabpanel', tabs: [generalTab, advancedTab] }, + }), + ); + }); + test('tests onChange function in plugin', () => { + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + // Access the onChange function from the opened configuration + const onChangeFunction = editorMock.windowManager.open.mock.calls[0][0].onChange; + + // Mock API and field for onChange + const apiMock = { + getData: jest.fn(() => ({ sizeType: 'big' })), + redial: jest.fn(), + }; + const field = { + name: 'sizeType', + }; + + // Simulate calling the onChange function + onChangeFunction(apiMock, field); + + expect(apiMock.getData).toHaveBeenCalled(); + expect(apiMock.redial).toHaveBeenCalled(); + }); + + test('modifies generalTab items when sizeType is not inline', () => { + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + const onChangeFunction = editorMock.windowManager.open.mock.calls[0][0].onChange; + + const apiMock = { + getData: jest.fn(() => ({ sizeType: 'big' })), + redial: jest.fn(), + }; + const field = { + name: 'sizeType', + }; + + onChangeFunction(apiMock, field); + + const [generalTab, advancedTab] = pluginConfig.body.tabs; + const generalTabExpected = generalTab.items.filter( + (item) => item.type !== 'sizeinput', + ); + + const expectedTabs = [ + { title: generalTab.title, items: generalTabExpected, type: generalTab.type }, + advancedTab, + ]; + + const expectedBody = { + type: pluginConfig.body.type, + tabs: expectedTabs, + }; + + expect(apiMock.redial).toHaveBeenCalledWith(expect.objectContaining({ + body: expectedBody, + })); + }); + + test('adds sizeinput to generalTab items when sizeType is inline', () => { + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + const onChangeFunction = editorMock.windowManager.open.mock.calls[0][0].onChange; + + const apiMock = { + getData: jest.fn(() => ({ sizeType: 'inline' })), + redial: jest.fn(), + }; + const field = { + name: 'sizeType', + }; + + onChangeFunction(apiMock, field); + + const [generalTab, advancedTab] = pluginConfig.body.tabs; + + expect(apiMock.redial).toHaveBeenCalledWith( + expect.objectContaining({ + body: { type: 'tabpanel', tabs: [generalTab, advancedTab] }, + }), + ); + }); + + test('tests onSubmit function in plugin', () => { + const dataMock = { + source: 'https://www.example.com', + sizeType: 'big', + }; + const apiMock = { + getData: jest.fn(() => dataMock), + close: jest.fn(), + }; + + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit; + onSubmitFunction(apiMock); + + expect(apiMock.getData).toHaveBeenCalled(); + expect(editorMock.insertContent).toHaveBeenCalled(); + expect(apiMock.close).toHaveBeenCalled(); + }); + + test('tests onSubmit function in plugin advanced properties', () => { + const dataMock = { + source: 'https://www.example.com', + sizeType: 'big', + name: 'iframeName', + title: 'iframeTitle', + longDescriptionURL: 'https://example.com/description', + border: true, + scrollbar: true, + }; + const apiMock = { + getData: jest.fn(() => dataMock), + close: jest.fn(), + }; + + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit; + onSubmitFunction(apiMock); + + expect(apiMock.getData).toHaveBeenCalled(); + + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="800px"')); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="800px"')); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining(`name="${dataMock.name}"`)); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining(`title="${dataMock.title}"`)); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining(`longdesc="${dataMock.longDescriptionURL}"`)); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('scrolling="yes"')); + + expect(apiMock.close).toHaveBeenCalled(); + }); + + describe('tests onSubmit function in plugin sizeType', () => { + test('tests onSubmit function in plugin with sizeType big', () => { + const dataMock = { + source: 'https://www.example.com', + sizeType: 'big', + }; + + const apiMock = { + getData: jest.fn(() => dataMock), + close: jest.fn(), + }; + + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit; + onSubmitFunction(apiMock); + + expect(apiMock.getData).toHaveBeenCalled(); + + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="800px"')); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="800px"')); + + expect(apiMock.close).toHaveBeenCalled(); + }); + + test('tests onSubmit function in plugin with sizeType small', () => { + const dataMock = { + source: 'https://www.example.com', + sizeType: 'small', + }; + + const apiMock = { + getData: jest.fn(() => dataMock), + close: jest.fn(), + }; + + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit; + onSubmitFunction(apiMock); + + expect(apiMock.getData).toHaveBeenCalled(); + + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="100px"')); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="100px"')); + expect(apiMock.close).toHaveBeenCalled(); + }); + + test('tests onSubmit function in plugin with custom sizeType', () => { + const dataMock = { + source: 'https://www.example.com', + sizeType: 'inline', + size: { + width: '500px', + height: '700px', + }, + }; + + const apiMock = { + getData: jest.fn(() => dataMock), + close: jest.fn(), + }; + + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit; + onSubmitFunction(apiMock); + + expect(apiMock.getData).toHaveBeenCalled(); + + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="500px"')); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="700px"')); + expect(apiMock.close).toHaveBeenCalled(); + }); + + test('tests onSubmit function in plugin with custom sizeType invalid values', () => { + const dataMock = { + source: 'https://www.example.com', + sizeType: 'inline', + size: { + width: 'test', + height: 'test', + }, + }; + + const apiMock = { + getData: jest.fn(() => dataMock), + close: jest.fn(), + }; + + tinyMCEEmbedIframePlugin(editorMock); + editorMock.ui.registry.addButton.mock.calls[0][1].onAction(); + + const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit; + onSubmitFunction(apiMock); + + expect(apiMock.getData).toHaveBeenCalled(); + + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="300px"')); + expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="300px"')); + expect(apiMock.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/editors/sharedComponents/TinyMceWidget/index.jsx b/src/editors/sharedComponents/TinyMceWidget/index.jsx index 371a6774b..d2c8a3ff1 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.jsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.jsx @@ -14,6 +14,7 @@ import { selectors } from '../../data/redux'; import ImageUploadModal from '../ImageUploadModal'; import SourceCodeModal from '../SourceCodeModal'; import * as hooks from './hooks'; +import './customTinyMcePlugins/embedIframePlugin'; const editorConfigDefaultProps = { setEditorRef: undefined, diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js index 509fcd9b7..61386de47 100644 --- a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js +++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js @@ -33,6 +33,7 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => { quickToolbar, plugins.a11ychecker, plugins.powerpaste, + plugins.embediframe, ].join(' '), menubar: false, toolbar: toolbar ? mapToolbars([ @@ -54,7 +55,7 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => { ], [imageUploadButton, buttons.link, buttons.unlink, buttons.blockQuote, buttons.codeBlock], [buttons.table, buttons.emoticons, buttons.charmap, buttons.hr], - [buttons.removeFormat, codeButton, buttons.a11ycheck], + [buttons.removeFormat, codeButton, buttons.a11ycheck, buttons.embediframe], ]) : false, imageToolbar: mapToolbars([ // [buttons.rotate.left, buttons.rotate.right],