diff --git a/jest.config.js b/jest.config.js index ead7df147ad..79692c8a51a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,7 +24,14 @@ module.exports = { '\\.svg$': '/jest/imageMock.js', 'node_modules-version-of-backbone': require.resolve('backbone'), 'node_modules-version-of-react-modal': require.resolve('react-modal'), - '^Backbone$': '/public/javascripts/Backbone.js' + '^Backbone$': '/public/javascripts/Backbone.js', + // jest can't import the icons + '@instructure/ui-icons/es/svg': '/packages/canvas-rce/src/rce/__tests__/_mockIcons.js', + // redirect import from es/rce/CanvasRce to lib + '@instructure/canvas-rce/es/rce/CanvasRce': + '/packages/canvas-rce/lib/rce/CanvasRce.js', + // mock the tinymce-react Editor react component + '@tinymce/tinymce-react': '/packages/canvas-rce/src/rce/__mocks__/tinymceReact.js' }, roots: ['ui', 'gems/plugins', 'public/javascripts'], moduleDirectories: ['ui/shims', 'public/javascripts', 'node_modules'], diff --git a/package.json b/package.json index 31a281bef93..541fef7686f 100644 --- a/package.json +++ b/package.json @@ -258,6 +258,7 @@ "jest-localstorage-mock": "^2", "jest-moxios-utils": "^1", "jest-raw-loader": "^1", + "jsdom-global": "^3.0.2", "json-loader": "^0.5.7", "karma": "^3", "karma-chrome-launcher": "^2", diff --git a/packages/canvas-rce/jest.config.js b/packages/canvas-rce/jest.config.js index d28c4fdc630..ec9c21f98ad 100644 --- a/packages/canvas-rce/jest.config.js +++ b/packages/canvas-rce/jest.config.js @@ -32,10 +32,12 @@ module.exports = { setupFilesAfterEnv: ['/jest/jest-setup-framework.js'], testPathIgnorePatterns: ['/node_modules', '/lib', '/canvas'], testMatch: ['**/__tests__/**/?(*.)(spec|test).js'], - modulePathIgnorePatterns: ['/lib', '/canvas'], + modulePathIgnorePatterns: ['/es', '/lib', '/canvas'], testEnvironment: 'jest-environment-jsdom-fourteen', moduleNameMapper: { // jest can't import the icons - '@instructure/ui-icons/es/svg': '/src/rce/__tests__/_mockIcons.js' + '@instructure/ui-icons/es/svg': '/src/rce/__tests__/_mockIcons.js', + // mock the tinymce-react Editor component + '@tinymce/tinymce-react': '/src/rce/__mocks__/tinymceReact.js' } } diff --git a/packages/canvas-rce/scripts/build-canvas b/packages/canvas-rce/scripts/build-canvas index e8aebbecf59..71a18a1894b 100755 --- a/packages/canvas-rce/scripts/build-canvas +++ b/packages/canvas-rce/scripts/build-canvas @@ -16,6 +16,6 @@ rm -rf canvas/* yarn installTranslations -JEST_WORKER_ID=true ./node_modules/.bin/babel --out-dir lib src --ignore '**/__tests__' -./node_modules/.bin/babel --out-dir es src --ignore '**/__tests__' +JEST_WORKER_ID=true ./node_modules/.bin/babel --out-dir lib src --ignore '**/__tests__,**/__mocks__' +./node_modules/.bin/babel --out-dir es src --ignore '**/__tests__,**/__mocks__' cp -r lib locales README.md package.json canvas diff --git a/packages/canvas-rce/src/rce/RCEWrapper.js b/packages/canvas-rce/src/rce/RCEWrapper.js index 49dc8748414..7e0e49a418a 100644 --- a/packages/canvas-rce/src/rce/RCEWrapper.js +++ b/packages/canvas-rce/src/rce/RCEWrapper.js @@ -662,6 +662,8 @@ class RCEWrapper extends React.Component { focus() { this.onTinyMCEInstance('mceFocus') + // tinymce doesn't always call the focus handler. + this.handleFocusEditor(new Event('focus', {target: this.mceInstance()})) } focusCurrentView() { @@ -1125,7 +1127,7 @@ class RCEWrapper extends React.Component { // If the editor is invisible for some reason, don't show the autosave modal // This doesn't apply if the editor is off-screen or has visibility:hidden; // only if it isn't rendered or has display:none; - const editorVisible = this.editor.container.offsetParent + const editorVisible = this.editor.getContainer().offsetParent return ( this.props.autosave.enabled && @@ -1489,7 +1491,7 @@ class RCEWrapper extends React.Component { this.observer = new MutationObserver((mutationList, _observer) => { mutationList.forEach(mutation => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { - this.handleFocusEditor(new FocusEvent('focus', {target: mutation.target})) + this.handleFocusEditor(new Event('focus', {target: mutation.target})) } }) }) diff --git a/packages/canvas-rce/src/rce/__mocks__/tinymceReact.js b/packages/canvas-rce/src/rce/__mocks__/tinymceReact.js new file mode 100644 index 00000000000..8bd4cc61263 --- /dev/null +++ b/packages/canvas-rce/src/rce/__mocks__/tinymceReact.js @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2021 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +/* + * This is a mock for the @tinymce/tinymce-react Editor component + * and the inner tinymce editor object + * jest.config.js moduleNameMapper has jest load this + * file in response to + * import {Editor} from '@tinymce/tinymce-react' + * in RCEWrapper.js + */ + +import React, {useEffect, useRef} from 'react' + +class FakeEditor { + constructor(textareaId) { + this.hidden = true + this._textareaId = textareaId + this.readonly = undefined + this._eventHandlers = {} + } + + execCommand(_cmd) {} + + focus() { + this.getElement().focus() + } + + getContainer() { + return this.getElement().parentElement + } + + getElement() { + return document.getElementById(this._textareaId) + } + + isHidden() { + return this.hidden + } + + on(event, handler) { + this._eventHandlers[event] = handler + } + + getBody() {} + + getContent() { + return this.getElement().value + } + + mode = { + set: mode => { + this.readonly = mode === 'readonly' + } + } + + setContent(content) { + this.getElement().value = content + this._eventHandlers.change?.({ + type: 'change', + target: this.getElement() + }) + } + + selection = { + collapse: () => {}, + select: () => {} + } + + hide() { + this.hidden = true + } + + show() { + this.hidden = false + } +} + +export function Editor(props) { + const editorRef = useRef(null) + const textareaRef = useRef(null) + const tinymceEditor = useRef(new FakeEditor(props.id)) + + useEffect(() => { + window.tinymce.editors[0] = tinymceEditor.current + tinymceEditor.current.on('change', handleChange) + props.onInit && props.onInit({}, tinymceEditor.current) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + function handleChange(event) { + props.onEditorChange?.(event.target.value) + } + + return ( +
+