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 ( +
+
+

{subTitle}

+

{title}

+
+
+ +
+
+ ); +}; + +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`] = ` +