diff --git a/package-lock.json b/package-lock.json
index cf64b2ec0..ed88ce6c0 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.3",
"lodash-es": "^4.17.21",
+ "lodash.clonedeep": "^4.5.0",
"lodash.flatten": "^4.4.0",
"moment": "^2.29.4",
"moment-shortformat": "^2.1.0",
@@ -14870,6 +14871,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 e3558d886..cbe167d6b 100644
--- a/package.json
+++ b/package.json
@@ -77,6 +77,7 @@
"fast-xml-parser": "^4.0.10",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
"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 f7edbe273..90832d471 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',
embediframe: 'embediframe',
});
diff --git a/src/editors/data/redux/index.js b/src/editors/data/redux/index.js
index 05811bbf2..0339b1ea6 100644
--- a/src/editors/data/redux/index.js
+++ b/src/editors/data/redux/index.js
@@ -7,6 +7,7 @@ import * as requests from './requests';
import * as video from './video';
import * as problem from './problem';
import * as game from './game';
+import * as insertlink from './insertlink';
/* eslint-disable import/no-cycle */
export { default as thunkActions } from './thunkActions';
@@ -17,6 +18,7 @@ const modules = {
video,
problem,
game,
+ insertlink,
};
const moduleProps = (propName) => Object.keys(modules).reduce(
diff --git a/src/editors/data/redux/insertlink/index.js b/src/editors/data/redux/insertlink/index.js
new file mode 100644
index 000000000..78455d116
--- /dev/null
+++ b/src/editors/data/redux/insertlink/index.js
@@ -0,0 +1,2 @@
+export { actions, reducer } from './reducers';
+export { default as selectors } from './selectors';
diff --git a/src/editors/data/redux/insertlink/reducers.js b/src/editors/data/redux/insertlink/reducers.js
new file mode 100644
index 000000000..4ea03c0ca
--- /dev/null
+++ b/src/editors/data/redux/insertlink/reducers.js
@@ -0,0 +1,27 @@
+import { createSlice } from '@reduxjs/toolkit';
+import { StrictDict } from '../../../utils';
+
+const initialState = {
+ selectedBlocks: {},
+};
+
+// eslint-disable-next-line no-unused-vars
+const insertlink = createSlice({
+ name: 'insertlink',
+ initialState,
+ reducers: {
+ addBlock: (state, { payload }) => {
+ state.selectedBlocks = { ...state.selectedBlocks, ...payload };
+ },
+ },
+});
+
+const actions = StrictDict(insertlink.actions);
+
+const { reducer } = insertlink;
+
+export {
+ actions,
+ initialState,
+ reducer,
+};
diff --git a/src/editors/data/redux/insertlink/reducers.test.js b/src/editors/data/redux/insertlink/reducers.test.js
new file mode 100644
index 000000000..e91c6a62c
--- /dev/null
+++ b/src/editors/data/redux/insertlink/reducers.test.js
@@ -0,0 +1,28 @@
+import { reducer, actions, initialState } from './reducers';
+
+describe('insertlink reducer', () => {
+ it('should return the initial state', () => {
+ expect(reducer(undefined, {})).toEqual(initialState);
+ });
+
+ it('should handle addBlock', () => {
+ const payload = {
+ block123: { id: 'block123', content: 'Block 123 content' },
+ block456: { id: 'block456', content: 'Block 456 content' },
+ };
+ const action = actions.addBlock(payload);
+
+ const previousState = {
+ selectedBlocks: { block789: { id: 'block789', content: 'Block 789 content' } },
+ };
+
+ const expectedState = {
+ selectedBlocks: {
+ ...previousState.selectedBlocks,
+ ...payload,
+ },
+ };
+
+ expect(reducer(previousState, action)).toEqual(expectedState);
+ });
+});
diff --git a/src/editors/data/redux/insertlink/selectors.js b/src/editors/data/redux/insertlink/selectors.js
new file mode 100644
index 000000000..ee6c7cb43
--- /dev/null
+++ b/src/editors/data/redux/insertlink/selectors.js
@@ -0,0 +1,5 @@
+export const insertlinkState = (state) => state.insertlink;
+
+export default {
+ insertlinkState,
+};
diff --git a/src/editors/data/redux/insertlink/selectors.test.js b/src/editors/data/redux/insertlink/selectors.test.js
new file mode 100644
index 000000000..2e43a4877
--- /dev/null
+++ b/src/editors/data/redux/insertlink/selectors.test.js
@@ -0,0 +1,19 @@
+import { insertlinkState } from './selectors';
+
+describe('insertlink selectors', () => {
+ describe('insertlinkState selector', () => {
+ it('should return the insertlink slice of the state', () => {
+ const state = {
+ insertlink: {
+ selectedBlocks: {
+ block123: { id: 'block123', url: 'https://www.example.com' },
+ block456: { id: 'block456', url: 'https://www.example.com' },
+ },
+ },
+ };
+
+ const { selectedBlocks } = insertlinkState(state);
+ expect(selectedBlocks).toEqual(state.insertlink.selectedBlocks);
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/InsertLinkModal/BlockLink/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/InsertLinkModal/BlockLink/__snapshots__/index.test.jsx.snap
new file mode 100644
index 000000000..de192bfcb
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/BlockLink/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`BlockLink Component snapshot 1`] = `
+
+`;
diff --git a/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx
new file mode 100644
index 000000000..db42fe005
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import { Button } from '@openedx/paragon';
+import { LinkOff } from '@openedx/paragon/icons';
+import formatBlockPath from '../formatBlockPath';
+
+import './index.scss';
+
+const BlockLink = ({ path, onCloseLink }) => {
+ const { title, subTitle } = formatBlockPath(path);
+ return (
+
+ );
+};
+
+BlockLink.propTypes = {
+ path: PropTypes.string.isRequired,
+ onCloseLink: PropTypes.func.isRequired,
+};
+
+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..c1a6da8f6
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { render, fireEvent, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import formatBlockPath from '../formatBlockPath';
+import BlockLink from './index';
+
+describe('BlockLink Component', () => {
+ const defaultProps = {
+ path: 'Some Path',
+ onCloseLink: jest.fn(),
+ };
+
+ const renderComponent = (overrideProps = {}) => render(
+ ,
+ );
+
+ test('renders with default props', () => {
+ renderComponent();
+ expect(screen.getByText('Some Path')).toBeInTheDocument();
+ });
+
+ test('snapshot', () => {
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ test('renders correctly with custom path', () => {
+ const customProps = {
+ ...defaultProps,
+ path: 'Custom Path',
+ };
+ renderComponent(customProps);
+ expect(screen.getByText('Custom Path')).toBeInTheDocument();
+ });
+
+ test('calls onCloseLink when the button is clicked', () => {
+ renderComponent();
+ fireEvent.click(screen.getByTestId('close-link-button'));
+ expect(defaultProps.onCloseLink).toHaveBeenCalledTimes(1);
+ });
+
+ test('renders with valid title and subtitle', () => {
+ const customProps = {
+ path: 'Root Section / Child 1',
+ onCloseLink: jest.fn(),
+ };
+
+ renderComponent(customProps);
+ const { title, subTitle } = formatBlockPath(customProps.path);
+
+ expect(screen.getByText(title)).toBeInTheDocument();
+ expect(screen.getByText(subTitle)).toBeInTheDocument();
+ });
+});
diff --git a/src/editors/sharedComponents/InsertLinkModal/BlocksList/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/InsertLinkModal/BlocksList/__snapshots__/index.test.jsx.snap
new file mode 100644
index 000000000..39e27e427
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,59 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`BlocksList Component snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx
new file mode 100644
index 000000000..eca56c06a
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx
@@ -0,0 +1,156 @@
+import { useState } from 'react';
+import PropTypes from 'prop-types';
+import { Button, TransitionReplace, ActionRow } from '@openedx/paragon';
+import { ArrowForwardIos, ArrowBack } from '@openedx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import blockTypes from '../blockTypes';
+import { getSectionsList, getChildrenFromList } from './utils';
+
+import messages from './messages';
+import './index.scss';
+
+const BlocksList = ({
+ blocks,
+ onBlockSelected,
+ disableBlocks,
+}) => {
+ const intl = useIntl();
+ const messageBlockType = {
+ [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 && (
+
+
+ {messageBlockType[blockState.type]}
+
+ )}
+
+ {listItems.map((block) => (
+
+
+
+ {!isBlockSelectedUnit && (
+
+ )}
+
+
+ ))}
+
+ >
+ );
+};
+
+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.defaultProps = {
+ disableBlocks: false,
+};
+
+BlocksList.propTypes = {
+ blocks: PropTypes.objectOf(blockShape).isRequired,
+ onBlockSelected: PropTypes.func.isRequired,
+ disableBlocks: PropTypes.bool,
+};
+
+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..fa9406a4a
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.test.jsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import { fireEvent, render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import BlocksList from '.';
+
+const mockBlocks = {
+ 'block-key': {
+ id: 'block-key',
+ blockId: 'edx_block-1',
+ lmsWebUrl: 'http://localhost/weburl',
+ legacyWebUrl: 'http://localhost/legacy',
+ studentViewUrl: 'http://localhost/studentview',
+ type: 'chapter',
+ 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',
+ },
+};
+
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+describe('BlocksList Component', () => {
+ // eslint-disable-next-line react/prop-types
+ const IntlProviderWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ let onBlockSelectedMock;
+
+ beforeEach(() => {
+ onBlockSelectedMock = jest.fn();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderComponent = (overrideProps = {}) => render(
+
+
+ ,
+ );
+
+ test('snapshot', async () => {
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ test('renders without crashing', () => {
+ const { getByText } = renderComponent();
+ expect(getByText('Any display name')).toBeInTheDocument();
+ });
+
+ test('should call onBlockSelected when block name is clicked', () => {
+ const { getByTestId } = renderComponent();
+
+ const blockNameButton = getByTestId('block-name');
+ fireEvent.click(blockNameButton);
+ expect(onBlockSelectedMock).toHaveBeenCalledWith(mockBlocks['block-key']);
+ });
+
+ test('should not call onBlockSelected when block navigation is clicked', () => {
+ const { getByTestId } = renderComponent();
+
+ const blockNavigateButton = getByTestId('block-navigation');
+ fireEvent.click(blockNavigateButton);
+ expect(onBlockSelectedMock).not.toHaveBeenCalled();
+ });
+
+ test('should show back button when navigation block happens', () => {
+ const { getByTestId, getByText } = renderComponent();
+
+ const blockNavigateButton = getByTestId('block-navigation');
+ fireEvent.click(blockNavigateButton);
+
+ const backButton = getByTestId('block-back-navigation');
+ expect(getByText('Subsections')).toBeInTheDocument();
+ expect(getByText('Block children 1')).toBeInTheDocument();
+ expect(backButton).toBeInTheDocument();
+ });
+
+ test('should show previous block when back navigation button is clicked', () => {
+ const { getByTestId, getByText } = renderComponent();
+
+ const blockNavigateButton = getByTestId('block-navigation');
+ fireEvent.click(blockNavigateButton);
+
+ const backButton = getByTestId('block-back-navigation');
+ expect(getByText('Subsections')).toBeInTheDocument();
+ expect(getByText('Block children 1')).toBeInTheDocument();
+ expect(backButton).toBeInTheDocument();
+ fireEvent.click(backButton);
+ expect(getByText('Any display name')).toBeInTheDocument();
+ });
+
+ test('should disabled buttons when prop disableBlocks is true', () => {
+ const { getByTestId } = renderComponent({ disableBlocks: true });
+ const backButton = getByTestId('block-navigation');
+ const blockNameButton = getByTestId('block-name');
+
+ expect(backButton).toHaveAttribute('disabled');
+ expect(blockNameButton).toHaveAttribute('disabled');
+ });
+});
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/BlocksList/utils.js b/src/editors/sharedComponents/InsertLinkModal/BlocksList/utils.js
new file mode 100644
index 000000000..78d37c04b
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/utils.js
@@ -0,0 +1,34 @@
+import cloneDeep from 'lodash.clonedeep';
+import blockTypes from '../blockTypes';
+
+/**
+ * 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 [];
+};
diff --git a/src/editors/sharedComponents/InsertLinkModal/BlocksList/utils.test.js b/src/editors/sharedComponents/InsertLinkModal/BlocksList/utils.test.js
new file mode 100644
index 000000000..dcee75d46
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/utils.test.js
@@ -0,0 +1,123 @@
+import { getSectionsList, getChildrenFromList } from './utils';
+
+describe('BlockList utils', () => {
+ 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([]);
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/__snapshots__/index.test.jsx.snap
new file mode 100644
index 000000000..9b20c0b20
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,22 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FilteredBlock Component snapshot 1`] = `
+
+
+
+`;
diff --git a/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.jsx b/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.jsx
new file mode 100644
index 000000000..83a1c8239
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.jsx
@@ -0,0 +1,53 @@
+import { Button } from '@openedx/paragon';
+import PropTypes from 'prop-types';
+
+import formatBlockPath from '../formatBlockPath';
+
+const FilteredBlock = ({
+ block,
+ onBlockFilterClick,
+ blockDisabled,
+}) => {
+ const { title, subTitle } = formatBlockPath(block.path);
+
+ const handleBlockClick = () => {
+ onBlockFilterClick(block);
+ };
+
+ return (
+
+ );
+};
+
+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),
+});
+
+FilteredBlock.defaultProps = {
+ blockDisabled: false,
+};
+
+FilteredBlock.propTypes = {
+ block: PropTypes.objectOf(blockShape).isRequired,
+ onBlockFilterClick: PropTypes.func.isRequired,
+ blockDisabled: PropTypes.bool,
+};
+
+export default FilteredBlock;
diff --git a/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.test.jsx b/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.test.jsx
new file mode 100644
index 000000000..4e24e2581
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.test.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import { render, fireEvent, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import FilterBlock from '.';
+
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+describe('FilteredBlock Component', () => {
+ const mockOnBlockFilterClick = jest.fn();
+
+ const mockBlock = {
+ 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',
+ path: 'Path / To / Block 1',
+ children: ['block-children-1', 'block-children-2'],
+ };
+
+ const renderComponent = (overrideProps = {}) => render(
+ ,
+ );
+
+ test('renders without crashing', () => {
+ const { container } = renderComponent();
+ expect(container).toBeTruthy();
+ });
+
+ test('snapshot', () => {
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ test('calls onBlockFilterClick when the button is clicked', () => {
+ const { getByTestId } = renderComponent();
+ const button = getByTestId('filtered-block-item');
+ fireEvent.click(button);
+ expect(mockOnBlockFilterClick).toHaveBeenCalledWith(mockBlock);
+ });
+
+ test('displays the block title and subtitle', () => {
+ renderComponent();
+ expect(screen.getByText('Path / To')).toBeInTheDocument();
+ expect(screen.getByText('Block 1')).toBeInTheDocument();
+ });
+
+ test('should disabled the button when blockDisabled prop is true', () => {
+ const { getByTestId } = renderComponent({ blockDisabled: true });
+ const button = getByTestId('filtered-block-item');
+ expect(button).toHaveAttribute('disabled');
+ });
+});
diff --git a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/__snapshots__/index.test.jsx.snap
new file mode 100644
index 000000000..e07145532
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,67 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SearchBlocks Component snapshot 1`] = `
+
+`;
diff --git a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx
new file mode 100644
index 000000000..5b26b1f46
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx
@@ -0,0 +1,104 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { SearchField } from '@openedx/paragon';
+import FilteredBlock from '../FilteredBlock';
+import { filterBlocksByText } from './utils';
+
+import messages from './messages';
+import './index.scss';
+
+export const SearchBlocks = ({
+ blocks,
+ onSearchFilter,
+ searchInputValue = '',
+ onBlockSelected,
+ disabledBlocks,
+}) => {
+ 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 = {
+ searchInputValue: '',
+ disabledBlocks: false,
+};
+
+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.isRequired,
+ searchInputValue: PropTypes.string,
+ onBlockSelected: PropTypes.func.isRequired,
+ disabledBlocks: PropTypes.bool,
+};
+
+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..a5325a7f0
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.test.jsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import { fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import SearchBlocks from '.';
+
+const mockBlocks = {
+ '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',
+ path: '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',
+ path: 'Any display name / 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: 'vertical',
+ displayName: 'Block children 2',
+ path: 'Any display name / Block children 2',
+ },
+};
+
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+describe('SearchBlocks Component', () => {
+ // eslint-disable-next-line react/prop-types
+ const IntlProviderWrapper = ({ children }) => (
+ {children}
+ );
+
+ let onSearchFilterMock;
+ let onBlockSelectedMock;
+
+ beforeEach(() => {
+ onSearchFilterMock = jest.fn();
+ onBlockSelectedMock = jest.fn();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderComponent = (overrideProps = {}) => render(
+
+
+ ,
+ );
+
+ test('snapshot', async () => {
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ test('renders without crashing', () => {
+ const { getByTestId } = renderComponent();
+ expect(getByTestId('search-field')).toBeInTheDocument();
+ });
+
+ test('displays placeholder text in the SearchField', () => {
+ const { getByPlaceholderText } = renderComponent();
+
+ const searchField = getByPlaceholderText('Search course pages');
+ expect(searchField).toBeInTheDocument();
+ });
+
+ test('updates searchField state on input change', async () => {
+ renderComponent();
+
+ const inputElement = screen.getByRole('searchbox');
+ userEvent.type(inputElement, 'New value');
+
+ expect(onSearchFilterMock).toHaveBeenCalledWith(true);
+ });
+
+ test('updates searchField state on input change empty value', async () => {
+ renderComponent();
+
+ const inputElement = screen.getByRole('searchbox');
+ userEvent.type(inputElement, ' ');
+
+ expect(onSearchFilterMock).toHaveBeenCalledWith(false);
+ });
+
+ test('search a block when the searchInputValue matches', async () => {
+ const { getByTestId } = renderComponent({ searchInputValue: 'Block children 1' });
+
+ const blockFiltered = getByTestId('filtered-block-item');
+ expect(blockFiltered).toBeInTheDocument();
+ });
+
+ test('should call onBlockSelected when a block is selected', async () => {
+ const { getByTestId } = renderComponent({ searchInputValue: 'Block children 1' });
+
+ const blockFiltered = getByTestId('filtered-block-item');
+ expect(blockFiltered).toBeInTheDocument();
+ fireEvent.click(blockFiltered);
+ expect(onBlockSelectedMock).toHaveBeenCalledWith(mockBlocks['block-children-1']);
+ });
+
+ test('should disable the blocks filtered when disabledBlocks is true', async () => {
+ const { queryAllByTestId } = renderComponent({ searchInputValue: 'Block', disabledBlocks: true });
+
+ const blocksFiltered = queryAllByTestId('filtered-block-item');
+ blocksFiltered.forEach((button) => {
+ expect(button).toHaveAttribute('disabled');
+ });
+ });
+});
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/SearchBlocks/utils.js b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/utils.js
new file mode 100644
index 000000000..73f0d34e9
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/utils.js
@@ -0,0 +1,23 @@
+/* eslint-disable import/prefer-default-export */
+import cloneDeep from 'lodash.clonedeep';
+
+/**
+ * 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;
+ }, {});
+};
diff --git a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/utils.test.js b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/utils.test.js
new file mode 100644
index 000000000..3aaf1024c
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/utils.test.js
@@ -0,0 +1,62 @@
+import { filterBlocksByText } from './utils';
+
+describe('SearchBlocks utils', () => {
+ 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',
+ },
+ });
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/InsertLinkModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/InsertLinkModal/__snapshots__/index.test.jsx.snap
new file mode 100644
index 000000000..297f07013
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,8 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`InsertLinkModal snapshot 1`] = `
+
+`;
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/blockTypes.js b/src/editors/sharedComponents/InsertLinkModal/blockTypes.js
new file mode 100644
index 000000000..cc74e8901
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/blockTypes.js
@@ -0,0 +1,7 @@
+const blockTypes = {
+ section: 'chapter',
+ subsection: 'sequential',
+ unit: 'vertical',
+};
+
+export default blockTypes;
diff --git a/src/editors/sharedComponents/InsertLinkModal/formatBlockPath.js b/src/editors/sharedComponents/InsertLinkModal/formatBlockPath.js
new file mode 100644
index 000000000..213819dde
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/formatBlockPath.js
@@ -0,0 +1,28 @@
+/**
+ * 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.
+ */
+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,
+ };
+};
+
+export default formatBlockPath;
diff --git a/src/editors/sharedComponents/InsertLinkModal/formatBlockPath.test.js b/src/editors/sharedComponents/InsertLinkModal/formatBlockPath.test.js
new file mode 100644
index 000000000..32ed81b66
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/formatBlockPath.test.js
@@ -0,0 +1,48 @@
+import formatBlockPath from './formatBlockPath';
+
+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: '',
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/InsertLinkModal/index.jsx b/src/editors/sharedComponents/InsertLinkModal/index.jsx
new file mode 100644
index 000000000..098d69bd5
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/index.jsx
@@ -0,0 +1,250 @@
+import { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import PropTypes from 'prop-types';
+import { logError } from '@edx/frontend-platform/logging';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Button,
+ Tabs,
+ Tab,
+} from '@openedx/paragon';
+import { actions, selectors } from '../../data/redux/insertlink';
+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 [, setInvalidUrlInput] = useState(false);
+ const [inputUrlValue, setInputUrlValue] = useState('');
+ const dispatch = useDispatch();
+ const { selectedBlocks } = useSelector(selectors.insertlinkState);
+
+ const handleSearchedBlocks = (isSearched) => {
+ setBlocksSearched(isSearched);
+ };
+
+ const handleSelectedBlock = (blockSelectedFromList) => {
+ setBlocksSelected(blockSelectedFromList);
+ setInputUrlValue('');
+ };
+
+ const handleCloseLink = () => {
+ setSearchField('');
+ setBlocksSelected(null);
+ };
+
+ /* istanbul ignore next */
+ const handleSave = () => {
+ const editor = editorRef.current;
+ const urlPath = blockSelected?.lmsWebUrl || inputUrlValue;
+ const blockId = blockSelected?.blockId;
+ const linkRegex = /]*><\/a>/gi;
+ if (editor && urlPath) {
+ const validateUrl = isValidURL(urlPath);
+
+ if (!validateUrl) {
+ setInvalidUrlInput(true);
+ return;
+ }
+
+ const selectedRange = editor.selection.getRng();
+ const selectedNode = editor.selection.getNode();
+ const textContent = editor.selection.getContent({ format: 'text' });
+ const selectedText = textContent || selectedNode.textContent;
+
+ const newLinkNode = editor.dom.create('a', {
+ href: urlPath,
+ 'data-mce-href': urlPath,
+ 'data-block-id': blockId,
+ target: '_blank',
+ });
+
+ if (textContent) {
+ // If the selected node is a text node, replace the selection with the new link
+ newLinkNode.textContent = selectedText;
+
+ selectedRange.deleteContents();
+ selectedRange.insertNode(newLinkNode);
+ } else {
+ // If the selected node is an element node, wrap its text content in the new link
+ newLinkNode.textContent = selectedNode.textContent;
+ selectedNode.textContent = '';
+ selectedNode.appendChild(newLinkNode);
+ }
+
+ // Remove empty "a" tags after replacing URLs (if needed)
+ const editorContent = editor.getContent();
+ const modifiedContent = editorContent.replace(linkRegex, '');
+ editor.setContent(modifiedContent);
+
+ dispatch(actions.addBlock({ [blockId]: blockSelected }));
+ }
+
+ if (editor && !blockId) {
+ const selectedNode = editor.selection.getNode();
+
+ if (selectedNode.nodeName === 'A') {
+ // If the selected node is a link, unwrap it
+ editor.dom.remove(selectedNode, true);
+ } else {
+ // If the selected node contains links, remove them
+ const links = selectedNode.querySelectorAll('a');
+ links.forEach(link => editor.dom.remove(link, true));
+ }
+ // Update the editor content
+ editor.setContent(editor.getContent());
+ }
+
+ 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();
+ }, []);
+
+ useEffect(() => {
+ /* istanbul ignore next */
+ const editor = editorRef.current;
+ if (editor) {
+ const selectionNode = editor.selection.getNode();
+ const selectedHTML = editor.selection.getContent({ format: 'html' }) || selectionNode.outerHTML;
+ const regexDataBlockId = /data-block-id="([^"]+)"/;
+ const regexHref = /href="([^"]+)"/;
+ const matchDataBlockId = selectedHTML.match(regexDataBlockId);
+ const matchHreUrl = selectedHTML.match(regexHref);
+
+ // Extracting the value from the match
+ const dataBlockId = matchDataBlockId ? matchDataBlockId[1] : null;
+ const hrefUrl = matchHreUrl ? matchHreUrl[1] : null;
+ const blockSelectedUrl = selectedBlocks?.[dataBlockId]?.lmsWebUrl;
+ const hasExternalUrl = hrefUrl !== blockSelectedUrl;
+
+ if (selectedHTML && !dataBlockId) {
+ const selectedNode = editor.selection.getNode();
+ const parentNode = editor.dom.getParent(selectedNode, 'a');
+ if (parentNode) {
+ const dataBlockIdParent = parentNode.getAttribute('data-block-id');
+ const url = parentNode.getAttribute('href');
+ const blockIsValid = dataBlockIdParent in selectedBlocks;
+ const blockIdFormat = blockSelectedUrl ?? selectedBlocks?.[dataBlockIdParent]?.lmsWebUrl;
+ const hasValidUrl = url === blockIdFormat;
+ if (dataBlockIdParent && blockIsValid && hasValidUrl) {
+ setBlocksSelected(selectedBlocks[dataBlockIdParent]);
+ } else {
+ setBlocksSelected(null);
+ }
+ }
+ }
+
+ if (dataBlockId && hasExternalUrl) {
+ setBlocksSelected(null);
+ }
+
+ if (dataBlockId && !hasExternalUrl) {
+ const blockIsValid = dataBlockId in selectedBlocks;
+ if (dataBlockId && blockIsValid) {
+ setBlocksSelected(selectedBlocks[dataBlockId]);
+ }
+ }
+ }
+ }, []);
+
+ return (
+
+ {intl.formatMessage(messages.insertLinkModalButtonSave)}
+
+ )}
+ >
+ {blockSelected ? (
+
+ ) : (
+
+
+
+
+ {!blocksSearched && (
+
+ )}
+
+
+ )}
+
+ );
+};
+
+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,
+ getRng: PropTypes.func,
+ getNode: PropTypes.func,
+ }),
+ getContent: PropTypes.func,
+ setContent: PropTypes.func,
+ dom: PropTypes.shape({
+ create: PropTypes.func,
+ getParent: PropTypes.func,
+ remove: 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/index.test.jsx b/src/editors/sharedComponents/InsertLinkModal/index.test.jsx
new file mode 100644
index 000000000..4fb7261b4
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/index.test.jsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import { render, screen } from '@testing-library/react';
+import { useSelector } from 'react-redux';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { logError } from '@edx/frontend-platform/logging';
+
+import * as api from './api';
+
+import InsertLinkModal from '.';
+
+jest.mock('@edx/frontend-platform/logging', () => ({
+ logError: jest.fn(),
+}));
+
+jest.mock('./api', () => ({
+ getBlocksFromCourse: jest.fn().mockResolvedValue({
+ blocks: {},
+ root: {},
+ }),
+}));
+
+jest.mock('./utils', () => ({
+ ...jest.requireActual('./utils'),
+ formatBlocks: jest.fn(),
+ isValidURL: jest.fn(),
+}));
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+ useDispatch: jest.fn(),
+}));
+
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+describe('InsertLinkModal', () => {
+ const mockProps = {
+ courseId: 'course123',
+ isOpen: true,
+ onClose: jest.fn(),
+ editorRef: {
+ current: {
+ selection: {
+ getContent: () => 'Sample content',
+ setContent: jest.fn(),
+ getRng: jest.fn(),
+ getNode: jest.fn(),
+ },
+ },
+ },
+ };
+
+ // eslint-disable-next-line react/prop-types
+ const IntlProviderWrapper = ({ children }) => (
+ {children}
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useSelector.mockReturnValue({ selectedBlocks: {} });
+ });
+
+ const renderComponent = (overrideProps = {}) => render(
+
+
+ ,
+ );
+
+ test('snapshot', async () => {
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ test('renders without crashing', async () => {
+ renderComponent();
+ expect(screen.getByText('Link to')).toBeInTheDocument();
+ });
+
+ test('should show Course pages tab', () => {
+ renderComponent();
+
+ const tabs = screen.getAllByRole('tab');
+ const [coursePagesTab] = tabs;
+ expect(coursePagesTab).toHaveTextContent('Course pages');
+ });
+
+ test('should find Cancel and Save buttons', () => {
+ renderComponent();
+
+ const cancelButton = screen.getByText('Cancel');
+ expect(cancelButton).toBeInTheDocument();
+
+ const saveButton = screen.getByText('Save');
+ expect(saveButton).toBeInTheDocument();
+ });
+
+ test('should call logError on API error', async () => {
+ api.getBlocksFromCourse.mockRejectedValue(new Error('API error'));
+
+ renderComponent();
+
+ // Wait for the useEffect to complete
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(logError).toHaveBeenCalledWith(new Error('API error'));
+ });
+});
diff --git a/src/editors/sharedComponents/InsertLinkModal/messages.js b/src/editors/sharedComponents/InsertLinkModal/messages.js
new file mode 100644
index 000000000..89ae1f116
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/messages.js
@@ -0,0 +1,41 @@
+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',
+ },
+ insertLinkModalUrlNotSelectedErrorMessage: {
+ id: 'insert.link.modal.url.not.selected.error.message',
+ defaultMessage: 'The text for the URL was not selected',
+ description: 'Feedback message error when user does not select a text for the link',
+ },
+});
+
+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..437cbb58d
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/utils.js
@@ -0,0 +1,61 @@
+import cloneDeep from 'lodash.clonedeep';
+
+import blockTypes from './blockTypes';
+
+/**
+ * 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;
+};
+
+/**
+ * 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) => {
+ try {
+ // eslint-disable-next-line no-new
+ new URL(url);
+ return true;
+ } catch (error) {
+ return false;
+ }
+};
diff --git a/src/editors/sharedComponents/InsertLinkModal/utils.test.js b/src/editors/sharedComponents/InsertLinkModal/utils.test.js
new file mode 100644
index 000000000..f864822f2
--- /dev/null
+++ b/src/editors/sharedComponents/InsertLinkModal/utils.test.js
@@ -0,0 +1,157 @@
+import {
+ addPathToBlocks,
+ formatBlocks,
+ 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('formatBlocks', () => {
+ const mockBlocks = {
+ blockRoot: {
+ id: 'blockRoot',
+ blockId: 'edx_block-1',
+ lmsWebUrl: 'http://localhost/weburl',
+ legacyWebUrl: 'http://localhost/legacy',
+ studentViewUrl: 'http://localhost/studentview',
+ type: 'character',
+ displayName: 'Any display name',
+ children: ['block1', 'block2'],
+ },
+ block1: {
+ id: 'block1',
+ blockId: 'edx_block-1',
+ lmsWebUrl: 'http://localhost/weburl',
+ legacyWebUrl: 'http://localhost/legacy',
+ studentViewUrl: 'http://localhost/studentview',
+ displayName: 'Block children 1',
+ type: 'sequential',
+ },
+ block2: {
+ id: 'block2',
+ blockId: 'edx_block-2',
+ lmsWebUrl: 'http://localhost/weburl',
+ legacyWebUrl: 'http://localhost/legacy',
+ studentViewUrl: 'http://localhost/studentview',
+ type: 'sequential',
+ displayName: 'Block children 2',
+ },
+ };
+
+ test('correctly formats blocks with path information', () => {
+ const formattedBlocks = formatBlocks(mockBlocks, 'blockRoot');
+ expect(formattedBlocks.block1.path).toBeDefined();
+ expect(formattedBlocks.block2.path).toBeDefined();
+ });
+
+ test('correctly assigns parentId to blocks', () => {
+ const formattedBlocks = formatBlocks(mockBlocks, 'blockRoot');
+ expect(formattedBlocks.block1.parentId).toBeDefined();
+ expect(formattedBlocks.block2.parentId).toBeDefined();
+ });
+
+ test('returns an empty object when blocks are empty', () => {
+ const formattedBlocks = formatBlocks({}, 'blockRoot');
+ expect(formattedBlocks).toEqual({});
+ });
+
+ test('handles invalid input gracefully', () => {
+ const formattedBlocks = formatBlocks(mockBlocks, 'nonExistingRoot');
+ expect(formattedBlocks.blockRoot.parentId).toBeNull();
+ expect(formattedBlocks.block1.parentId).toBeNull();
+ expect(formattedBlocks.block2.parentId).toBeNull();
+ });
+
+ test('maintains the original structure of blocks', () => {
+ const formattedBlocks = formatBlocks(mockBlocks, 'blockRoot');
+ expect(formattedBlocks.block1.id).toEqual('block1');
+ expect(formattedBlocks.block1.displayName).toEqual('Block children 1');
+ });
+ });
+ 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/SelectionModal/SearchSort.jsx b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx
index 57ef43db6..d469ae98f 100644
--- a/src/editors/sharedComponents/SelectionModal/SearchSort.jsx
+++ b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx
@@ -74,23 +74,23 @@ export const SearchSort = ({
{ onFilterClick && (
-
-
-
-
-
- {Object.keys(filterKeys).map(key => (
-
-
-
- ))}
-
-
+
+
+
+
+
+ {Object.keys(filterKeys).map(key => (
+
+
+
+ ))}
+
+
)}
{ showSwitch && (
diff --git a/src/editors/sharedComponents/SourceCodeModal/index.jsx b/src/editors/sharedComponents/SourceCodeModal/index.jsx
index 3cc0638e1..e675f6e23 100644
--- a/src/editors/sharedComponents/SourceCodeModal/index.jsx
+++ b/src/editors/sharedComponents/SourceCodeModal/index.jsx
@@ -30,7 +30,7 @@ export const SourceCodeModal = ({
- )}
+ )}
isOpen={isOpen}
title={intl.formatMessage(messages.titleLabel)}
>
diff --git a/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap
index 4d585d3b6..67e94f61b 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 +134,8 @@ export const setupCustomBehavior = ({
updateContent,
openImgModal,
openSourceCodeModal,
+ openInsertLinkModal,
+ translations,
editorType,
imageUrls,
images,
@@ -151,6 +156,28 @@ export const setupCustomBehavior = ({
editor, images, setImage, openImgModal,
}),
});
+
+ // insert link button
+ /* addButton only has setDisabled/isDisabled in api while
+ addToggleButton has setDisabled/isDisabled/setActive/isActive
+ addButton doc: https://www.tiny.cloud/docs/ui-components/typesoftoolbarbuttons/#basicbutton
+ addToggleButton: doc: https://www.tiny.cloud/docs/ui-components/typesoftoolbarbuttons/#togglebutton
+ */
+ editor.ui.registry.addToggleButton(tinyMCE.buttons.insertLink, {
+ icon: 'new-tab',
+ tooltip: translations?.insertLinkTooltipTitle ?? '',
+ onAction: openInsertLinkModal,
+ onSetup(api) {
+ editor.on('SelectionChange', () => {
+ const node = editor.selection.getNode();
+ const isLink = node.nodeName === 'A';
+ const hasTextSelected = editor.selection.getContent().length > 0;
+ api.setActive(isLink);
+ api.setDisabled(!isLink && !hasTextSelected);
+ });
+ },
+ });
+
// overriding the code plugin's icon with 'HTML' text
editor.ui.registry.addButton(tinyMCE.buttons.code, {
text: 'HTML',
@@ -225,6 +252,8 @@ export const editorConfig = ({
initializeEditor,
openImgModal,
openSourceCodeModal,
+ openInsertLinkModal,
+ translations = {},
setSelection,
updateContent,
content,
@@ -263,6 +292,8 @@ export const editorConfig = ({
updateContent,
openImgModal,
openSourceCodeModal,
+ openInsertLinkModal,
+ translations,
lmsEndpointUrl,
setImage: setSelection,
content,
@@ -303,6 +334,29 @@ export const imgModalToggle = () => {
};
};
+export const insertLinkModalToggle = () => {
+ const [isInsertLinkOpen, setIsInsertLinkOpen] = module.state.isInsertLinkModalOpen(false);
+ return {
+ isInsertLinkOpen,
+ openInsertLinkModal: () => setIsInsertLinkOpen(true),
+ closeInsertLinkModal: () => setIsInsertLinkOpen(false),
+ };
+};
+
+export const useTranslations = (messages = {}) => {
+ const intl = useIntl();
+ const messagesKeys = Object.keys(messages);
+ const translationsFormatted = messagesKeys.reduce(
+ (prevMessages, messageKey) => ({
+ ...prevMessages,
+ [messageKey]: intl.formatMessage(messages[messageKey]),
+ }),
+ {},
+ );
+
+ return translationsFormatted;
+};
+
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..5dcce107f 100644
--- a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js
+++ b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js
@@ -1,3 +1,5 @@
+import React from 'react';
+import { createIntl, useIntl } from '@edx/frontend-platform/i18n';
import { MockUseState } from '../../../testUtils';
import tinyMCE from '../../data/constants/tinyMCE';
@@ -13,6 +15,11 @@ jest.mock('react', () => ({
useCallback: (cb, prereqs) => ({ cb, prereqs }),
}));
+jest.mock('@edx/frontend-platform/i18n', () => ({
+ ...jest.requireActual('@edx/frontend-platform/i18n'),
+ useIntl: jest.fn(),
+}));
+
const state = new MockUseState(module);
const moduleKeys = keyStore(module);
@@ -75,6 +82,7 @@ describe('TinyMceEditor hooks', () => {
state.testGetter(state.keys.isImageModalOpen);
state.testGetter(state.keys.isSourceCodeModalOpen);
state.testGetter(state.keys.imageSelection);
+ state.testGetter(state.keys.isInsertLinkModalOpen);
});
describe('non-state hooks', () => {
@@ -116,6 +124,8 @@ describe('TinyMceEditor hooks', () => {
const addToggleButton = jest.fn();
const openImgModal = jest.fn();
const openSourceCodeModal = jest.fn();
+ const openInsertLinkModal = jest.fn();
+ const translations = {};
const setImage = jest.fn();
const updateContent = jest.fn();
const editorType = 'expandable';
@@ -137,11 +147,13 @@ describe('TinyMceEditor hooks', () => {
updateContent,
openImgModal,
openSourceCodeModal,
+ openInsertLinkModal,
+ translations,
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,12 +163,18 @@ 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,
- }],
- ]);
+ ]));
+ expect(addToggleButton.mock.calls).toContainEqual(
+ expect.arrayContaining([
+ tinyMCE.buttons.codeBlock,
+ {
+ icon: 'sourcecode',
+ tooltip: 'Code Block',
+ onAction: toggleCodeFormatting,
+ onSetup: setupCodeFormatting,
+ },
+ ]),
+ );
expect(openImgModal).not.toHaveBeenCalled();
expect(editor.on).toHaveBeenCalled();
});
@@ -228,6 +246,7 @@ describe('TinyMceEditor hooks', () => {
studioEndpointUrl: 'sOmEoThEruRl.cOm',
images: mockImagesRef,
isLibrary: false,
+ translations: {},
};
const evt = 'fakeEvent';
const editor = 'myEditor';
@@ -239,6 +258,7 @@ describe('TinyMceEditor hooks', () => {
props.openSourceCodeModal = jest.fn();
props.initializeEditor = jest.fn();
props.updateContent = jest.fn();
+ props.openInsertLinkModal = jest.fn();
jest.spyOn(module, moduleKeys.setupCustomBehavior)
.mockImplementationOnce(setupCustomBehavior);
output = module.editorConfig(props);
@@ -347,6 +367,8 @@ describe('TinyMceEditor hooks', () => {
imageUrls: module.fetchImageUrls(props.images),
images: mockImagesRef,
lmsEndpointUrl: props.lmsEndpointUrl,
+ openInsertLinkModal: props.openInsertLinkModal,
+ translations: props.translations,
}),
);
});
@@ -402,6 +424,74 @@ describe('TinyMceEditor hooks', () => {
});
});
+ describe('insertLinkModalToggle', () => {
+ const hookKey = state.keys.isInsertLinkModalOpen;
+ beforeEach(() => {
+ hook = module.insertLinkModalToggle();
+ });
+ test('isInsertLinkOpen: state value', () => {
+ expect(hook.isInsertLinkOpen).toEqual(state.stateVals[hookKey]);
+ });
+ test('openInsertLinkModal: calls setter with true', () => {
+ hook.openInsertLinkModal();
+ expect(state.setState[hookKey]).toHaveBeenCalledWith(true);
+ });
+ test('closeInsertLinkModal: calls setter with false', () => {
+ hook.closeInsertLinkModal();
+ expect(state.setState[hookKey]).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('useTranslations', () => {
+ beforeEach(() => {
+ hook = module.useTranslations;
+ const intl = createIntl({
+ locale: 'en',
+ messages: {
+ 'app.message1': 'This is message 1',
+ 'app.message2': 'This is message 2',
+ },
+ });
+
+ jest.spyOn(React, 'useContext').mockReturnValue(intl);
+
+ const intlFormatMessage = {
+ formatMessage: jest.fn((message) => `Translated: ${message?.defaultMessage}`),
+ };
+
+ useIntl.mockReturnValue(intlFormatMessage);
+ });
+ test('should translate messages correctly', () => {
+ const testMessages = {
+ message1: {
+ id: 'app.message1',
+ defaultMessage: 'This is message 1',
+ },
+ message2: {
+ id: 'app.message2',
+ defaultMessage: 'This is message 2',
+ },
+ };
+
+ const result = hook(testMessages);
+
+ expect(result.message1).toBe('Translated: This is message 1');
+ expect(result.message2).toBe('Translated: This is message 2');
+ });
+
+ test('should return an empty object without messages', () => {
+ const testMessages = {};
+ const result = hook(testMessages);
+ expect(result).toEqual({});
+ });
+
+ test('should handle undefined messages', () => {
+ const testMessages = undefined;
+ const result = hook(testMessages);
+ expect(result).toEqual({});
+ });
+ });
+
describe('openModalWithSelectedImage', () => {
const setImage = jest.fn();
const openImgModal = jest.fn();
diff --git a/src/editors/sharedComponents/TinyMceWidget/index.jsx b/src/editors/sharedComponents/TinyMceWidget/index.jsx
index d2c8a3ff1..2822399b7 100644
--- a/src/editors/sharedComponents/TinyMceWidget/index.jsx
+++ b/src/editors/sharedComponents/TinyMceWidget/index.jsx
@@ -13,7 +13,9 @@ 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';
+import messages from './messages';
import './customTinyMcePlugins/embedIframePlugin';
const editorConfigDefaultProps = {
@@ -39,6 +41,7 @@ export const TinyMceWidget = ({
editorRef,
disabled,
id,
+ courseId,
editorContentHtml, // editorContent in html form
// redux
assets,
@@ -48,8 +51,10 @@ export const TinyMceWidget = ({
onChange,
...editorConfig
}) => {
+ const translations = hooks.useTranslations(messages);
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);
@@ -67,6 +72,16 @@ export const TinyMceWidget = ({
{...imageSelection}
/>
)}
+
+ {isInsertLinkOpen && (
+
+ )}
{editorType === 'text' ? (
);
@@ -116,6 +131,7 @@ TinyMceWidget.propTypes = {
isLibrary: PropTypes.bool,
assets: PropTypes.shape({}),
editorRef: PropTypes.shape({}),
+ courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
studioEndpointUrl: PropTypes.string,
id: PropTypes.string,
@@ -131,6 +147,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 4bfb7a081..6ca2fde2e 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,12 @@ 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'),
+ })),
+ useTranslations: jest.fn(() => ({})),
filterAssets: jest.fn(() => [{ staTICUrl: staticUrl }]),
useImages: jest.fn(() => ({ imagesRef: { current: [{ externalUrl: staticUrl }] } })),
}));
diff --git a/src/editors/sharedComponents/TinyMceWidget/messages.js b/src/editors/sharedComponents/TinyMceWidget/messages.js
new file mode 100644
index 000000000..7526eefb9
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/messages.js
@@ -0,0 +1,11 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ insertLinkTooltipTitle: {
+ id: 'insert.link.tooltip.title',
+ defaultMessage: 'Jump to course section',
+ description: 'Title for the tooltip in the text editor to insert a link',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js
index 61386de47..edb5a43ac 100644
--- a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js
+++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js
@@ -19,7 +19,6 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
return (
StrictDict({
plugins: [
- plugins.link,
plugins.lists,
plugins.codesample,
plugins.emoticons,
@@ -34,6 +33,7 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
plugins.a11ychecker,
plugins.powerpaste,
plugins.embediframe,
+ plugins.link,
].join(' '),
menubar: false,
toolbar: toolbar ? mapToolbars([
@@ -53,9 +53,11 @@ 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.embediframe],
+ [buttons.link, buttons.unlink],
+ [buttons.insertLink],
]) : false,
imageToolbar: mapToolbars([
// [buttons.rotate.left, buttons.rotate.right],
diff --git a/src/setupTest.js b/src/setupTest.js
index fec165894..aceac82f6 100644
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -153,3 +153,9 @@ jest.mock('react-redux', () => {
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({
a11ycheckerCss: '',
}));
+
+global.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+}));