From 53ce3a6e9f32f4002e73f081dcdbf63bfdd93acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jhon=20Vent=C3=A9?= Date: Wed, 17 Jan 2024 20:25:21 -0500 Subject: [PATCH] feat: insert link plugin tinymce --- package-lock.json | 6 + package.json | 1 + src/editors/data/constants/tinyMCE.js | 1 + .../InsertLinkModal/BlockLink/index.jsx | 41 +++ .../InsertLinkModal/BlockLink/index.scss | 5 + .../InsertLinkModal/BlockLink/index.test.jsx | 46 +++ .../InsertLinkModal/BlocksList/index.jsx | 150 ++++++++ .../InsertLinkModal/BlocksList/index.scss | 5 + .../InsertLinkModal/BlocksList/index.test.jsx | 31 ++ .../InsertLinkModal/BlocksList/messages.js | 16 + .../InsertLinkModal/FilterBlock/index.jsx | 47 +++ .../InsertLinkModal/SearchBlocks/index.jsx | 102 ++++++ .../InsertLinkModal/SearchBlocks/index.scss | 5 + .../SearchBlocks/index.test.jsx | 31 ++ .../InsertLinkModal/SearchBlocks/messages.js | 11 + .../sharedComponents/InsertLinkModal/api.js | 29 ++ .../InsertLinkModal/api.test.js | 79 +++++ .../InsertLinkModal/index.jsx | 168 +++++++++ .../InsertLinkModal/index.scss | 3 + .../InsertLinkModal/messages.js | 36 ++ .../sharedComponents/InsertLinkModal/utils.js | 143 ++++++++ .../InsertLinkModal/utils.test.js | 325 ++++++++++++++++++ .../__snapshots__/index.test.jsx.snap | 39 +++ .../sharedComponents/TinyMceWidget/hooks.js | 22 ++ .../TinyMceWidget/hooks.test.js | 6 +- .../sharedComponents/TinyMceWidget/index.jsx | 44 ++- .../TinyMceWidget/index.test.jsx | 6 + .../TinyMceWidget/pluginConfig.js | 6 +- 28 files changed, 1384 insertions(+), 20 deletions(-) create mode 100644 src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx create mode 100644 src/editors/sharedComponents/InsertLinkModal/BlockLink/index.scss create mode 100644 src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx create mode 100644 src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx create mode 100644 src/editors/sharedComponents/InsertLinkModal/BlocksList/index.scss create mode 100644 src/editors/sharedComponents/InsertLinkModal/BlocksList/index.test.jsx create mode 100644 src/editors/sharedComponents/InsertLinkModal/BlocksList/messages.js create mode 100644 src/editors/sharedComponents/InsertLinkModal/FilterBlock/index.jsx create mode 100644 src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx create mode 100644 src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.scss create mode 100644 src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.test.jsx create mode 100644 src/editors/sharedComponents/InsertLinkModal/SearchBlocks/messages.js create mode 100644 src/editors/sharedComponents/InsertLinkModal/api.js create mode 100644 src/editors/sharedComponents/InsertLinkModal/api.test.js create mode 100644 src/editors/sharedComponents/InsertLinkModal/index.jsx create mode 100644 src/editors/sharedComponents/InsertLinkModal/index.scss create mode 100644 src/editors/sharedComponents/InsertLinkModal/messages.js create mode 100644 src/editors/sharedComponents/InsertLinkModal/utils.js create mode 100644 src/editors/sharedComponents/InsertLinkModal/utils.test.js diff --git a/package-lock.json b/package-lock.json index eedecadb4..be9dec7dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "fast-xml-parser": "^4.0.10", "frontend-components-tinymce-advanced-plugins": "^1.0.2", "lodash-es": "^4.17.21", + "lodash.clonedeep": "^4.5.0", "lodash.flatten": "^4.4.0", "moment": "^2.29.4", "moment-shortformat": "^2.1.0", @@ -17199,6 +17200,11 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", diff --git a/package.json b/package.json index c55eeacc9..cd49b090b 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "fast-xml-parser": "^4.0.10", "frontend-components-tinymce-advanced-plugins": "^1.0.2", "lodash-es": "^4.17.21", + "lodash.clonedeep": "^4.5.0", "lodash.flatten": "^4.4.0", "moment": "^2.29.4", "moment-shortformat": "^2.1.0", diff --git a/src/editors/data/constants/tinyMCE.js b/src/editors/data/constants/tinyMCE.js index f98333580..d6f53d3e0 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', + insertLink: 'insertlink', }); export const plugins = listKeyStore([ diff --git a/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx new file mode 100644 index 000000000..5800cd2f8 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import { Button } from '@edx/paragon'; +import { LinkOff } from '@edx/paragon/icons'; +import { formatBlockPath } from '../utils'; + +import './index.scss'; + +const BlockLink = ({ path, onCloseLink }) => { + const { title, subTitle } = formatBlockPath(path); + return ( +
+
+

{subTitle}

+

{title}

+
+
+ +
+
+ ); +}; + +BlockLink.defaultProps = { + onCloseLink: () => {}, +}; + +BlockLink.propTypes = { + path: PropTypes.string.isRequired, + onCloseLink: PropTypes.func, +}; + +export default BlockLink; diff --git a/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.scss b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.scss new file mode 100644 index 000000000..2b09f79af --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.scss @@ -0,0 +1,5 @@ +.link-container { + .title { + overflow-wrap: break-word; + } +} diff --git a/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx new file mode 100644 index 000000000..b2157927c --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import Enzyme, { shallow } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import { formatBlockPath } from '../utils'; +import BlockLink from './index'; + +Enzyme.configure({ adapter: new Adapter() }); + +describe('BlockLink Component', () => { + const defaultProps = { + path: 'Some Path', + onCloseLink: jest.fn(), + }; + + test('renders with default props', () => { + const wrapper = shallow(); + expect(wrapper.text()).toContain('Some Path'); + }); + + test('renders correctly with custom path', () => { + const customProps = { + ...defaultProps, + path: 'Custom Path', + }; + const wrapper = shallow(); + expect(wrapper.text()).toContain('Custom Path'); + }); + + test('calls onCloseLink when the button is clicked', () => { + const wrapper = shallow(); + wrapper.find({ 'data-testid': 'close-link-button' }).simulate('click'); + expect(defaultProps.onCloseLink).toHaveBeenCalledTimes(1); + }); + + test('renders with valid title and subtitle', () => { + const customProps = { + path: 'Root Section / Child 1', + onCloseLink: jest.fn(), + }; + const wrapper = shallow(); + const { title, subTitle } = formatBlockPath(customProps.path); + + expect(wrapper.text()).toContain(title); + expect(wrapper.text()).toContain(subTitle); + }); +}); diff --git a/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx new file mode 100644 index 000000000..3fdb3b5c9 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, TransitionReplace, ActionRow } from '@edx/paragon'; +import { ArrowForwardIos, ArrowBack } from '@edx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { + blockTypes, + getSectionsList, + getChildrenFromList, +} from '../utils'; + +import messages from './messages'; +import './index.scss'; + +const BlocksList = ({ blocks, onBlockSelected }) => { + const intl = useIntl(); + const messagesTest = { + [blockTypes.section]: intl.formatMessage( + messages.blocksListSubsectionTitle, + ), + [blockTypes.subsection]: intl.formatMessage(messages.blocksListUnitTitle), + [blockTypes.unit]: intl.formatMessage(messages.blocksListUnitTitle), + }; + + const [blockState, setBlockState] = useState({ + blockSelected: {}, + type: blockTypes.subsection, + hasNavigated: false, + blocksNavigated: [], + }); + + const sections = getSectionsList(blocks); + const subSections = getChildrenFromList( + blockState.blockSelected, + blocks, + ); + const listItems = blockState.hasNavigated ? subSections : sections; + + const isBlockSelectedUnit = blockState.type === blockTypes.unit; + const blockNameButtonClass = isBlockSelectedUnit ? 'col-12' : 'col-11'; + + const handleSelectBlock = (block, navigate = false) => { + if (navigate) { + setBlockState({ + ...blockState, + blocksNavigated: [...blockState.blocksNavigated, block.id], + blockSelected: block, + type: block.type, + hasNavigated: true, + }); + } else { + onBlockSelected(block); + } + }; + + const handleGoBack = () => { + const newValue = blockState.blocksNavigated.filter( + (id) => id !== blockState.blockSelected.id, + ); + if (newValue.length) { + const lastBlockIndex = newValue.length - 1; + const blockId = newValue[lastBlockIndex]; + const newBlock = blocks[blockId]; + setBlockState({ + ...blockState, + type: newBlock.type, + hasNavigated: true, + blockSelected: newBlock, + blocksNavigated: newValue, + }); + } else { + setBlockState({ + ...blockState, + type: blockState.section, + hasNavigated: false, + blockSelected: {}, + }); + } + }; + + return ( + <> + {blockState.hasNavigated && ( + + + +

{messagesTest[blockState.type]}

+
+ )} +
+ {listItems.map((block) => ( + + + + {!isBlockSelectedUnit && ( + + )} + + + ))} +
+ + ); +}; + +BlocksList.defaultProps = { + onBlockSelected: () => {}, +}; + +const blockShape = PropTypes.shape({ + id: PropTypes.string.isRequired, + blockId: PropTypes.string.isRequired, + lmsWebUrl: PropTypes.string.isRequired, + legacyWebUrl: PropTypes.string.isRequired, + studentViewUrl: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + children: PropTypes.arrayOf(PropTypes.string), +}); + +BlocksList.propTypes = { + blocks: PropTypes.objectOf(blockShape).isRequired, + onBlockSelected: PropTypes.func, +}; + +export default BlocksList; diff --git a/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.scss b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.scss new file mode 100644 index 000000000..362a69bd1 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.scss @@ -0,0 +1,5 @@ +.block-list-container { + height: 200px; + overflow-y: auto; + overflow-x: none; +} diff --git a/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.test.jsx b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.test.jsx new file mode 100644 index 000000000..506f459b3 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.test.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import Enzyme, { shallow } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; + +import BlocksList from '.'; + +Enzyme.configure({ adapter: new Adapter() }); + +const mockBlocks = { + block1: { id: 'block1', path: 'Block 1', type: 'section' }, + block2: { id: 'block2', path: 'Block 2', type: 'subsection' }, +}; + +describe('BlocksList Component', () => { + // eslint-disable-next-line react/prop-types + const IntlProviderWrapper = ({ children }) => ( + + {children} + + ); + + test('renders without crashing', () => { + const wrapper = shallow( + + {}} /> + , + ); + expect(wrapper.exists()).toBeTruthy(); + }); +}); diff --git a/src/editors/sharedComponents/InsertLinkModal/BlocksList/messages.js b/src/editors/sharedComponents/InsertLinkModal/BlocksList/messages.js new file mode 100644 index 000000000..ad32495d4 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/messages.js @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + blocksListSubsectionTitle: { + id: 'blocks.list.subsection.title', + defaultMessage: 'Subsections', + description: 'Title for the subsections blocks', + }, + blocksListUnitTitle: { + id: 'blocks.list.unit.title', + defaultMessage: 'Units', + description: 'Title for the units blocks', + }, +}); + +export default messages; diff --git a/src/editors/sharedComponents/InsertLinkModal/FilterBlock/index.jsx b/src/editors/sharedComponents/InsertLinkModal/FilterBlock/index.jsx new file mode 100644 index 000000000..d4a71a46e --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/FilterBlock/index.jsx @@ -0,0 +1,47 @@ +import { Button } from '@edx/paragon'; +import PropTypes from 'prop-types'; + +import { formatBlockPath } from '../utils'; + +const FilterBlock = ({ block, onBlockFilterClick }) => { + const { title, subTitle } = formatBlockPath(block.path); + + const handleBlockClick = () => { + onBlockFilterClick(block); + }; + + return ( + + ); +}; + +FilterBlock.defaultProps = { + onBlockFilterClick: () => {}, +}; + +const blockShape = PropTypes.shape({ + id: PropTypes.string.isRequired, + blockId: PropTypes.string.isRequired, + lmsWebUrl: PropTypes.string.isRequired, + legacyWebUrl: PropTypes.string.isRequired, + studentViewUrl: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + children: PropTypes.arrayOf(PropTypes.string), +}); + +FilterBlock.propTypes = { + block: PropTypes.objectOf(blockShape).isRequired, + onBlockFilterClick: PropTypes.func, +}; + +export default FilterBlock; diff --git a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx new file mode 100644 index 000000000..ba29ef28d --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx @@ -0,0 +1,102 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { SearchField } from '@edx/paragon'; +import FilterBlock from '../FilterBlock'; +import { filterBlocksByText } from '../utils'; + +import messages from './messages'; +import './index.scss'; + +const SearchBlocks = ({ + blocks, + onSearchFilter, + searchInputValue = '', + onBlockSelected, +}) => { + const intl = useIntl(); + const [searchField, setSearchField] = useState(searchInputValue); + const [blocksFilteredItems, setBlocksFilteredItems] = useState(null); + + const blocksFilteredItemsFormat = blocksFilteredItems + ? Object.keys(blocksFilteredItems) + : []; + + const handleSearchBlock = (value) => { + setSearchField(value); + }; + + const handleSelectedBlock = (block) => { + onBlockSelected(block); + }; + + useEffect(() => { + if (searchField.trim()) { + const blockFilter = filterBlocksByText(searchField, blocks); + setBlocksFilteredItems(blockFilter); + onSearchFilter(true); + } else { + setBlocksFilteredItems(null); + onSearchFilter(false); + } + }, [searchField]); + + return ( +
+ null} + /> + + {searchField.trim() && ( +

+ {intl.formatMessage(messages.searchBlocksResultMessages, { + searchField: `"${searchField}"`, + })} +

+ )} + + {blocksFilteredItemsFormat.length > 0 && ( +
+ {blocksFilteredItemsFormat.map((key) => ( + + ))} +
+ )} +
+ ); +}; + +SearchBlocks.defaultProps = { + onSearchFilter: () => {}, + onBlockSelected: () => {}, + searchInputValue: '', +}; + +const blockShape = PropTypes.shape({ + id: PropTypes.string.isRequired, + blockId: PropTypes.string.isRequired, + lmsWebUrl: PropTypes.string.isRequired, + legacyWebUrl: PropTypes.string.isRequired, + studentViewUrl: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + children: PropTypes.arrayOf(PropTypes.string), +}); + +SearchBlocks.propTypes = { + blocks: PropTypes.objectOf(blockShape).isRequired, + onSearchFilter: PropTypes.func, + searchInputValue: PropTypes.string, + onBlockSelected: PropTypes.func, +}; + +export default SearchBlocks; diff --git a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.scss b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.scss new file mode 100644 index 000000000..9e15c7cb5 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.scss @@ -0,0 +1,5 @@ +.blocks-filter-container { + height: 200px; + overflow-y: auto; + overflow-x: none; +} diff --git a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.test.jsx b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.test.jsx new file mode 100644 index 000000000..1bcae1e3c --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.test.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import Enzyme, { shallow } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; + +import SearchBlocks from '.'; + +Enzyme.configure({ adapter: new Adapter() }); + +const mockBlocks = { + block1: { id: 'block1', path: 'Block 1', type: 'section' }, + block2: { id: 'block2', path: 'Block 2', type: 'subsection' }, +}; + +describe('SearchBlocks Component', () => { + // eslint-disable-next-line react/prop-types + const IntlProviderWrapper = ({ children }) => ( + + {children} + + ); + + test('renders without crashing', () => { + const wrapper = shallow( + + + , + ); + expect(wrapper.exists()).toBeTruthy(); + }); +}); diff --git a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/messages.js b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/messages.js new file mode 100644 index 000000000..f2aba1bf1 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/messages.js @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + searchBlocksResultMessages: { + id: 'search.blocks.result.messages', + defaultMessage: 'Showing course pages matching your search for {searchField}', + description: 'Dynamic message for search result', + }, +}); + +export default messages; diff --git a/src/editors/sharedComponents/InsertLinkModal/api.js b/src/editors/sharedComponents/InsertLinkModal/api.js new file mode 100644 index 000000000..4f11e370b --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/api.js @@ -0,0 +1,29 @@ +/* eslint-disable import/prefer-default-export */ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +export const getBlocksFromCourse = async (courseId) => { + try { + const courseIdFormat = encodeURIComponent(courseId); + const { data } = await getAuthenticatedHttpClient().get( + `${ + getConfig().LMS_BASE_URL + }/api/courses/v1/blocks/?course_id=${courseIdFormat}&all_blocks=true&depth=all&requested_fields=name,parent, display_name,block_type,children`, + ); + + const { blocks } = data; + const blocksFormat = Object.keys(blocks).reduce( + (prevBlocks, key) => ({ + ...prevBlocks, + [key]: camelCaseObject(blocks[key]), + }), + {}, + ); + + data.blocks = blocksFormat; + + return data; + } catch (error) { + throw new Error(error); + } +}; diff --git a/src/editors/sharedComponents/InsertLinkModal/api.test.js b/src/editors/sharedComponents/InsertLinkModal/api.test.js new file mode 100644 index 000000000..87c6a0ce3 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/api.test.js @@ -0,0 +1,79 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +import { getBlocksFromCourse } from './api'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), + camelCaseObject: jest.fn((obj) => obj), +})); + +describe('getTopicsList function', () => { + const mockCourseId = 'course123'; + const mockBlocks = { + block_key: { + id: 'block-key', + block_id: 'edx_block-1', + lms_web_url: 'http://localhost/weburl', + legacy_web_url: 'http://localhost/legacy', + student_view_url: 'http://localhost/studentview', + type: 'sequential', + display_name: 'Any display name', + children: ['block_children_1', 'block_children_2'], + }, + block_children_1: { + id: 'block-children-1', + block_id: 'edx_block-1', + lms_web_url: 'http://localhost/weburl', + legacy_web_url: 'http://localhost/legacy', + student_view_url: 'http://localhost/studentview', + type: 'sequential', + display_name: 'Block children 1', + }, + block_children_2: { + id: 'block-children-2', + block_id: 'edx_block-2', + lms_web_url: 'http://localhost/weburl', + legacy_web_url: 'http://localhost/legacy', + student_view_url: 'http://localhost/studentview', + type: 'sequential', + display_name: 'Block children 2', + }, + }; + + const mockResponseData = { data: { root: 'block_key', blocks: mockBlocks } }; + const mockConfig = { LMS_BASE_URL: 'http://localhost' }; + + beforeEach(() => { + getConfig.mockReturnValue(mockConfig); + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue(mockResponseData), + }); + }); + + test('successfully fetches teams list with default parameters', async () => { + const response = await getBlocksFromCourse(mockCourseId); + expect(response).toEqual(mockResponseData.data); + expect(getAuthenticatedHttpClient().get).toHaveBeenCalledWith( + `http://localhost/api/courses/v1/blocks/?course_id=${mockCourseId}&all_blocks=true&depth=all&requested_fields=name,parent, display_name,block_type,children`, + ); + }); + + test('handles empty response', async () => { + const mockEmptyResponse = { data: { root: 'block_key', blocks: {} } }; + getAuthenticatedHttpClient().get.mockResolvedValue(mockEmptyResponse); + + const response = await getBlocksFromCourse(mockCourseId); + expect(response).toEqual(mockEmptyResponse.data); + }); + + test('handles an API error', async () => { + const errorMessage = 'Network error'; + getAuthenticatedHttpClient().get.mockRejectedValue(new Error(errorMessage)); + + await expect(getBlocksFromCourse(mockCourseId)).rejects.toThrow(errorMessage); + }); +}); diff --git a/src/editors/sharedComponents/InsertLinkModal/index.jsx b/src/editors/sharedComponents/InsertLinkModal/index.jsx new file mode 100644 index 000000000..91364a6dc --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/index.jsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { logError } from '@edx/frontend-platform/logging'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Tabs, + Tab, + Form, +} from '@edx/paragon'; +import BaseModal from '../BaseModal'; +import BlocksList from './BlocksList'; +import BlockLink from './BlockLink'; +import SearchBlocks from './SearchBlocks'; +import { formatBlocks, isValidURL } from './utils'; +import { getBlocksFromCourse } from './api'; + +import messages from './messages'; +import './index.scss'; + +const InsertLinkModal = ({ + courseId, + isOpen, + onClose, + editorRef, +}) => { + const intl = useIntl(); + const [searchField, setSearchField] = useState(''); + const [blocksSearched, setBlocksSearched] = useState(false); + const [blockSelected, setBlocksSelected] = useState(null); + const [blocksList, setBlocksList] = useState(null); + const [invalidUrlInput, setInvalidUrlInput] = useState(false); + const [inputUrlValue, setInputUrlValue] = useState(''); + + const handleSearchedBlocks = (isSearched) => { + setBlocksSearched(isSearched); + }; + + const handleSelectedBlock = (blockSelectedFromList) => { + setBlocksSelected(blockSelectedFromList); + setInputUrlValue(''); + }; + + const handleCloseLink = () => { + setSearchField(''); + setBlocksSelected(null); + }; + + const handleChangeInputUrl = ({ target: { value } }) => { + setInputUrlValue(value); + }; + + const handleSave = () => { + const editor = editorRef.current; + const urlPath = blockSelected?.lmsWebUrl || inputUrlValue; + if (editor && urlPath) { + const validateUrl = isValidURL(urlPath); + + if (!validateUrl) { + setInvalidUrlInput(true); + return; + } + + const selectedText = editor.selection.getContent({ format: 'text' }); + + if (selectedText.trim() !== '') { + const linkHtml = `${selectedText}`; + editor.selection.setContent(linkHtml); + } + } + + onClose(); + }; + + useEffect(() => { + const getBlocksList = async () => { + try { + const blocksData = await getBlocksFromCourse(courseId); + const { blocks: blocksResponse, root: rootBlocksResponse } = blocksData; + const blockListFormatted = formatBlocks( + blocksResponse, + rootBlocksResponse, + ); + setBlocksList(blockListFormatted); + } catch (error) { + logError(error); + } + }; + + getBlocksList(); + }, []); + + return ( + + {intl.formatMessage(messages.insertLinkModalButtonSave)} + + )} + > + {blockSelected ? ( + + ) : ( + + + + {!blocksSearched && ( + + )} + + + + + {invalidUrlInput && ( + + {intl.formatMessage(messages.insertLinkModalInputErrorMessage)} + + )} + + + + )} + + ); +}; + +InsertLinkModal.propTypes = { + courseId: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + editorRef: PropTypes.shape({ + current: PropTypes.shape({ + selection: PropTypes.shape({ + getContent: PropTypes.func, + setContent: PropTypes.func, + }), + }), + }).isRequired, +}; + +export default InsertLinkModal; diff --git a/src/editors/sharedComponents/InsertLinkModal/index.scss b/src/editors/sharedComponents/InsertLinkModal/index.scss new file mode 100644 index 000000000..f96309399 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/index.scss @@ -0,0 +1,3 @@ +.tabs-container { + min-height: 200px; +} diff --git a/src/editors/sharedComponents/InsertLinkModal/messages.js b/src/editors/sharedComponents/InsertLinkModal/messages.js new file mode 100644 index 000000000..4c2774221 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/messages.js @@ -0,0 +1,36 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + insertLinkModalTitle: { + id: 'insert.link.modal.title', + defaultMessage: 'Link to', + description: 'Title for the modal', + }, + insertLinkModalButtonSave: { + id: 'insert.link.modal.button.save', + defaultMessage: 'Save', + description: 'Button save in the modal', + }, + insertLinkModalCoursePagesTabTitle: { + id: 'insert.link.modal.course.pages.tab.title', + defaultMessage: 'Course pages', + description: 'Title for course pages tab', + }, + insertLinkModalUrlTabTitle: { + id: 'insert.link.modal.url.tab.title', + defaultMessage: 'URL', + description: 'Title for url tab', + }, + insertLinkModalInputPlaceholder: { + id: 'insert.link.modal.input.placeholder', + defaultMessage: 'http://www.example.com', + description: 'Placeholder for url input', + }, + insertLinkModalInputErrorMessage: { + id: 'insert.link.modal.input.error.message', + defaultMessage: 'The url provided is invalid', + description: 'Feedback message error for url input', + }, +}); + +export default messages; diff --git a/src/editors/sharedComponents/InsertLinkModal/utils.js b/src/editors/sharedComponents/InsertLinkModal/utils.js new file mode 100644 index 000000000..808339734 --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/utils.js @@ -0,0 +1,143 @@ +import cloneDeep from 'lodash.clonedeep'; + +export const blockTypes = { + section: 'chapter', + subsection: 'sequential', + unit: 'vertical', +}; + +/** + * Recursively adds path, parent ID, and root status to blocks in a nested structure. + * + * @param {Object} block - The current block in the recursion. + * @param {string} [parentPath=""] - The path of the parent block. + * @param {Object} blocks - The collection of blocks. + * @param {string} blockRoot - The key of the root block. + * @param {string|null} [parentId=null] - The ID of the parent block. + */ +export const addPathToBlocks = (block, blocks, blockRoot, parentId = null, parentPath = '') => { + const path = parentPath ? `${parentPath} / ${block.displayName}` : block.displayName; + block.path = path; + block.parentId = parentId; + + if (block.children && block.children.length > 0) { + block.children.forEach(childKey => { + const childBlock = blocks[childKey]; + addPathToBlocks(childBlock, blocks, blockRoot, block.id, path); + }); + } +}; + +/** + * Formats the blocks by adding path information to each block. + * + * @param {Object} blocks - The blocks to be formatted. + * @param {string} blockRoot - The key of the root block. + * @returns {Object} - The formatted blocks with added path information. + */ +export const formatBlocks = (blocks, blockRoot) => { + const copyBlocks = cloneDeep(blocks); + Object.keys(copyBlocks).forEach(key => { + const block = copyBlocks[key]; + const rootBlock = copyBlocks[blockRoot]; + const parentPath = block.type === blockTypes.section ? rootBlock.displayName : ''; + + addPathToBlocks(block, copyBlocks, blockRoot, null, parentPath); + }); + + return copyBlocks; +}; + +/** + * Retrieves a list of sections from the provided blocks object. + * + * @param {Object} blocks - The blocks object containing various block types. + * @returns {Array} An array of section (type: chapter) blocks extracted from the blocks object. + */ +export const getSectionsList = (blocks = {}) => { + const blocksList = Object.keys(blocks); + return blocksList.reduce((previousBlocks, blockKey) => { + const block = cloneDeep(blocks[blockKey]); + if (block.type === blockTypes.section) { + return [...previousBlocks, block]; + } + + return previousBlocks; + }, []); +}; + +/** + * Retrieves an array of child blocks based on the children list of a selected block. + * + * @param {Object} blockSelected - The selected block for which children are to be retrieved. + * @param {Object} blocks - The blocks object containing various block types. + * @returns {Array} An array of child blocks cloned from the blocks object. + */ +export const getChildrenFromList = (blockSelected, blocks) => { + if (blockSelected.children) { + return blockSelected.children.map((key) => cloneDeep(blocks[key])); + } + return []; +}; + +/** + * Filters blocks based on the provided searchText. + * + * @param {string} searchText - The text to filter blocks. + * @param {Object} blocks - The object containing blocks. + * @returns {Object} - Filtered blocks. + */ +export const filterBlocksByText = (searchText, blocks) => { + if (!searchText) { + return {}; + } + const copyBlocks = cloneDeep(blocks); + return Object.keys(copyBlocks).reduce((result, key) => { + const item = copyBlocks[key]; + if (item.path.toLowerCase().includes(searchText.toLowerCase())) { + result[key] = item; + } + return result; + }, {}); +}; + +/** + * Formats a block path into title and subtitle. + * + * @param {string} path - The path to be formatted. + * @returns {Object} - Formatted block path with title and subtitle. + */ +export const formatBlockPath = (path) => { + if (!path) { + return { + title: '', + subTitle: '', + }; + } + const pathSlitted = path.split(' / '); + let title = pathSlitted.pop(); + const subTitle = pathSlitted.join(' / '); + + if (!title.trim()) { + // If the last part is empty or contains only whitespace + title = pathSlitted.pop(); + } + return { + title, + subTitle, + }; +}; + +/** + * Validates a URL using a regular expression. + * + * @param {string} url - The URL to be validated. + * @returns {boolean} - True if the URL is valid, false otherwise. + */ +export const isValidURL = (url) => { + // Regular expression for a basic URL validation + const urlPattern = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/; + + // Test the provided URL against the pattern + return urlPattern.test(url); +}; diff --git a/src/editors/sharedComponents/InsertLinkModal/utils.test.js b/src/editors/sharedComponents/InsertLinkModal/utils.test.js new file mode 100644 index 000000000..ce4a87e8f --- /dev/null +++ b/src/editors/sharedComponents/InsertLinkModal/utils.test.js @@ -0,0 +1,325 @@ +import { + addPathToBlocks, + getSectionsList, + getChildrenFromList, + filterBlocksByText, + formatBlockPath, + isValidURL, +} from './utils'; + +describe('utils', () => { + describe('addPathToBlocks function', () => { + const testBlocks = { + 'block-key': { + id: 'block-key', + blockId: 'edx_block-1', + lmsWebUrl: 'http://localhost/weburl', + legacyWebUrl: 'http://localhost/legacy', + studentViewUrl: 'http://localhost/studentview', + type: 'sequential', + displayName: 'Any display name', + children: ['block-children-1', 'block-children-2'], + }, + 'block-children-1': { + id: 'block-children-1', + blockId: 'edx_block-1', + lmsWebUrl: 'http://localhost/weburl', + legacyWebUrl: 'http://localhost/legacy', + studentViewUrl: 'http://localhost/studentview', + type: 'sequential', + displayName: 'Block children 1', + }, + 'block-children-2': { + id: 'block-children-2', + blockId: 'edx_block-2', + lmsWebUrl: 'http://localhost/weburl', + legacyWebUrl: 'http://localhost/legacy', + studentViewUrl: 'http://localhost/studentview', + type: 'sequential', + displayName: 'Block children 2', + }, + }; + + test('Adds path to block without parent', () => { + const testBlock = testBlocks['block-key']; + addPathToBlocks(testBlock, testBlocks, 'block-key'); + + expect(testBlock.path).toBe('Any display name'); + expect(testBlock.parentId).toBe(null); + }); + + test('Adds path to nested block', () => { + const rootBlockId = 'block-key'; + const parentBlock = testBlocks[rootBlockId]; + const nestedBlock1 = testBlocks['block-children-1']; + const nestedBlock2 = testBlocks['block-children-2']; + + addPathToBlocks(nestedBlock1, testBlocks, rootBlockId, parentBlock.id, parentBlock.displayName); + addPathToBlocks(nestedBlock2, testBlocks, rootBlockId, parentBlock.id, parentBlock.displayName); + + expect(nestedBlock1.path).toBe('Any display name / Block children 1'); + expect(nestedBlock1.parentId).toBe(rootBlockId); + + expect(nestedBlock2.path).toBe('Any display name / Block children 2'); + expect(nestedBlock2.parentId).toBe(rootBlockId); + }); + }); + + describe('getSectionsList function', () => { + test('returns an empty array for an empty blocks object', () => { + const result = getSectionsList({}); + expect(result).toEqual([]); + }); + + test('returns an empty array if there are no sections in the blocks object', () => { + const blocks = { + block1: { + id: 'block1', + type: 'unit', + }, + block2: { + id: 'block2', + type: 'vertical', + }, + }; + const result = getSectionsList(blocks); + expect(result).toEqual([]); + }); + + test('returns an array containing sections from the blocks object', () => { + const blocks = { + section1: { + id: 'section1', + type: 'chapter', + }, + block1: { + id: 'block1', + type: 'unit', + }, + section2: { + id: 'section2', + type: 'chapter', + }, + block2: { + id: 'block2', + type: 'vertical', + }, + }; + const result = getSectionsList(blocks); + const expected = [ + { + id: 'section1', + type: 'chapter', + }, + { + id: 'section2', + type: 'chapter', + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('getChildrenFromList function', () => { + test('returns an empty array when blockSelected has no children', () => { + const blocks = { + parentBlock: { + id: 'parentBlock', + }, + }; + + const selectedBlock = blocks.parentBlock; + const childrenList = getChildrenFromList(selectedBlock, blocks); + + expect(childrenList).toEqual([]); + }); + + test('returns an array of child blocks when blockSelected has children', () => { + const blocks = { + parentBlock: { + id: 'parentBlock', + children: ['child1', 'child2'], + }, + child1: { + id: 'child1', + }, + child2: { + id: 'child2', + }, + }; + + const selectedBlock = blocks.parentBlock; + const childrenList = getChildrenFromList(selectedBlock, blocks); + + expect(childrenList).toHaveLength(2); + expect(childrenList).toContainEqual(blocks.child1); + expect(childrenList).toContainEqual(blocks.child2); + }); + + test('returns an empty array when blockSelected.children is undefined', () => { + const blocks = { + parentBlock: { + id: 'parentBlock', + children: undefined, + }, + }; + + const selectedBlock = blocks.parentBlock; + const childrenList = getChildrenFromList(selectedBlock, blocks); + + expect(childrenList).toEqual([]); + }); + + test('returns an empty array when blockSelected.children is an empty array', () => { + const blocks = { + parentBlock: { + id: 'parentBlock', + children: [], + }, + }; + + const selectedBlock = blocks.parentBlock; + const childrenList = getChildrenFromList(selectedBlock, blocks); + + expect(childrenList).toEqual([]); + }); + }); + + describe('filterBlocksByText function', () => { + const testBlocks = { + block1: { + id: 'block1', + path: 'Root / Child 1', + }, + block2: { + id: 'block2', + path: 'Root / Child 2', + }, + block3: { + id: 'block3', + path: 'Another / Block', + }, + }; + + test('returns an empty object when searchText is empty', () => { + const searchText = ''; + const filteredBlocks = filterBlocksByText(searchText, testBlocks); + expect(filteredBlocks).toEqual({}); + }); + + test('filters blocks based on case-insensitive searchText', () => { + const searchText = 'child'; + const filteredBlocks = filterBlocksByText(searchText, testBlocks); + expect(filteredBlocks).toEqual({ + block1: { + id: 'block1', + path: 'Root / Child 1', + }, + block2: { + id: 'block2', + path: 'Root / Child 2', + }, + }); + }); + + test('returns an empty object when no blocks match searchText', () => { + const searchText = 'nonexistent'; + const filteredBlocks = filterBlocksByText(searchText, testBlocks); + expect(filteredBlocks).toEqual({}); + }); + + test('filters blocks with partial matches in path', () => { + const searchText = 'root'; + const filteredBlocks = filterBlocksByText(searchText, testBlocks); + expect(filteredBlocks).toEqual({ + block1: { + id: 'block1', + path: 'Root / Child 1', + }, + block2: { + id: 'block2', + path: 'Root / Child 2', + }, + }); + }); + }); + + describe('formatBlockPath function', () => { + test('formats a simple path with title and subtitle', () => { + const path = 'Root / Child 1 / Grandchild'; + const formattedPath = formatBlockPath(path); + expect(formattedPath).toEqual({ + title: 'Grandchild', + subTitle: 'Root / Child 1', + }); + }); + + test('handles an empty title by using the previous part as title', () => { + const path = 'Root / Child 1 / '; + const formattedPath = formatBlockPath(path); + expect(formattedPath).toEqual({ + title: 'Child 1', + subTitle: 'Root / Child 1', + }); + }); + + test('handles an empty path by returning an empty title and subtitle', () => { + const path = ''; + const formattedPath = formatBlockPath(path); + expect(formattedPath).toEqual({ + title: '', + subTitle: '', + }); + }); + + test('handles whitespace in the title by using the previous part as title', () => { + const path = 'Root / Child 1 / '; + const formattedPath = formatBlockPath(path); + expect(formattedPath).toEqual({ + title: 'Child 1', + subTitle: 'Root / Child 1', + }); + }); + + test('handles a path with only one part by using it as the title', () => { + const path = 'SinglePart'; + const formattedPath = formatBlockPath(path); + expect(formattedPath).toEqual({ + title: 'SinglePart', + subTitle: '', + }); + }); + }); + + describe('isValidURL function', () => { + test('returns true for a valid HTTP URL', () => { + const validHTTPUrl = 'http://www.example.com'; + expect(isValidURL(validHTTPUrl)).toBe(true); + }); + + test('returns true for a valid HTTPS URL', () => { + const validHTTPSUrl = 'https://www.example.com'; + expect(isValidURL(validHTTPSUrl)).toBe(true); + }); + + test('returns true for a valid FTP URL', () => { + const validFTPUrl = 'ftp://ftp.example.com'; + expect(isValidURL(validFTPUrl)).toBe(true); + }); + + test('returns false for an invalid URL', () => { + const invalidUrl = 'invalid-url'; + expect(isValidURL(invalidUrl)).toBe(false); + }); + + test('returns false for an empty URL', () => { + const emptyUrl = ''; + expect(isValidURL(emptyUrl)).toBe(false); + }); + + test('returns false for a URL with spaces', () => { + const urlWithSpaces = 'http://www.example with spaces.com'; + expect(isValidURL(urlWithSpaces)).toBe(false); + }); + }); +}); diff --git a/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap index bef0f0f25..f44d3c6a4 100644 --- a/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap @@ -12,6 +12,18 @@ exports[`TinyMceWidget snapshots ImageUploadModal is not rendered 1`] = ` } } > + + + useState(val), // eslint-disable-next-line react-hooks/rules-of-hooks refReady: (val) => useState(val), + // eslint-disable-next-line react-hooks/rules-of-hooks + isInsertLinkModalOpen: (val) => useState(val), }); export const addImagesAndDimensionsToRef = ({ imagesRef, assets, editorContentHtml }) => { @@ -131,6 +133,7 @@ export const setupCustomBehavior = ({ updateContent, openImgModal, openSourceCodeModal, + openInsertLinkModal, editorType, imageUrls, images, @@ -151,6 +154,14 @@ export const setupCustomBehavior = ({ editor, images, setImage, openImgModal, }), }); + + // insert link button + editor.ui.registry.addButton(tinyMCE.buttons.insertLink, { + icon: 'link', + tooltip: 'Insert link', + onAction: openInsertLinkModal, + }); + // overriding the code plugin's icon with 'HTML' text editor.ui.registry.addButton(tinyMCE.buttons.code, { text: 'HTML', @@ -225,6 +236,7 @@ export const editorConfig = ({ initializeEditor, openImgModal, openSourceCodeModal, + openInsertLinkModal, setSelection, updateContent, content, @@ -263,6 +275,7 @@ export const editorConfig = ({ updateContent, openImgModal, openSourceCodeModal, + openInsertLinkModal, lmsEndpointUrl, setImage: setSelection, content, @@ -303,6 +316,15 @@ export const imgModalToggle = () => { }; }; +export const insertLinkModalToggle = () => { + const [isInsertLinkOpen, setIsInsertLinkOpen] = module.state.isInsertLinkModalOpen(false); + return { + isInsertLinkOpen, + openInsertLinkModal: () => setIsInsertLinkOpen(true), + closeInsertLinkModal: () => setIsInsertLinkOpen(false), + }; +}; + export const sourceCodeModalToggle = (editorRef) => { const [isSourceCodeOpen, setIsOpen] = module.state.isSourceCodeModalOpen(false); return { diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js index 2eb52c412..4b4cfc1e5 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js @@ -116,6 +116,7 @@ describe('TinyMceEditor hooks', () => { const addToggleButton = jest.fn(); const openImgModal = jest.fn(); const openSourceCodeModal = jest.fn(); + const openInsertLinkModal = jest.fn(); const setImage = jest.fn(); const updateContent = jest.fn(); const editorType = 'expandable'; @@ -137,11 +138,12 @@ describe('TinyMceEditor hooks', () => { updateContent, openImgModal, openSourceCodeModal, + openInsertLinkModal, setImage, lmsEndpointUrl, })(editor); expect(addIcon.mock.calls).toEqual([['textToSpeech', tinyMCE.textToSpeechIcon]]); - expect(addButton.mock.calls).toEqual([ + expect(addButton.mock.calls).toEqual(expect.arrayContaining([ [tinyMCE.buttons.imageUploadButton, { icon: 'image', tooltip: 'Add Image', onAction: openImgModal }], [tinyMCE.buttons.editImageSettings, { icon: 'image', tooltip: 'Edit Image Settings', onAction: expectedSettingsAction }], [tinyMCE.buttons.code, { text: 'HTML', tooltip: 'Source code', onAction: openSourceCodeModal }], @@ -151,7 +153,7 @@ describe('TinyMceEditor hooks', () => { tooltip: 'Apply a "Question" label to specific text, recognized by screen readers. Recommended to improve accessibility.', onAction: toggleLabelFormatting, }], - ]); + ])); expect(addToggleButton.mock.calls).toEqual([ [tinyMCE.buttons.codeBlock, { icon: 'sourcecode', tooltip: 'Code Block', onAction: toggleCodeFormatting, onSetup: setupCodeFormatting, diff --git a/src/editors/sharedComponents/TinyMceWidget/index.jsx b/src/editors/sharedComponents/TinyMceWidget/index.jsx index 371a6774b..4f0d932fb 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.jsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.jsx @@ -13,6 +13,7 @@ import store from '../../data/store'; import { selectors } from '../../data/redux'; import ImageUploadModal from '../ImageUploadModal'; import SourceCodeModal from '../SourceCodeModal'; +import InsertLinkModal from '../InsertLinkModal'; import * as hooks from './hooks'; const editorConfigDefaultProps = { @@ -38,6 +39,7 @@ export const TinyMceWidget = ({ editorRef, disabled, id, + courseId, editorContentHtml, // editorContent in html form // redux assets, @@ -49,6 +51,7 @@ export const TinyMceWidget = ({ }) => { const { isImgOpen, openImgModal, closeImgModal } = hooks.imgModalToggle(); const { isSourceCodeOpen, openSourceCodeModal, closeSourceCodeModal } = hooks.sourceCodeModalToggle(editorRef); + const { isInsertLinkOpen, openInsertLinkModal, closeInsertLinkModal } = hooks.insertLinkModalToggle(); const { imagesRef } = hooks.useImages({ assets, editorContentHtml }); const imageSelection = hooks.selectedImage(null); @@ -66,6 +69,16 @@ export const TinyMceWidget = ({ {...imageSelection} /> )} + + {isInsertLinkOpen && ( + + )} {editorType === 'text' ? ( ); @@ -115,6 +127,7 @@ TinyMceWidget.propTypes = { isLibrary: PropTypes.bool, assets: PropTypes.shape({}), editorRef: PropTypes.shape({}), + courseId: PropTypes.string, lmsEndpointUrl: PropTypes.string, studioEndpointUrl: PropTypes.string, id: PropTypes.string, @@ -130,6 +143,7 @@ export const mapStateToProps = (state) => ({ lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), studioEndpointUrl: selectors.app.studioEndpointUrl(state), isLibrary: selectors.app.isLibrary(state), + courseId: selectors.app.learningContextId(state), }); export default (connect(mapStateToProps)(TinyMceWidget)); diff --git a/src/editors/sharedComponents/TinyMceWidget/index.test.jsx b/src/editors/sharedComponents/TinyMceWidget/index.test.jsx index 913a0c5ed..7f4efa09a 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.test.jsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.test.jsx @@ -31,6 +31,7 @@ jest.mock('../../data/redux', () => ({ studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })), isLibrary: jest.fn(state => ({ isLibrary: state })), assets: jest.fn(state => ({ assets: state })), + learningContextId: jest.fn(state => ({ learningContext: state })), }, }, })); @@ -52,6 +53,11 @@ jest.mock('./hooks', () => ({ setSelection: jest.fn().mockName('hooks.selectedImage.setSelection'), clearSelection: jest.fn().mockName('hooks.selectedImage.clearSelection'), })), + insertLinkModalToggle: jest.fn(() => ({ + isInsertLinkOpen: true, + openInsertLinkModal: jest.fn().mockName('openModal'), + closeInsertLinkModal: jest.fn().mockName('closeModal'), + })), filterAssets: jest.fn(() => [{ staTICUrl: staticUrl }]), useImages: jest.fn(() => ({ imagesRef: { current: [{ externalUrl: staticUrl }] } })), })); diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js index 509fcd9b7..53f3ac626 100644 --- a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js +++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js @@ -8,6 +8,7 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => { const imageTools = isLibrary ? '' : plugins.imagetools; const imageUploadButton = isLibrary ? '' : buttons.imageUploadButton; const editImageSettings = isLibrary ? '' : buttons.editImageSettings; + const insertLinkButton = isLibrary ? '' : buttons.insertLink; const codePlugin = editorType === 'text' ? plugins.code : ''; const codeButton = editorType === 'text' ? buttons.code : ''; const labelButton = editorType === 'question' ? buttons.customLabelButton : ''; @@ -19,7 +20,6 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => { return ( StrictDict({ plugins: [ - plugins.link, plugins.lists, plugins.codesample, plugins.emoticons, @@ -52,9 +52,9 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => { buttons.outdent, buttons.indent, ], - [imageUploadButton, buttons.link, buttons.unlink, buttons.blockQuote, buttons.codeBlock], + [imageUploadButton, buttons.blockQuote, buttons.codeBlock], [buttons.table, buttons.emoticons, buttons.charmap, buttons.hr], - [buttons.removeFormat, codeButton, buttons.a11ycheck], + [buttons.removeFormat, codeButton, buttons.a11ycheck, insertLinkButton], ]) : false, imageToolbar: mapToolbars([ // [buttons.rotate.left, buttons.rotate.right],