From 2138c12eb422bd077e5caa0bf7d2de381a1f2538 Mon Sep 17 00:00:00 2001 From: Felipe Trzaskowski Date: Thu, 14 Oct 2021 19:41:40 -0300 Subject: [PATCH] fix: remove the "powered by tiny" text fix: skin loading with transparent background For some reason, loading the skin from `skin.min.css` was resulting in dialogs and menus appearing with a transparent background. Replacing the import for the non-minified version, `skin.css`, solves the problem. feat: enable native browser spellchecking Disabling the TinyMCE context menu is required for the native spellchecking right click suggestions to work. feat: enable autosave plugin Autosaves to local storage every second. Restores draft when empty. feat: add new toolbar buttons Additional toolbar buttons requested: quoting, code formatting, HTML button with text label, clear formatting, undo/redo feat: add image button to the toolbar, and load the imagetools plugin The imagetools only work on images loaded from the same domain. feat: add image upload capability Depends on an upload endpoint in the REST API. feat: enable a11y_advanced_options This will only affect the image plugin. For full a11y checking functionality, a premium TinyMCE key is required. fix: TinyMCE content styles not being properly loaded Content CSS need to be loaded as raw text, to be included in the TinyMCE initialization. For this, we need webpack's `raw-loader` as a dependency. fix: refactor the upload API call into posts/data/api feat: pass courseId and threadId to the upload API endpoint fix: rename customized buttons with openedx prefix fix: use exact version for dependencies fix: improve jsdoc and update the upload endpoint fix: simplify TinyMCE side of the upload call fix: update the upload url --- package-lock.json | 41 +++++++--------- package.json | 1 + src/components/TinyMCEEditor.jsx | 67 +++++++++++++++++++++++--- src/discussions/posts/data/api.js | 21 ++++++++ src/discussions/posts/data/api.test.js | 35 ++++++++++++++ 5 files changed, 135 insertions(+), 30 deletions(-) create mode 100644 src/discussions/posts/data/api.test.js diff --git a/package-lock.json b/package-lock.json index 46437668..703a5ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5694,8 +5694,7 @@ "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", - "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", - "dev": true + "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==" }, "@types/json5": { "version": "0.0.29", @@ -6210,7 +6209,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6221,8 +6219,7 @@ "ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==" }, "alphanum-sort": { "version": "1.0.2", @@ -7036,8 +7033,7 @@ "big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" }, "bin-build": { "version": "3.0.0", @@ -9351,8 +9347,7 @@ "emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" }, "encodeurl": { "version": "1.0.2", @@ -10387,8 +10382,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.7", @@ -10460,8 +10454,7 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-levenshtein": { "version": "2.0.6", @@ -16114,8 +16107,7 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -16127,7 +16119,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", - "dev": true, "requires": { "minimist": "^1.2.5" } @@ -16269,7 +16260,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", - "dev": true, "requires": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -16831,8 +16821,7 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" }, "mixin-deep": { "version": "1.3.2", @@ -18591,8 +18580,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "q": { "version": "1.5.1", @@ -18656,6 +18644,15 @@ "unpipe": "1.0.0" } }, + "raw-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", + "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==", + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + } + }, "react": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", @@ -19915,7 +19912,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", - "dev": true, "requires": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -21844,7 +21840,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "requires": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index f610fe0f..b6920a09 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "formik": "2.2.6", "lodash.snakecase": "4.1.1", "prop-types": "15.7.2", + "raw-loader": "4.0.2", "react": "16.14.0", "react-dom": "16.14.0", "react-redux": "7.2.5", diff --git a/src/components/TinyMCEEditor.jsx b/src/components/TinyMCEEditor.jsx index 8d4d88fd..8a521deb 100644 --- a/src/components/TinyMCEEditor.jsx +++ b/src/components/TinyMCEEditor.jsx @@ -1,38 +1,91 @@ import React from 'react'; import { Editor } from '@tinymce/tinymce-react'; -/* eslint import/no-webpack-loader-syntax: off */ -import contentCss from 'tinymce/skins/content/default/content.min.css'; -import contentUiCss from 'tinymce/skins/ui/oxide/content.min.css'; +import { useParams } from 'react-router'; // TinyMCE so the global var exists // eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies import tinymce from 'tinymce/tinymce'; +import { uploadFile } from '../discussions/posts/data/api'; + import 'tinymce/plugins/code'; // Theme import 'tinymce/themes/silver'; // Toolbar icons import 'tinymce/icons/default'; // Editor styles -import 'tinymce/skins/ui/oxide/skin.min.css'; +import 'tinymce/skins/ui/oxide/skin.css'; // importing the plugin js. import 'tinymce/plugins/autolink'; +import 'tinymce/plugins/autosave'; import 'tinymce/plugins/codesample'; +import 'tinymce/plugins/image'; +import 'tinymce/plugins/imagetools'; import 'tinymce/plugins/link'; import 'tinymce/plugins/lists'; +/* eslint import/no-webpack-loader-syntax: off */ +// eslint-disable-next-line import/no-unresolved +import edxBrandCss from '!!raw-loader!sass-loader!../index.scss'; +// eslint-disable-next-line import/no-unresolved +import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.css'; +// eslint-disable-next-line import/no-unresolved +import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css'; + +const setup = (editor) => { + editor.ui.registry.addButton('openedx_code', { + icon: 'sourcecode', + onAction: () => { + editor.execCommand('CodeSample'); + }, + }); + editor.ui.registry.addButton('openedx_html', { + text: 'HTML', + onAction: () => { + editor.execCommand('mceCodeEditor'); + }, + }); +}; export default function TinyMCEEditor(props) { // note that skin and content_css is disabled to avoid the normal // loading process and is instead loaded as a string via content_style + + const { courseId, postId } = useParams(); + + const uploadHandler = async (blobInfo, success, failure) => { + try { + const blob = blobInfo.blob(); + const filename = blobInfo.filename(); + const { location } = await uploadFile(blob, filename, courseId, postId || 'root'); + success(location); + } catch (e) { + failure(e.toString(), { remove: true }); + } + }; + return ( diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index c290d986..65470b26 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -11,6 +11,7 @@ ensureConfig([ const apiBaseUrl = getConfig().LMS_BASE_URL; export const threadsApiUrl = `${apiBaseUrl}/api/discussion/v1/threads/`; +export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`; /** * Fetches all the threads in the given course and topic. @@ -146,3 +147,23 @@ export async function deleteThread(threadId) { const url = `${threadsApiUrl}${threadId}/`; await getAuthenticatedHttpClient().delete(url); } + +/** + * Upload a file. + * @param {Blob} blob The file body + * @param {string} filename + * @param {string} courseId + * @param {string} threadKey + * @returns {Promise<{ location: string }>} + */ +export async function uploadFile(blob, filename, courseId, threadKey) { + const uploadUrl = `${coursesApiUrl}${courseId}/upload`; + const formData = new FormData(); + formData.append('thread_key', threadKey); + formData.append('uploaded_file', blob, filename); + const { data } = await getAuthenticatedHttpClient().post(uploadUrl, formData); + if (data.developer_message) { + throw new Error(data.developer_message); + } + return data; +} diff --git a/src/discussions/posts/data/api.test.js b/src/discussions/posts/data/api.test.js new file mode 100644 index 00000000..adc89bbd --- /dev/null +++ b/src/discussions/posts/data/api.test.js @@ -0,0 +1,35 @@ +import MockAdapter from 'axios-mock-adapter'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; + +import { coursesApiUrl, uploadFile } from './api'; + +const courseId = 'course-v1:edX+TestX+Test_Course'; + +let axiosMock = null; + +describe('Threads/Posts api tests', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + test('successfully completes upload requests', async () => { + axiosMock.onPost(`${coursesApiUrl}${courseId}/upload`) + .reply(200, { location: 'http://test/file.jpg' }); + const response = await uploadFile(new Blob(['sample data']), 'sample_file.jpg', courseId, 'root'); + expect(response.location).toEqual('http://test/file.jpg'); + }); +});