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/BlocksList/index.jsx b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx
index bbcebb0b6..b1f64754f 100644
--- a/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx
+++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx
@@ -108,15 +108,15 @@ const BlocksList = ({ blocks, onBlockSelected }) => {
{block.displayName}
{!isBlockSelectedUnit && (
-
)}
diff --git a/src/editors/sharedComponents/InsertLinkModal/index.jsx b/src/editors/sharedComponents/InsertLinkModal/index.jsx
index b334ccc4d..67f6b7891 100644
--- a/src/editors/sharedComponents/InsertLinkModal/index.jsx
+++ b/src/editors/sharedComponents/InsertLinkModal/index.jsx
@@ -1,4 +1,5 @@
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';
@@ -8,6 +9,7 @@ import {
Tab,
Form,
} from '@openedx/paragon';
+import { actions, selectors } from '../../data/redux/insertlink';
import BaseModal from '../BaseModal';
import BlocksList from './BlocksList';
import BlockLink from './BlockLink';
@@ -29,8 +31,11 @@ const InsertLinkModal = ({
const [blocksSearched, setBlocksSearched] = useState(false);
const [blockSelected, setBlocksSelected] = useState(null);
const [blocksList, setBlocksList] = useState(null);
- const [invalidUrlInput, setInvalidUrlInput] = useState(false);
+ const [, setInvalidUrlInput] = useState(false);
const [inputUrlValue, setInputUrlValue] = useState('');
+ const [errorUrlNotSelected, setErrorUrlNotSelected] = useState(false);
+ const dispatch = useDispatch();
+ const { selectedBlocks } = useSelector(selectors.insertlinkState);
const handleSearchedBlocks = (isSearched) => {
setBlocksSearched(isSearched);
@@ -46,14 +51,11 @@ const InsertLinkModal = ({
setBlocksSelected(null);
};
- const handleChangeInputUrl = ({ target: { value } }) => {
- setInputUrlValue(value);
- };
-
/* istanbul ignore next */
const handleSave = () => {
const editor = editorRef.current;
const urlPath = blockSelected?.lmsWebUrl || inputUrlValue;
+ const blockId = blockSelected?.blockId;
if (editor && urlPath) {
const validateUrl = isValidURL(urlPath);
@@ -62,12 +64,26 @@ const InsertLinkModal = ({
return;
}
+ const selectedRange = editor.selection.getRng();
const selectedText = editor.selection.getContent({ format: 'text' });
- if (selectedText.trim() !== '') {
- const linkHtml = `${selectedText}`;
- editor.selection.setContent(linkHtml);
- }
+ const newLinkNode = editor.dom.create('a', {
+ href: urlPath,
+ 'data-mce-href': urlPath,
+ 'data-block-id': blockId,
+ target: '_blank',
+ });
+
+ newLinkNode.textContent = selectedText;
+
+ selectedRange.deleteContents();
+ selectedRange.insertNode(newLinkNode);
+ // Remove empty "a" tags after replacing URLs
+ const editorContent = editor.getContent();
+ const modifiedContent = editorContent.replace(/]*><\/a>/gi, '');
+ editor.setContent(modifiedContent);
+
+ dispatch(actions.addBlock({ [blockId]: blockSelected }));
}
onClose();
@@ -91,13 +107,48 @@ const InsertLinkModal = ({
getBlocksList();
}, []);
+ useEffect(() => {
+ /* istanbul ignore next */
+ const editor = editorRef.current;
+ if (editor) {
+ const selectedHTML = editor.selection.getContent({ format: 'html' });
+ const regex = /data-block-id="([^"]+)"/;
+ const match = selectedHTML.match(regex);
+
+ // Extracting the value from the match
+ const dataBlockId = match ? match[1] : null;
+ 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 blockIsValid = dataBlockIdParent in selectedBlocks;
+ if (dataBlockIdParent && blockIsValid) {
+ setBlocksSelected(selectedBlocks[dataBlockIdParent]);
+ }
+ }
+ }
+
+ if (dataBlockId) {
+ const blockIsValid = dataBlockId in selectedBlocks;
+ if (dataBlockId && blockIsValid) {
+ setBlocksSelected(selectedBlocks[dataBlockId]);
+ }
+ }
+
+ if (!selectedHTML) {
+ setErrorUrlNotSelected(true);
+ }
+ }
+ }, []);
+
return (
+
{intl.formatMessage(messages.insertLinkModalButtonSave)}
)}
@@ -116,6 +167,12 @@ const InsertLinkModal = ({
title={intl.formatMessage(messages.insertLinkModalCoursePagesTabTitle)}
className="col-12 w-100 tabs-container"
>
+ {errorUrlNotSelected && (
+
+ {intl.formatMessage(messages.insertLinkModalUrlNotSelectedErrorMessage)}
+
+ )}
+
)}
-
-
-
- {invalidUrlInput && (
-
- {intl.formatMessage(messages.insertLinkModalInputErrorMessage)}
-
- )}
-
-
)}
@@ -162,6 +201,14 @@ InsertLinkModal.propTypes = {
selection: PropTypes.shape({
getContent: PropTypes.func,
setContent: PropTypes.func,
+ getRng: PropTypes.func, // Add this line
+ getNode: PropTypes.func, // Add this line
+ }),
+ getContent: PropTypes.func,
+ setContent: PropTypes.func,
+ dom: PropTypes.shape({
+ create: PropTypes.func,
+ getParent: PropTypes.func,
}),
}),
}).isRequired,
diff --git a/src/editors/sharedComponents/InsertLinkModal/index.test.jsx b/src/editors/sharedComponents/InsertLinkModal/index.test.jsx
index 08ee5cd53..2e06e0b87 100644
--- a/src/editors/sharedComponents/InsertLinkModal/index.test.jsx
+++ b/src/editors/sharedComponents/InsertLinkModal/index.test.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
-import { fireEvent, render, screen } from '@testing-library/react';
+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';
@@ -25,6 +26,11 @@ jest.mock('./utils', () => ({
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');
@@ -37,7 +43,7 @@ describe('InsertLinkModal', () => {
editorRef: {
current: {
selection: {
- getContent: jest.fn(),
+ getContent: () => 'Sample content',
setContent: jest.fn(),
},
},
@@ -51,6 +57,7 @@ describe('InsertLinkModal', () => {
beforeEach(() => {
jest.clearAllMocks();
+ useSelector.mockReturnValue({ selectedBlocks: {} });
});
const renderComponent = (overrideProps = {}) => render(
@@ -69,14 +76,12 @@ describe('InsertLinkModal', () => {
expect(screen.getByText('Link to')).toBeInTheDocument();
});
- test('should show Course pages and URL tabs', () => {
+ test('should show Course pages tab', () => {
renderComponent();
const tabs = screen.getAllByRole('tab');
- const [coursePagesTab, urlTab] = tabs;
-
+ const [coursePagesTab] = tabs;
expect(coursePagesTab).toHaveTextContent('Course pages');
- expect(urlTab).toHaveTextContent('URL');
});
test('should find Cancel and Save buttons', () => {
@@ -89,36 +94,6 @@ describe('InsertLinkModal', () => {
expect(saveButton).toBeInTheDocument();
});
- test('should show input for url when URL tab is clicked', () => {
- const { getByTestId } = renderComponent();
-
- const tabs = screen.getAllByRole('tab');
- const [, urlTab] = tabs;
-
- fireEvent.click(urlTab);
-
- const urlInput = getByTestId('url-input');
- expect(urlInput).toBeInTheDocument();
- });
-
- test('should show message when the url is invalid', () => {
- const { getByTestId, getByText } = renderComponent();
-
- const tabs = screen.getAllByRole('tab');
- const [, urlTab] = tabs;
-
- fireEvent.click(urlTab);
-
- const urlInput = getByTestId('url-input');
- fireEvent.change(urlInput, { target: { value: 'invalid-url' } });
-
- const saveButton = getByText('Save');
- fireEvent.click(saveButton);
-
- const errorMessage = getByText('The url provided is invalid');
- expect(errorMessage).toBeInTheDocument();
- });
-
test('should call logError on API error', async () => {
api.getBlocksFromCourse.mockRejectedValue(new Error('API error'));
diff --git a/src/editors/sharedComponents/InsertLinkModal/messages.js b/src/editors/sharedComponents/InsertLinkModal/messages.js
index 4c2774221..89ae1f116 100644
--- a/src/editors/sharedComponents/InsertLinkModal/messages.js
+++ b/src/editors/sharedComponents/InsertLinkModal/messages.js
@@ -31,6 +31,11 @@ const messages = defineMessages({
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/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/hooks.js b/src/editors/sharedComponents/TinyMceWidget/hooks.js
index a8df22d40..2b46033cb 100644
--- a/src/editors/sharedComponents/TinyMceWidget/hooks.js
+++ b/src/editors/sharedComponents/TinyMceWidget/hooks.js
@@ -159,7 +159,7 @@ export const setupCustomBehavior = ({
// insert link button
editor.ui.registry.addButton(tinyMCE.buttons.insertLink, {
- icon: 'link',
+ icon: 'new-tab',
tooltip: translations?.insertLinkTooltipTitle ?? '',
onAction: openInsertLinkModal,
});
diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js
index ed445a30c..edb5a43ac 100644
--- a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js
+++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js
@@ -8,7 +8,6 @@ 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 : '';
@@ -34,6 +33,7 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
plugins.a11ychecker,
plugins.powerpaste,
plugins.embediframe,
+ plugins.link,
].join(' '),
menubar: false,
toolbar: toolbar ? mapToolbars([
@@ -56,7 +56,8 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
[imageUploadButton, buttons.blockQuote, buttons.codeBlock],
[buttons.table, buttons.emoticons, buttons.charmap, buttons.hr],
[buttons.removeFormat, codeButton, buttons.a11ycheck, buttons.embediframe],
- [insertLinkButton],
+ [buttons.link, buttons.unlink],
+ [buttons.insertLink],
]) : false,
imageToolbar: mapToolbars([
// [buttons.rotate.left, buttons.rotate.right],