diff --git a/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx index 953d2a8d4..433cec649 100644 --- a/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx +++ b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import { Button } from '@edx/paragon'; import { LinkOff } from '@edx/paragon/icons'; -import { formatBlockPath } from '../utils'; +import formatBlockPath from '../formatBlockPath'; import './index.scss'; diff --git a/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx index 165e34d39..c1a6da8f6 100644 --- a/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx +++ b/src/editors/sharedComponents/InsertLinkModal/BlockLink/index.test.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, fireEvent, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { formatBlockPath } from '../utils'; +import formatBlockPath from '../formatBlockPath'; import BlockLink from './index'; describe('BlockLink Component', () => { diff --git a/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx index b4c25fad1..93fbd66d8 100644 --- a/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx +++ b/src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx @@ -4,11 +4,8 @@ import { Button, TransitionReplace, ActionRow } from '@edx/paragon'; import { ArrowForwardIos, ArrowBack } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { - blockTypes, - getSectionsList, - getChildrenFromList, -} from '../utils'; +import blockTypes from '../blockTypes'; +import { getSectionsList, getChildrenFromList } from './utils'; import messages from './messages'; import './index.scss'; 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/index.jsx b/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.jsx index 730092120..a44ca3ffc 100644 --- a/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.jsx +++ b/src/editors/sharedComponents/InsertLinkModal/FilteredBlock/index.jsx @@ -1,7 +1,7 @@ import { Button } from '@edx/paragon'; import PropTypes from 'prop-types'; -import { formatBlockPath } from '../utils'; +import formatBlockPath from '../formatBlockPath'; const FilteredBlock = ({ block, onBlockFilterClick }) => { const { title, subTitle } = formatBlockPath(block.path); diff --git a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx index f57e78af2..2f5d4067c 100644 --- a/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx +++ b/src/editors/sharedComponents/InsertLinkModal/SearchBlocks/index.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { SearchField } from '@edx/paragon'; import FilteredBlock from '../FilteredBlock'; -import { filterBlocksByText } from '../utils'; +import { filterBlocksByText } from './utils'; import messages from './messages'; import './index.scss'; 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/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/utils.js b/src/editors/sharedComponents/InsertLinkModal/utils.js index 808339734..437cbb58d 100644 --- a/src/editors/sharedComponents/InsertLinkModal/utils.js +++ b/src/editors/sharedComponents/InsertLinkModal/utils.js @@ -1,10 +1,6 @@ import cloneDeep from 'lodash.clonedeep'; -export const blockTypes = { - section: 'chapter', - subsection: 'sequential', - unit: 'vertical', -}; +import blockTypes from './blockTypes'; /** * Recursively adds path, parent ID, and root status to blocks in a nested structure. @@ -48,86 +44,6 @@ export const formatBlocks = (blocks, blockRoot) => { return copyBlocks; }; -/** - * Retrieves a list of sections from the provided blocks object. - * - * @param {Object} blocks - The blocks object containing various block types. - * @returns {Array} An array of section (type: chapter) blocks extracted from the blocks object. - */ -export const getSectionsList = (blocks = {}) => { - const blocksList = Object.keys(blocks); - return blocksList.reduce((previousBlocks, blockKey) => { - const block = cloneDeep(blocks[blockKey]); - if (block.type === blockTypes.section) { - return [...previousBlocks, block]; - } - - return previousBlocks; - }, []); -}; - -/** - * Retrieves an array of child blocks based on the children list of a selected block. - * - * @param {Object} blockSelected - The selected block for which children are to be retrieved. - * @param {Object} blocks - The blocks object containing various block types. - * @returns {Array} An array of child blocks cloned from the blocks object. - */ -export const getChildrenFromList = (blockSelected, blocks) => { - if (blockSelected.children) { - return blockSelected.children.map((key) => cloneDeep(blocks[key])); - } - return []; -}; - -/** - * Filters blocks based on the provided searchText. - * - * @param {string} searchText - The text to filter blocks. - * @param {Object} blocks - The object containing blocks. - * @returns {Object} - Filtered blocks. - */ -export const filterBlocksByText = (searchText, blocks) => { - if (!searchText) { - return {}; - } - const copyBlocks = cloneDeep(blocks); - return Object.keys(copyBlocks).reduce((result, key) => { - const item = copyBlocks[key]; - if (item.path.toLowerCase().includes(searchText.toLowerCase())) { - result[key] = item; - } - return result; - }, {}); -}; - -/** - * Formats a block path into title and subtitle. - * - * @param {string} path - The path to be formatted. - * @returns {Object} - Formatted block path with title and subtitle. - */ -export const formatBlockPath = (path) => { - if (!path) { - return { - title: '', - subTitle: '', - }; - } - const pathSlitted = path.split(' / '); - let title = pathSlitted.pop(); - const subTitle = pathSlitted.join(' / '); - - if (!title.trim()) { - // If the last part is empty or contains only whitespace - title = pathSlitted.pop(); - } - return { - title, - subTitle, - }; -}; - /** * Validates a URL using a regular expression. * @@ -135,9 +51,11 @@ export const formatBlockPath = (path) => { * @returns {boolean} - True if the URL is valid, false otherwise. */ export const isValidURL = (url) => { - // Regular expression for a basic URL validation - const urlPattern = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/; - - // Test the provided URL against the pattern - return urlPattern.test(url); + 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 index d92865969..778b6c39c 100644 --- a/src/editors/sharedComponents/InsertLinkModal/utils.test.js +++ b/src/editors/sharedComponents/InsertLinkModal/utils.test.js @@ -1,10 +1,6 @@ import { addPathToBlocks, formatBlocks, - getSectionsList, - getChildrenFromList, - filterBlocksByText, - formatBlockPath, isValidURL, } from './utils'; @@ -128,233 +124,6 @@ describe('utils', () => { expect(formattedBlocks.block1.displayName).toEqual('Block children 1'); }); }); - - 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([]); - }); - }); - - describe('filterBlocksByText function', () => { - const testBlocks = { - block1: { - id: 'block1', - path: 'Root / Child 1', - }, - block2: { - id: 'block2', - path: 'Root / Child 2', - }, - block3: { - id: 'block3', - path: 'Another / Block', - }, - }; - - test('returns an empty object when searchText is empty', () => { - const searchText = ''; - const filteredBlocks = filterBlocksByText(searchText, testBlocks); - expect(filteredBlocks).toEqual({}); - }); - - test('filters blocks based on case-insensitive searchText', () => { - const searchText = 'child'; - const filteredBlocks = filterBlocksByText(searchText, testBlocks); - expect(filteredBlocks).toEqual({ - block1: { - id: 'block1', - path: 'Root / Child 1', - }, - block2: { - id: 'block2', - path: 'Root / Child 2', - }, - }); - }); - - test('returns an empty object when no blocks match searchText', () => { - const searchText = 'nonexistent'; - const filteredBlocks = filterBlocksByText(searchText, testBlocks); - expect(filteredBlocks).toEqual({}); - }); - - test('filters blocks with partial matches in path', () => { - const searchText = 'root'; - const filteredBlocks = filterBlocksByText(searchText, testBlocks); - expect(filteredBlocks).toEqual({ - block1: { - id: 'block1', - path: 'Root / Child 1', - }, - block2: { - id: 'block2', - path: 'Root / Child 2', - }, - }); - }); - }); - - describe('formatBlockPath function', () => { - test('formats a simple path with title and subtitle', () => { - const path = 'Root / Child 1 / Grandchild'; - const formattedPath = formatBlockPath(path); - expect(formattedPath).toEqual({ - title: 'Grandchild', - subTitle: 'Root / Child 1', - }); - }); - - test('handles an empty title by using the previous part as title', () => { - const path = 'Root / Child 1 / '; - const formattedPath = formatBlockPath(path); - expect(formattedPath).toEqual({ - title: 'Child 1', - subTitle: 'Root / Child 1', - }); - }); - - test('handles an empty path by returning an empty title and subtitle', () => { - const path = ''; - const formattedPath = formatBlockPath(path); - expect(formattedPath).toEqual({ - title: '', - subTitle: '', - }); - }); - - test('handles whitespace in the title by using the previous part as title', () => { - const path = 'Root / Child 1 / '; - const formattedPath = formatBlockPath(path); - expect(formattedPath).toEqual({ - title: 'Child 1', - subTitle: 'Root / Child 1', - }); - }); - - test('handles a path with only one part by using it as the title', () => { - const path = 'SinglePart'; - const formattedPath = formatBlockPath(path); - expect(formattedPath).toEqual({ - title: 'SinglePart', - subTitle: '', - }); - }); - }); - describe('isValidURL function', () => { test('returns true for a valid HTTP URL', () => { const validHTTPUrl = 'http://www.example.com';