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 ( + )} @@ -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],