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 (
+
+ );
+};
+
+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..c513de5f2
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/utils.test.js
@@ -0,0 +1,339 @@
+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, null, 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, parentBlock.displayName, testBlocks, rootBlockId, parentBlock.id);
+ addPathToBlocks(nestedBlock2, parentBlock.displayName, testBlocks, rootBlockId, parentBlock.id);
+
+ 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',
+ // other properties
+ },
+ block2: {
+ id: 'block2',
+ type: 'vertical',
+ // other properties
+ },
+ };
+ const result = getSectionsList(blocks);
+ expect(result).toEqual([]);
+ });
+
+ test('returns an array containing sections from the blocks object', () => {
+ const blocks = {
+ section1: {
+ id: 'section1',
+ type: 'chapter',
+ // other properties
+ },
+ block1: {
+ id: 'block1',
+ type: 'unit',
+ // other properties
+ },
+ section2: {
+ id: 'section2',
+ type: 'chapter',
+ // other properties
+ },
+ block2: {
+ id: 'block2',
+ type: 'vertical',
+ // other properties
+ },
+ };
+ const result = getSectionsList(blocks);
+ const expected = [
+ {
+ id: 'section1',
+ type: 'chapter',
+ // other properties
+ },
+ {
+ id: 'section2',
+ type: 'chapter',
+ // other properties
+ },
+ ];
+ expect(result).toEqual(expected);
+ });
+ });
+
+ describe('getChildrenFromList function', () => {
+ test('returns an empty array when blockSelected has no children', () => {
+ const blocks = {
+ parentBlock: {
+ id: 'parentBlock',
+ // other properties
+ },
+ };
+
+ 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'],
+ // other properties
+ },
+ child1: {
+ id: 'child1',
+ // other properties
+ },
+ child2: {
+ id: 'child2',
+ // other properties
+ },
+ };
+
+ 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,
+ // other properties
+ },
+ };
+
+ 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: [],
+ // other properties
+ },
+ };
+
+ 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],