diff --git a/src/generic/DraggableList/SortableItem.jsx b/src/generic/DraggableList/SortableItem.tsx similarity index 70% rename from src/generic/DraggableList/SortableItem.jsx rename to src/generic/DraggableList/SortableItem.tsx index cf488d8b50..a35e28b8fe 100644 --- a/src/generic/DraggableList/SortableItem.jsx +++ b/src/generic/DraggableList/SortableItem.tsx @@ -1,14 +1,25 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { intlShape, injectIntl } from '@edx/frontend-platform/i18n'; +import React, { MouseEventHandler } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ActionRow, Card, Icon, IconButtonWithTooltip, } from '@openedx/paragon'; import { DragIndicator } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; +interface SortableItemProps { + id: string, + children?: React.ReactNode, + actions: React.ReactNode, + actionStyle?: {}, + componentStyle?: {}, + isClickable?: boolean, + onClick?: MouseEventHandler, + disabled?: boolean, + cardClassName?: string, +} + const SortableItem = ({ id, componentStyle, @@ -19,9 +30,8 @@ const SortableItem = ({ onClick, disabled, cardClassName = '', - // injected - intl, -}) => { +}: SortableItemProps) => { + const intl = useIntl(); const { attributes, listeners, @@ -78,26 +88,5 @@ const SortableItem = ({ ); }; -SortableItem.defaultProps = { - componentStyle: null, - actions: null, - actionStyle: null, - isClickable: false, - onClick: null, - disabled: false, -}; -SortableItem.propTypes = { - id: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, - actions: PropTypes.node, - actionStyle: PropTypes.shape({}), - componentStyle: PropTypes.shape({}), - isClickable: PropTypes.bool, - onClick: PropTypes.func, - disabled: PropTypes.bool, - cardClassName: PropTypes.string, - // injected - intl: intlShape.isRequired, -}; -export default injectIntl(SortableItem); +export default SortableItem; diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts index 8d3569d2bf..3a62821b30 100644 --- a/src/generic/key-utils.test.ts +++ b/src/generic/key-utils.test.ts @@ -4,8 +4,6 @@ import { getLibraryId, isLibraryKey, isLibraryV1Key, - getContainerTypeFromId, - ContainerType, } from './key-utils'; describe('component utils', () => { @@ -14,6 +12,9 @@ describe('component utils', () => { ['lb:org:lib:html:id', 'html'], ['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'html'], ['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'problem'], + ['lct:org:lib:unit:my-unit-9284e2', 'unit'], + ['lct:org:lib:section:my-section-9284e2', 'section'], + ['lct:org:lib:subsection:my-section-9284e2', 'subsection'], ]) { it(`returns '${expected}' for usage key '${input}'`, () => { expect(getBlockType(input)).toStrictEqual(expected); @@ -99,16 +100,4 @@ describe('component utils', () => { }); } }); - - describe('getContainerTypeFromId', () => { - for (const [input, expected] of [ - ['lct:org:lib:unit:my-unit-9284e2', ContainerType.Unit], - ['lct:OpenCraftX:ALPHA:my-unit-a3223f', undefined], - ['', undefined], - ]) { - it(`returns '${expected}' for container key '${input}'`, () => { - expect(getContainerTypeFromId(input!)).toStrictEqual(expected); - }); - } - }); }); diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index 63498927ae..03689a29d7 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -4,7 +4,7 @@ * @returns The block type as a string */ export function getBlockType(usageKey: string): string { - if (usageKey && usageKey.startsWith('lb:')) { + if (usageKey && (usageKey.startsWith('lb:') || usageKey.startsWith('lct:'))) { const blockType = usageKey.split(':')[3]; if (blockType) { return blockType; @@ -66,22 +66,3 @@ export enum ContainerType { Sequential = 'sequential', Vertical = 'vertical', } - -/** - * Given a container key like `ltc:org:lib:unit:id` - * get the container type - */ -export function getContainerTypeFromId(containerId: string): ContainerType | undefined { - const parts = containerId.split(':'); - if (parts.length < 2) { - return undefined; - } - - const maybeType = parts[parts.length - 2]; - - if (Object.values(ContainerType).includes(maybeType as ContainerType)) { - return maybeType as ContainerType; - } - - return undefined; -} diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index a805e67e8d..f10b2cfa66 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -16,9 +16,12 @@ import LibraryCollectionPage from './collections/LibraryCollectionPage'; import { ComponentPicker } from './component-picker'; import { ComponentEditorModal } from './components/ComponentEditorModal'; import { LibraryUnitPage } from './units'; +import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections'; const LibraryLayoutWrapper: React.FC = ({ children }) => { - const { libraryId, collectionId, unitId } = useParams(); + const { + libraryId, collectionId, unitId, sectionId, subsectionId, + } = useParams(); if (libraryId === undefined) { // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. @@ -31,7 +34,7 @@ const LibraryLayoutWrapper: React.FC = ({ children }) = * when we navigate to a collection or unit page. This is necessary to make the back/forward navigation * work correctly, as the LibraryProvider needs to rebuild the state from the URL. * */ - key={collectionId || unitId} + key={collectionId || sectionId || subsectionId || unitId} libraryId={libraryId} /** NOTE: The component picker modal to use. We need to pass it as a reference instead of * directly importing it to avoid the import cycle: @@ -70,6 +73,14 @@ const LibraryLayout = () => ( path={ROUTES.COLLECTION} Component={LibraryCollectionPage} /> + + { const intl = useIntl(); - const { - componentPicker, - unitId, - } = useLibraryContext(); + const { componentPicker } = useLibraryContext(); const { insideCollection, insideUnit, @@ -129,9 +126,6 @@ const AddContentView = ({ blockType: 'libraryContent', }; - const extraFilter = unitId ? ['NOT block_type = "unit"', 'NOT type = "collections"'] : undefined; - const visibleTabs = unitId ? [LibraryContentTypes.components] : undefined; - /** List container content types that should be displayed based on current path */ const visibleContentTypes = useMemo(() => { if (insideCollection) { @@ -182,8 +176,6 @@ const AddContentView = ({ )}
@@ -276,7 +268,7 @@ const AddContent = () => { insideUnit, } = useLibraryRoutes(); const addComponentsToCollectionMutation = useAddItemsToCollection(libraryId, collectionId); - const addComponentsToContainerMutation = useAddComponentsToContainer(unitId); + const addComponentsToContainerMutation = useAddItemsToContainer(unitId); const createBlockMutation = useCreateLibraryBlock(); const pasteClipboardMutation = useLibraryPasteClipboard(); const { showToast } = useContext(ToastContext); diff --git a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx index 982b657e8b..b9ed6fd63d 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx @@ -33,25 +33,44 @@ const mockAddComponentsToContainer = jest.fn(); jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection); jest.spyOn(api, 'addComponentsToContainer').mockImplementation(mockAddComponentsToContainer); const unitId = 'lct:Axim:TEST:unit:test-unit-1'; - -const render = (context: 'collection' | 'unit') => baseRender(, { - path: context === 'collection' - ? '/library/:libraryId/collection/:collectionId/*' - : '/library/:libraryId/container/:unitId/*', - params: { - libraryId, - ...(context === 'collection' && { collectionId: 'collectionId' }), - ...(context === 'unit' && { unitId }), +const sectionId = 'lct:Axim:TEST:section:test-section-1'; +const subsectionId = 'lct:Axim:TEST:subsection:test-subsection-1'; +type ContextType = 'collection' | 'unit' | 'section' | 'subsection'; + +const getIdFromContext = (context: ContextType) => { + switch (context) { + case 'section': + return sectionId; + case 'subsection': + return subsectionId; + case 'unit': + return unitId; + default: + return ''; + } +}; + +const render = (context: ContextType) => baseRender( + , + { + path: `/library/:libraryId/${context}/:${context}Id/*`, + params: { + libraryId, + ...(context === 'collection' && { collectionId: 'collectionId' }), + ...(context === 'unit' && { unitId }), + ...(context === 'section' && { sectionId }), + ...(context === 'subsection' && { subsectionId }), + }, + extraWrapper: ({ children }) => ( + + {children} + + ), }, - extraWrapper: ({ children }) => ( - - {children} - - ), -}); +); describe('', () => { beforeEach(async () => { @@ -61,7 +80,12 @@ describe('', () => { jest.clearAllMocks(); }); - ['collection' as const, 'unit' as const].forEach((context) => { + [ + 'collection' as const, + 'unit' as const, + 'section' as const, + 'subsection' as const, + ].forEach((context) => { it(`can pick components from the modal (${context})`, async () => { render(context); @@ -78,17 +102,24 @@ describe('', () => { fireEvent.click(screen.getByRole('button', { name: /add to .*/i })); await waitFor(() => { - if (context === 'collection') { - expect(mockAddItemsToCollection).toHaveBeenCalledWith( - libraryId, - 'collectionId', - ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], - ); - } else { - expect(mockAddComponentsToContainer).toHaveBeenCalledWith( - unitId, - ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], - ); + switch (context) { + case 'collection': + expect(mockAddItemsToCollection).toHaveBeenCalledWith( + libraryId, + 'collectionId', + ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], + ); + break; + case 'unit': + case 'section': + case 'subsection': + expect(mockAddComponentsToContainer).toHaveBeenCalledWith( + getIdFromContext(context), + ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], + ); + break; + default: + break; } }); expect(onClose).toHaveBeenCalled(); @@ -119,17 +150,24 @@ describe('', () => { fireEvent.click(screen.getByRole('button', { name: /add to .*/i })); await waitFor(() => { - if (context === 'collection') { - expect(mockAddItemsToCollection).toHaveBeenCalledWith( - libraryId, - 'collectionId', - ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], - ); - } else { - expect(mockAddComponentsToContainer).toHaveBeenCalledWith( - unitId, - ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], - ); + switch (context) { + case 'collection': + expect(mockAddItemsToCollection).toHaveBeenCalledWith( + libraryId, + 'collectionId', + ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], + ); + break; + case 'unit': + case 'section': + case 'subsection': + expect(mockAddComponentsToContainer).toHaveBeenCalledWith( + getIdFromContext(context), + ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], + ); + break; + default: + break; } }); expect(onClose).toHaveBeenCalled(); diff --git a/src/library-authoring/add-content/PickLibraryContentModal.tsx b/src/library-authoring/add-content/PickLibraryContentModal.tsx index 4f243f5bf1..ceb39f12f2 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.tsx @@ -5,9 +5,9 @@ import { ActionRow, Button, StandardModal } from '@openedx/paragon'; import { ToastContext } from '../../generic/toast-context'; import { useLibraryContext } from '../common/context/LibraryContext'; import type { SelectedComponent } from '../common/context/ComponentPickerContext'; -import { useAddItemsToCollection, useAddComponentsToContainer } from '../data/apiHooks'; +import { useAddItemsToCollection, useAddItemsToContainer } from '../data/apiHooks'; import genericMessages from '../generic/messages'; -import type { ContentType } from '../routes'; +import { allLibraryPageTabs, ContentType, useLibraryRoutes } from '../routes'; import messages from './messages'; interface PickLibraryContentModalFooterProps { @@ -33,21 +33,16 @@ const PickLibraryContentModalFooter: React.FC void; - extraFilter?: string[]; - visibleTabs?: ContentType[], } -export const PickLibraryContentModal: React.FC = ({ - isOpen, - onClose, - extraFilter, - visibleTabs, -}) => { +export const PickLibraryContentModal: React.FC = ({ isOpen, onClose }) => { const intl = useIntl(); const { libraryId, collectionId, + sectionId, + subsectionId, unitId, /** We need to get it as a reference instead of directly importing it to avoid the import cycle: * ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > @@ -55,13 +50,17 @@ export const PickLibraryContentModal: React.FC = ( componentPicker: ComponentPicker, } = useLibraryContext(); - // istanbul ignore if: this should never happen - if (!(collectionId || unitId) || !ComponentPicker) { - throw new Error('collectionId/unitId and componentPicker are required'); - } + const { + insideCollection, insideUnit, insideSection, insideSubsection, + } = useLibraryRoutes(); const updateCollectionItemsMutation = useAddItemsToCollection(libraryId, collectionId); - const updateUnitComponentsMutation = useAddComponentsToContainer(unitId); + const updateContainerChildrenMutation = useAddItemsToContainer( + (insideSection && sectionId) + || (insideSubsection && subsectionId) + || (insideUnit && unitId) + || '', + ); const { showToast } = useContext(ToastContext); @@ -70,7 +69,7 @@ export const PickLibraryContentModal: React.FC = ( const onSubmit = useCallback(() => { const usageKeys = selectedComponents.map(({ usageKey }) => usageKey); onClose(); - if (collectionId) { + if (insideCollection && collectionId) { updateCollectionItemsMutation.mutateAsync(usageKeys) .then(() => { showToast(intl.formatMessage(genericMessages.manageCollectionsSuccess)); @@ -78,9 +77,8 @@ export const PickLibraryContentModal: React.FC = ( .catch(() => { showToast(intl.formatMessage(genericMessages.manageCollectionsFailed)); }); - } - if (unitId) { - updateUnitComponentsMutation.mutateAsync(usageKeys) + } else if (insideSection || insideSubsection || insideUnit) { + updateContainerChildrenMutation.mutateAsync(usageKeys) .then(() => { showToast(intl.formatMessage(messages.successAssociateComponentToContainerMessage)); }) @@ -88,7 +86,46 @@ export const PickLibraryContentModal: React.FC = ( showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage)); }); } - }, [selectedComponents]); + }, [ + selectedComponents, + insideSection, + insideSubsection, + insideUnit, + collectionId, + sectionId, + subsectionId, + unitId, + ]); + + // determine filter an visibleTabs based on current location + let extraFilter = ['NOT type = "collection"']; + let visibleTabs = allLibraryPageTabs.filter((tab) => tab !== ContentType.collections); + let addBtnText = messages.addToCollectionButton; + if (insideSection) { + // show only subsections + extraFilter = ['block_type = "subsection"']; + addBtnText = messages.addToSectionButton; + visibleTabs = [ContentType.subsections]; + } else if (insideSubsection) { + // show only units + extraFilter = ['block_type = "unit"']; + addBtnText = messages.addToSubsectionButton; + visibleTabs = [ContentType.units]; + } else if (insideUnit) { + // show only components + extraFilter = [ + 'NOT block_type = "unit"', + 'NOT block_type = "subsection"', + 'NOT block_type = "section"', + ]; + addBtnText = messages.addToUnitButton; + visibleTabs = [ContentType.components]; + } + + // istanbul ignore if: this should never happen, just here to satisfy type checker + if (!(collectionId || unitId || sectionId || subsectionId) || !ComponentPicker) { + throw new Error('collectionId/sectionId/unitId and componentPicker are required'); + } return ( = ( )} > diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index 4a2b176613..99198a6082 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -36,6 +36,16 @@ const messages = defineMessages({ defaultMessage: 'Add to Unit', description: 'Button to add library content to a unit.', }, + addToSectionButton: { + id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-section', + defaultMessage: 'Add to Section', + description: 'Button to add library content to a section.', + }, + addToSubsectionButton: { + id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-subsection', + defaultMessage: 'Add to Subsection', + description: 'Button to add library content to a subsection.', + }, selectedComponents: { id: 'course-authoring.library-authoring.add-content.selected-components', defaultMessage: '{count, plural, one {# Selected Component} other {# Selected Components}}', diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx index b885d72da2..93b02e2782 100644 --- a/src/library-authoring/collections/CollectionInfoHeader.tsx +++ b/src/library-authoring/collections/CollectionInfoHeader.tsx @@ -34,7 +34,6 @@ const CollectionInfoHeader = () => { showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); } catch (err) { showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); - throw err; } }; diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx index 0e90d4d571..9b3aad1bae 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -139,7 +139,7 @@ describe('', () => { expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); expect((await screen.findAllByText(title))[0]).toBeInTheDocument(); - expect(screen.getAllByText('This collection is currently empty.').length).toEqual(2); + expect((await screen.findAllByText('This collection is currently empty.')).length).toEqual(2); const addComponentButton = screen.getAllByRole('button', { name: /new/i })[1]; fireEvent.click(addComponentButton); diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx index 1d743007db..3263dc0bcb 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.tsx @@ -100,16 +100,13 @@ const HeaderActions = () => { const LibraryCollectionPage = () => { const intl = useIntl(); - const { libraryId, collectionId } = useLibraryContext(); - - if (!collectionId || !libraryId) { - // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. - throw new Error('Rendered without collectionId or libraryId URL parameter'); - } - const { componentPickerMode } = useComponentPickerContext(); const { - showOnlyPublished, extraFilter: contextExtraFilter, setCollectionId, + libraryId, + collectionId, + showOnlyPublished, + extraFilter: contextExtraFilter, + setCollectionId, } = useLibraryContext(); const { sidebarComponentInfo } = useSidebarContext(); @@ -122,6 +119,10 @@ const LibraryCollectionPage = () => { const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId); + if (!collectionId || !libraryId) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Rendered without collectionId or libraryId URL parameter'); + } // Only show loading if collection data is not fetched from index yet // Loading info for search results will be handled by LibraryCollectionComponents component. if (isLibLoading || isLoading) { diff --git a/src/library-authoring/common/context/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx index 8ed4089735..87e719297c 100644 --- a/src/library-authoring/common/context/LibraryContext.tsx +++ b/src/library-authoring/common/context/LibraryContext.tsx @@ -31,6 +31,10 @@ export type LibraryContextData = { setCollectionId: (collectionId?: string) => void; unitId: string | undefined; setUnitId: (unitId?: string) => void; + sectionId: string | undefined; + setSectionId: (sectionId?: string) => void; + subsectionId: string | undefined; + setSubsectionId: (sectionId?: string) => void; // Only show published components showOnlyPublished: boolean; // Additional filtering @@ -114,6 +118,8 @@ export const LibraryProvider = ({ const { collectionId: urlCollectionId, unitId: urlUnitId, + sectionId: urlSectionId, + subsectionId: urlSubsectionId, } = params; const [collectionId, setCollectionId] = useState( skipUrlUpdate ? undefined : urlCollectionId, @@ -121,6 +127,12 @@ export const LibraryProvider = ({ const [unitId, setUnitId] = useState( skipUrlUpdate ? undefined : urlUnitId, ); + const [sectionId, setSectionId] = useState( + skipUrlUpdate ? undefined : urlSectionId, + ); + const [subsectionId, setSubsectionId] = useState( + skipUrlUpdate ? undefined : urlSubsectionId, + ); const context = useMemo(() => { const contextValue = { @@ -130,6 +142,10 @@ export const LibraryProvider = ({ setCollectionId, unitId, setUnitId, + sectionId, + setSectionId, + subsectionId, + setSubsectionId, readOnly, isLoadingLibraryData, showOnlyPublished, diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index 6317230a45..ea963106ba 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -8,7 +8,7 @@ import { } from 'react'; import { useParams } from 'react-router-dom'; import { useStateWithUrlSearchParam } from '../../../hooks'; -import { getContainerTypeFromId } from '../../../generic/key-utils'; +import { getBlockType } from '../../../generic/key-utils'; import { useComponentPickerContext } from './ComponentPickerContext'; import { useLibraryContext } from './LibraryContext'; @@ -18,6 +18,8 @@ export enum SidebarBodyComponentId { ComponentInfo = 'component-info', CollectionInfo = 'collection-info', UnitInfo = 'unit-info', + SectionInfo = 'section-info', + SubsectionInfo = 'subsection-info', } export const COLLECTION_INFO_TABS = { @@ -190,7 +192,12 @@ export const SidebarProvider = ({ // Handle selected item id changes if (selectedItemId) { - const containerType = getContainerTypeFromId(selectedItemId); + let containerType: undefined | string; + try { + containerType = getBlockType(selectedItemId); + } catch { + // ignore + } if (containerType === 'unit') { openUnitInfoSidebar(selectedItemId); } else if (containerType === 'section') { diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx index 11b3256945..9615209c3f 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx @@ -36,7 +36,6 @@ const ComponentInfoHeader = () => { showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); } catch (err) { showToast(intl.formatMessage(messages.updateComponentErrorMsg)); - throw err; } }; diff --git a/src/library-authoring/component-info/ComponentUsage.tsx b/src/library-authoring/component-info/ComponentUsage.tsx index f3b0d4604b..72c9d73a06 100644 --- a/src/library-authoring/component-info/ComponentUsage.tsx +++ b/src/library-authoring/component-info/ComponentUsage.tsx @@ -7,7 +7,7 @@ import { useEntityLinks } from '../../course-libraries/data/apiHooks'; import AlertError from '../../generic/alert-error'; import Loading from '../../generic/Loading'; import messages from './messages'; -import { useComponentsFromSearchIndex } from '../data/apiHooks'; +import { useContentFromSearchIndex } from '../data/apiHooks'; interface ComponentUsageProps { usageKey: string; @@ -46,7 +46,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { isError: isErrorIndexDocuments, error: errorIndexDocuments, isLoading: isLoadingIndexDocuments, - } = useComponentsFromSearchIndex(downstreamKeys); + } = useContentFromSearchIndex(downstreamKeys); if (isErrorDownstreamLinks || isErrorIndexDocuments) { return ; diff --git a/src/library-authoring/components/ComponentDeleter.tsx b/src/library-authoring/components/ComponentDeleter.tsx index 629795ad07..c1fc67b551 100644 --- a/src/library-authoring/components/ComponentDeleter.tsx +++ b/src/library-authoring/components/ComponentDeleter.tsx @@ -5,7 +5,7 @@ import { School, Warning, Info } from '@openedx/paragon/icons'; import { useSidebarContext } from '../common/context/SidebarContext'; import { - useComponentsFromSearchIndex, + useContentFromSearchIndex, useDeleteLibraryBlock, useLibraryBlockMetadata, useRestoreLibraryBlock, @@ -69,7 +69,7 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => { } }, [usageKey, sidebarComponentUsageKey, closeLibrarySidebar]); - const { hits } = useComponentsFromSearchIndex([usageKey]); + const { hits } = useContentFromSearchIndex([usageKey]); const componentHit = (hits as ContentHit[])?.[0]; if (!props.isConfirmingDelete) { diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx index 7e80007c05..507c3a85d3 100644 --- a/src/library-authoring/components/ComponentMenu.tsx +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -13,7 +13,7 @@ import { SidebarActions, useSidebarContext } from '../common/context/SidebarCont import { useClipboard } from '../../generic/clipboard'; import { ToastContext } from '../../generic/toast-context'; import { - useAddComponentsToContainer, + useAddItemsToContainer, useRemoveContainerChildren, useRemoveItemsFromCollection, } from '../data/apiHooks'; @@ -38,11 +38,11 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { closeLibrarySidebar, setSidebarAction, } = useSidebarContext(); - const { navigateTo } = useLibraryRoutes(); + const { navigateTo, insideCollection } = useLibraryRoutes(); const canEdit = usageKey && canEditComponent(usageKey); const { showToast } = useContext(ToastContext); - const addComponentToContainerMutation = useAddComponentsToContainer(unitId); + const addComponentToContainerMutation = useAddItemsToContainer(unitId); const removeCollectionComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId); const removeContainerComponentsMutation = useRemoveContainerChildren(unitId); const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false); @@ -137,7 +137,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { - {collectionId && ( + {insideCollection && ( diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx index a33df12db5..cff6e6bbca 100644 --- a/src/library-authoring/components/ContainerCard.test.tsx +++ b/src/library-authoring/components/ContainerCard.test.tsx @@ -10,28 +10,27 @@ import { mockContentLibrary } from '../data/api.mocks'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import ContainerCard from './ContainerCard'; import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api'; +import { ContainerType } from '../../generic/key-utils'; +let axiosMock: MockAdapter; +let mockShowToast; const mockNavigate = jest.fn(); +const libraryId = 'lib:Axim:TEST'; -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts - useNavigate: () => mockNavigate, -})); - -const containerHitSample: ContainerHit = { - id: 'lctorg1democourse-unit-display-name-123', +const getContainerHitSample = (containerType: ContainerType = ContainerType.Unit) => ({ + id: `lctorg1democourse-${containerType}-display-name-123`, type: 'library_container', - contextKey: 'lb:org1:Demo_Course', - usageKey: 'lct:org1:Demo_Course:unit:unit-display-name-123', + contextKey: libraryId, + usageKey: `lct:org1:Demo_Course:${containerType}:${containerType}-display-name-123`, org: 'org1', - blockId: 'unit-display-name-123', - blockType: 'unit', + blockId: `${containerType}-display-name-123`, + blockType: containerType, breadcrumbs: [{ displayName: 'Demo Lib' }], - displayName: 'Unit Display Name', + displayName: `${containerType} Display Name`, formatted: { - displayName: 'Unit Display Formated Name', + displayName: `${containerType} Display Formated Name`, published: { - displayName: 'Published Unit Display Name', + displayName: `Published ${containerType} Display Name`, }, }, created: 1722434322294, @@ -42,15 +41,15 @@ const containerHitSample: ContainerHit = { }, tags: {}, publishStatus: PublishStatus.Published, -}; - -const libraryId = 'lib:Axim:TEST'; - -let axiosMock: MockAdapter; -let mockShowToast; +} as ContainerHit); mockContentLibrary.applyMock(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, { path: '/library/:libraryId', params: { libraryId }, @@ -70,56 +69,79 @@ describe('', () => { }); it('should render the card with title', () => { - render(); + render(); - expect(screen.getByText('Unit Display Formated Name')).toBeInTheDocument(); + expect(screen.getByText('unit Display Formated Name')).toBeInTheDocument(); expect(screen.queryByText('2')).toBeInTheDocument(); // Component count }); it('should render published content', () => { - render(, true); + render(, true); - expect(screen.getByText('Published Unit Display Name')).toBeInTheDocument(); + expect(screen.getByText('Published unit Display Name')).toBeInTheDocument(); expect(screen.queryByText('1')).toBeInTheDocument(); // Published Component Count }); - it('should navigate to the container if the open menu clicked', async () => { - render(); + test.each([ + { + label: 'should navigate to the unit if the open menu clicked', + containerType: ContainerType.Unit, + }, + { + label: 'should navigate to the section if the open menu clicked', + containerType: ContainerType.Section, + }, + { + label: 'should navigate to the subsection if the open menu clicked', + containerType: ContainerType.Subsection, + }, + ])('$label', async ({ containerType }) => { + render(); // Open menu expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument(); userEvent.click(screen.getByTestId('container-card-menu-toggle')); // Open menu item - const openMenuItem = screen.getByRole('button', { name: 'Open' }); + const openMenuItem = await screen.findByRole('button', { name: 'Open' }); expect(openMenuItem).toBeInTheDocument(); - - fireEvent.click(openMenuItem); - + userEvent.click(openMenuItem); expect(mockNavigate).toHaveBeenCalledWith({ - pathname: `/library/${libraryId}/unit/${containerHitSample.usageKey}`, + pathname: `/library/${libraryId}/${containerType}/${getContainerHitSample(containerType).usageKey}`, search: '', }); }); - it('should navigate to the container if double clicked', async () => { - render(); - - // Card title - const cardTitle = screen.getByText('Unit Display Formated Name'); - expect(cardTitle).toBeInTheDocument(); - userEvent.dblClick(cardTitle); + test.each([ + { + label: 'should navigate to the unit if the card is double clicked', + containerType: ContainerType.Unit, + }, + { + label: 'should navigate to the section if the card is double clicked', + containerType: ContainerType.Section, + }, + { + label: 'should navigate to the subsection if the card is double clicked', + containerType: ContainerType.Subsection, + }, + ])('$label', async ({ containerType }) => { + render(); + // Open menu item + const cardItem = await screen.findByText(`${containerType} Display Formated Name`); + expect(cardItem).toBeInTheDocument(); + userEvent.click(cardItem, undefined, { clickCount: 2 }); expect(mockNavigate).toHaveBeenCalledWith({ - pathname: `/library/${libraryId}/unit/${containerHitSample.usageKey}`, + pathname: `/library/${libraryId}/${containerType}/${getContainerHitSample(containerType).usageKey}`, search: '', }); }); it('should delete the container from the menu & restore the container', async () => { - axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(200); + axiosMock.onDelete(getLibraryContainerApiUrl(getContainerHitSample().usageKey)).reply(200); - render(); + render(); // Open menu expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument(); @@ -143,7 +165,7 @@ describe('', () => { // Get restore / undo func from the toast const restoreFn = mockShowToast.mock.calls[0][1].onClick; - const restoreUrl = getLibraryContainerRestoreApiUrl(containerHitSample.usageKey); + const restoreUrl = getLibraryContainerRestoreApiUrl(getContainerHitSample().usageKey); axiosMock.onPost(restoreUrl).reply(200); // restore collection restoreFn(); @@ -154,9 +176,9 @@ describe('', () => { }); it('should show error on delete the container from the menu', async () => { - axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(400); + axiosMock.onDelete(getLibraryContainerApiUrl(getContainerHitSample().usageKey)).reply(400); - render(); + render(); // Open menu expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument(); @@ -179,7 +201,7 @@ describe('', () => { }); it('should render no child blocks in card preview', async () => { - render(); + render(); expect(screen.queryByTitle('lb:org1:Demo_course:html:text-0')).not.toBeInTheDocument(); expect(screen.queryByText('+0')).not.toBeInTheDocument(); @@ -187,7 +209,7 @@ describe('', () => { it('should render <=5 child blocks in card preview', async () => { const containerWith5Children = { - ...containerHitSample, + ...getContainerHitSample(), content: { childUsageKeys: Array(5).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), }, @@ -200,7 +222,7 @@ describe('', () => { it('should render >5 child blocks with +N in card preview', async () => { const containerWith6Children = { - ...containerHitSample, + ...getContainerHitSample(), content: { childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), }, @@ -213,7 +235,7 @@ describe('', () => { it('should render published child blocks when rendering a published card preview', async () => { const containerWithPublishedChildren = { - ...containerHitSample, + ...getContainerHitSample(), content: { childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), }, diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 6b79d47301..1a4977d6a1 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -11,12 +11,12 @@ import { import { MoreVert } from '@openedx/paragon/icons'; import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; -import { getBlockType } from '../../generic/key-utils'; +import { ContainerType, getBlockType } from '../../generic/key-utils'; import { ToastContext } from '../../generic/toast-context'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { SidebarActions, SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; +import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; import { useRemoveItemsFromCollection } from '../data/apiHooks'; import { useLibraryRoutes } from '../routes'; import AddComponentWidget from './AddComponentWidget'; @@ -26,31 +26,28 @@ import ContainerDeleter from './ContainerDeleter'; import { useRunOnNextRender } from '../../utils'; type ContainerMenuProps = { - hit: ContainerHit, + containerKey: string; + containerType: ContainerType; + displayName: string; }; -const ContainerMenu = ({ hit } : ContainerMenuProps) => { +export const ContainerMenu = ({ containerKey, containerType, displayName } : ContainerMenuProps) => { const intl = useIntl(); - const { - usageKey: containerId, - displayName, - } = hit; const { libraryId, collectionId } = useLibraryContext(); const { sidebarComponentInfo, - openUnitInfoSidebar, closeLibrarySidebar, setSidebarAction, } = useSidebarContext(); const { showToast } = useContext(ToastContext); const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false); - const { navigateTo } = useLibraryRoutes(); + const { navigateTo, insideCollection } = useLibraryRoutes(); const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId); const removeFromCollection = () => { - removeComponentsMutation.mutateAsync([containerId]).then(() => { - if (sidebarComponentInfo?.id === containerId) { + removeComponentsMutation.mutateAsync([containerKey]).then(() => { + if (sidebarComponentInfo?.id === containerKey) { // Close sidebar if current component is open closeLibrarySidebar(); } @@ -67,13 +64,13 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { }); const showManageCollections = useCallback(() => { - navigateTo({ selectedItemId: containerId }); + navigateTo({ selectedItemId: containerKey }); scheduleJumpToCollection(); - }, [scheduleJumpToCollection, navigateTo, openUnitInfoSidebar, containerId]); + }, [scheduleJumpToCollection, navigateTo, containerKey]); const openContainer = useCallback(() => { - navigateTo({ unitId: containerId }); - }, [navigateTo, containerId]); + navigateTo({ [`${containerType}Id`]: containerKey }); + }, [navigateTo, containerKey]); return ( <> @@ -94,7 +91,7 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { - {collectionId && ( + {insideCollection && ( @@ -107,7 +104,7 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { @@ -181,7 +178,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { numChildren, published, publishStatus, - usageKey: containerId, + usageKey: containerKey, content, } = hit; @@ -197,26 +194,44 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { showOnlyPublished ? published?.content?.childUsageKeys : content?.childUsageKeys ) ?? []; - const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.UnitInfo - && sidebarComponentInfo.id === containerId; + const selected = sidebarComponentInfo?.id === containerKey; const { navigateTo } = useLibraryRoutes(); const selectContainer = useCallback((e?: React.MouseEvent) => { const doubleClicked = (e?.detail || 0) > 1; - - if (!componentPickerMode) { - if (doubleClicked) { - navigateTo({ unitId: containerId }); - } else { - navigateTo({ selectedItemId: containerId }); + if (componentPickerMode) { + switch (itemType) { + case ContainerType.Unit: + openUnitInfoSidebar(containerKey); + break; + case ContainerType.Section: + // TODO: open section sidebar + break; + case ContainerType.Subsection: + // TODO: open subsection sidebar + break; + default: + break; } + } else if (!doubleClicked) { + navigateTo({ selectedItemId: containerKey }); } else { - // In component picker mode, we want to open the sidebar - // without changing the URL - openUnitInfoSidebar(containerId); + switch (itemType) { + case ContainerType.Unit: + navigateTo({ unitId: containerKey }); + break; + case ContainerType.Section: + navigateTo({ sectionId: containerKey }); + break; + case ContainerType.Subsection: + navigateTo({ subsectionId: containerKey }); + break; + default: + break; + } } - }, [containerId, itemType, openUnitInfoSidebar, navigateTo]); + }, [containerKey, itemType, openUnitInfoSidebar, navigateTo]); return ( { actions={( {componentPickerMode ? ( - + ) : ( - + )} )} diff --git a/src/library-authoring/containers/ContainerEditableTitle.tsx b/src/library-authoring/containers/ContainerEditableTitle.tsx new file mode 100644 index 0000000000..ef4f8b2588 --- /dev/null +++ b/src/library-authoring/containers/ContainerEditableTitle.tsx @@ -0,0 +1,46 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useContext } from 'react'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; +import { ToastContext } from '../../generic/toast-context'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useContainer, useUpdateContainer } from '../data/apiHooks'; +import messages from './messages'; + +interface EditableTitleProps { + containerId: string; +} + +export const ContainerEditableTitle = ({ containerId }: EditableTitleProps) => { + const intl = useIntl(); + + const { readOnly } = useLibraryContext(); + + const { data: container } = useContainer(containerId); + + const updateMutation = useUpdateContainer(containerId); + const { showToast } = useContext(ToastContext); + + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + displayName: newDisplayName, + }); + showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); + } catch (err) { + showToast(intl.formatMessage(messages.updateContainerErrorMsg)); + } + }; + + // istanbul ignore if: this should never happen + if (!container) { + return null; + } + + return ( + + ); +}; diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx index 65357c0f14..a53be20655 100644 --- a/src/library-authoring/containers/ContainerInfoHeader.tsx +++ b/src/library-authoring/containers/ContainerInfoHeader.tsx @@ -33,7 +33,6 @@ const ContainerInfoHeader = () => { showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); } catch (err) { showToast(intl.formatMessage(messages.updateContainerErrorMsg)); - throw err; } }; diff --git a/src/library-authoring/containers/FooterActions.tsx b/src/library-authoring/containers/FooterActions.tsx new file mode 100644 index 0000000000..7f6de80b1e --- /dev/null +++ b/src/library-authoring/containers/FooterActions.tsx @@ -0,0 +1,51 @@ +import { Button, useToggle } from '@openedx/paragon'; +import { Add } from '@openedx/paragon/icons'; +import { PickLibraryContentModal } from '../add-content'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useSidebarContext } from '../common/context/SidebarContext'; + +interface FooterActionsProps { + addContentBtnText: string; + addExistingContentBtnText: string; +} + +export const FooterActions = ({ + addContentBtnText, + addExistingContentBtnText, +}: FooterActionsProps) => { + const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); + const { openAddContentSidebar } = useSidebarContext(); + const { readOnly } = useLibraryContext(); + return ( +
+
+ +
+
+ + +
+
+ ); +}; diff --git a/src/library-authoring/containers/HeaderActions.tsx b/src/library-authoring/containers/HeaderActions.tsx new file mode 100644 index 0000000000..63e0a526d4 --- /dev/null +++ b/src/library-authoring/containers/HeaderActions.tsx @@ -0,0 +1,70 @@ +import { Button } from '@openedx/paragon'; +import { Add, InfoOutline } from '@openedx/paragon/icons'; +import { useCallback } from 'react'; +import { ContainerType } from '../../generic/key-utils'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useSidebarContext } from '../common/context/SidebarContext'; +import { useLibraryRoutes } from '../routes'; + +interface HeaderActionsProps { + containerKey: string; + containerType: ContainerType; + infoBtnText: string; + addContentBtnText: string; +} + +export const HeaderActions = ({ + containerKey, + containerType, + infoBtnText, + addContentBtnText, +}: HeaderActionsProps) => { + const { readOnly } = useLibraryContext(); + const { + closeLibrarySidebar, + sidebarComponentInfo, + openUnitInfoSidebar, + openAddContentSidebar, + } = useSidebarContext(); + const { navigateTo } = useLibraryRoutes(); + + const infoSidebarIsOpen = sidebarComponentInfo?.id === containerKey; + + const handleOnClickInfoSidebar = useCallback(() => { + if (infoSidebarIsOpen) { + closeLibrarySidebar(); + } else { + switch (containerType) { + case ContainerType.Unit: + openUnitInfoSidebar(containerKey); + break; + /* istanbul ignore next */ + default: + break; + } + } + navigateTo({ [`${containerType}Id`]: containerKey }); + }, [containerKey, infoSidebarIsOpen, navigateTo]); + + return ( +
+ + +
+ ); +}; diff --git a/src/library-authoring/containers/index.tsx b/src/library-authoring/containers/index.tsx index 007a0eef72..66449377e1 100644 --- a/src/library-authoring/containers/index.tsx +++ b/src/library-authoring/containers/index.tsx @@ -1,2 +1,5 @@ export { default as UnitInfo } from './UnitInfo'; export { default as ContainerInfoHeader } from './ContainerInfoHeader'; +export { ContainerEditableTitle } from './ContainerEditableTitle'; +export { HeaderActions } from './HeaderActions'; +export { FooterActions } from './FooterActions'; diff --git a/src/library-authoring/create-container/CreateContainerModal.tsx b/src/library-authoring/create-container/CreateContainerModal.tsx index 752a450ad5..dab088e9e7 100644 --- a/src/library-authoring/create-container/CreateContainerModal.tsx +++ b/src/library-authoring/create-container/CreateContainerModal.tsx @@ -6,7 +6,6 @@ import { } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Formik } from 'formik'; -import { useNavigate } from 'react-router'; import * as Yup from 'yup'; import FormikControl from '../../generic/FormikControl'; import { useLibraryContext } from '../common/context/LibraryContext'; @@ -20,14 +19,13 @@ import { useLibraryRoutes } from '../routes'; /** Common modal to create section, subsection or unit in library */ const CreateContainerModal = () => { const intl = useIntl(); - const navigate = useNavigate(); const { collectionId, libraryId, createContainerModalType, setCreateContainerModalType, } = useLibraryContext(); - const { insideCollection } = useLibraryRoutes(); + const { navigateTo, insideCollection } = useLibraryRoutes(); const create = useCreateLibraryContainer(libraryId); const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId); const { showToast } = React.useContext(ToastContext); @@ -89,14 +87,14 @@ const CreateContainerModal = () => { await updateItemsMutation.mutateAsync([container.id]); } // Navigate to the new container - navigate(`/library/${libraryId}/${containerType}/${container.id}`); + navigateTo({ [`${containerType}Id`]: container.id }); showToast(labels.successMsg); } catch (error) { showToast(labels.errorMsg); } finally { handleClose(); } - }, [containerType, labels, handleClose]); + }, [containerType, labels, handleClose, navigateTo]); return ( { export async function mockGetContainerMetadata(containerId: string): Promise { switch (containerId) { case mockGetContainerMetadata.containerIdError: + case mockGetContainerMetadata.sectionIdError: + case mockGetContainerMetadata.subsectionIdError: throw createAxiosError({ code: 404, message: 'Not found.', path: api.getLibraryContainerApiUrl(containerId), }); case mockGetContainerMetadata.containerIdLoading: + case mockGetContainerMetadata.sectionIdLoading: + case mockGetContainerMetadata.subsectionIdLoading: return new Promise(() => { }); case mockGetContainerMetadata.containerIdWithCollections: return Promise.resolve(mockGetContainerMetadata.containerDataWithCollections); + case mockGetContainerMetadata.sectionId: + case mockGetContainerMetadata.sectionIdEmpty: + return Promise.resolve(mockGetContainerMetadata.sectionData); + case mockGetContainerMetadata.subsectionId: + case mockGetContainerMetadata.subsectionIdEmpty: + return Promise.resolve(mockGetContainerMetadata.subsectionData); default: return Promise.resolve(mockGetContainerMetadata.containerData); } } mockGetContainerMetadata.containerId = 'lct:org:lib:unit:test-unit-9a207'; +mockGetContainerMetadata.sectionId = 'lct:org:lib:section:test-section-1'; +mockGetContainerMetadata.subsectionId = 'lb:org1:Demo_course:subsection:subsection-0'; +mockGetContainerMetadata.sectionIdEmpty = 'lct:org:lib:section:test-section-empty'; +mockGetContainerMetadata.subsectionIdEmpty = 'lb:org1:Demo_course:subsection:subsection-empty'; mockGetContainerMetadata.containerIdError = 'lct:org:lib:unit:container_error'; +mockGetContainerMetadata.sectionIdError = 'lct:org:lib:section:section_error'; +mockGetContainerMetadata.subsectionIdError = 'lct:org:lib:section:section_error'; mockGetContainerMetadata.containerIdLoading = 'lct:org:lib:unit:container_loading'; +mockGetContainerMetadata.sectionIdLoading = 'lct:org:lib:section:section_loading'; +mockGetContainerMetadata.subsectionIdLoading = 'lct:org:lib:subsection:subsection_loading'; mockGetContainerMetadata.containerIdForTags = mockContentTaxonomyTagsData.containerTagsId; mockGetContainerMetadata.containerIdWithCollections = 'lct:org:lib:unit:container_collections'; mockGetContainerMetadata.containerData = { id: 'lct:org:lib:unit:test-unit-9a2072', - containerType: 'unit', + containerType: ContainerType.Unit, displayName: 'Test Unit', + publishedDisplayName: 'Test Unit', created: '2024-09-19T10:00:00Z', createdBy: 'test_author', lastPublished: '2024-09-20T10:00:00Z', @@ -504,6 +523,21 @@ mockGetContainerMetadata.containerData = { modified: '2024-09-20T11:00:00Z', hasUnpublishedChanges: true, collections: [], + tagsCount: 0, +} satisfies api.Container; +mockGetContainerMetadata.sectionData = { + ...mockGetContainerMetadata.containerData, + id: 'lct:org:lib:section:test-section-1', + containerType: ContainerType.Section, + displayName: 'Test section', + publishedDisplayName: 'Test section', +} satisfies api.Container; +mockGetContainerMetadata.subsectionData = { + ...mockGetContainerMetadata.containerData, + id: 'lb:org1:Demo_course:subsection:subsection-0', + containerType: ContainerType.Subsection, + displayName: 'Test subsection', + publishedDisplayName: 'Test subsection', } satisfies api.Container; mockGetContainerMetadata.containerDataWithCollections = { ...mockGetContainerMetadata.containerData, @@ -524,6 +558,8 @@ export async function mockGetContainerChildren(containerId: string): Promise ( { ...child, // Generate a unique ID for each child block to avoid "duplicate key" errors in tests - id: `lb:org1:Demo_course:html:text-${idx}`, - displayName: `text block ${idx}`, - publishedDisplayName: `text block published ${idx}`, + id: `lb:org1:Demo_course:${blockType}:${name}-${idx}`, + displayName: `${name} block ${idx}`, + publishedDisplayName: `${name} block published ${idx}`, } )), ); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 92f5d3f40b..990f8bcf09 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -598,8 +598,9 @@ export async function createLibraryContainer( export interface Container { id: string; - containerType: 'unit'; + containerType: ContainerType; displayName: string; + publishedDisplayName: string | null; lastPublished: string | null; publishedBy: string | null; createdBy: string | null; @@ -609,6 +610,7 @@ export interface Container { created: string; modified: string; collections: CollectionMetadata[]; + tagsCount: number; } /** @@ -656,7 +658,7 @@ export async function restoreContainer(containerId: string) { export async function getLibraryContainerChildren( containerId: string, published: boolean = false, -): Promise { +): Promise { const { data } = await getAuthenticatedHttpClient().get( getLibraryContainerChildrenApiUrl(containerId, published), ); diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 47ee776939..af996b46ef 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -29,7 +29,7 @@ import { useDeleteContainer, useRestoreContainer, useContainerChildren, - useAddComponentsToContainer, + useAddItemsToContainer, useUpdateContainerChildren, useRemoveContainerChildren, usePublishContainer, @@ -265,7 +265,7 @@ describe('library api hooks', () => { const url = getLibraryContainerChildrenApiUrl(containerId); axiosMock.onPost(url).reply(200); - const { result } = renderHook(() => useAddComponentsToContainer(containerId), { wrapper }); + const { result } = renderHook(() => useAddItemsToContainer(containerId), { wrapper }); await result.current.mutateAsync([componentId]); expect(axiosMock.history.post[0].url).toEqual(url); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 49b5e72c36..8573804ffc 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -61,13 +61,16 @@ export const libraryAuthoringQueryKeys = { 'blockTypes', libraryId, ], + allContainers: (libraryId?: string) => [ + ...libraryAuthoringQueryKeys.contentLibraryContent(libraryId), + 'container', + ], container: (containerId?: string) => { const baseKey = containerId - ? libraryAuthoringQueryKeys.contentLibraryContent(getLibraryId(containerId)) - : libraryAuthoringQueryKeys.all; + ? libraryAuthoringQueryKeys.allContainers(getLibraryId(containerId)) + : [...libraryAuthoringQueryKeys.all, 'container']; return [ ...baseKey, - 'container', containerId, ]; }, @@ -460,7 +463,7 @@ export const useDeleteXBlockAsset = (usageKey: string) => { /** * Get the metadata for a collection in a library */ -export const useCollection = (libraryId: string, collectionId: string) => ( +export const useCollection = (libraryId: string, collectionId?: string) => ( useQuery({ enabled: !!libraryId && !!collectionId, queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId), @@ -632,6 +635,8 @@ export const useUpdateContainer = (containerId: string) => { // container list. queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); queryClient.invalidateQueries({ queryKey: containerQueryKey }); + // NOTE: We invalidate all container query to update names in children list of containers + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.allContainers(libraryId) }); }, }); }; @@ -693,17 +698,17 @@ export const useContainerChildren = (containerId?: string, published: boolean = ); /** - * Use this mutation to add components to a container + * Use this mutation to add items to a container */ -export const useAddComponentsToContainer = (containerId?: string) => { +export const useAddItemsToContainer = (containerId?: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (componentIds: string[]) => { + mutationFn: async (itemIds: string[]) => { // istanbul ignore if: this should never happen if (!containerId) { return undefined; } - return api.addComponentsToContainer(containerId, componentIds); + return api.addComponentsToContainer(containerId, itemIds); }, onSettled: () => { // istanbul ignore if: this should never happen @@ -805,17 +810,17 @@ export const usePublishContainer = (containerId: string) => { }; /** - * Use this mutations to get a list of components from the search index + * Use this mutations to get a list of objects from the search index */ -export const useComponentsFromSearchIndex = (componentIds: string[]) => { +export const useContentFromSearchIndex = (contentIds: string[]) => { const { client, indexName } = useContentSearchConnection(); return useContentSearchResults({ client, indexName, searchKeywords: '', - extraFilter: [`usage_key IN ["${componentIds.join('","')}"]`], - limit: componentIds.length, - enabled: !!componentIds.length, + extraFilter: [`usage_key IN ["${contentIds.join('","')}"]`], + limit: contentIds.length, + enabled: !!contentIds.length, skipBlockTypeFetch: true, }); }; diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 9211dc2381..3c439e73b8 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -3,6 +3,7 @@ @import "./generic"; @import "./LibraryAuthoringPage"; @import "./units"; +@import "./section-subsections"; .library-cards-grid { display: grid; diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index a5409e5cf6..7906cb641c 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -33,10 +33,10 @@ export const ROUTES = { COLLECTION: '/collection/:collectionId/:selectedItemId?', // LibrarySectionPage route: // * with a selected sectionId and/or an optionally selected subsectionId. - SECTION: '/section/:sectionId/:subsectionId?', + SECTION: '/section/:sectionId/:selectedItemId?', // LibrarySubsectionPage route: // * with a selected subsectionId and/or an optionally selected unitId. - SUBSECTION: '/subsection/:subsectionId/:unitId?', + SUBSECTION: '/subsection/:subsectionId/:selectedItemId?', // LibraryUnitPage route: // * with a selected unitId and/or an optionally selected componentId. UNIT: '/unit/:unitId/:selectedItemId?', @@ -47,8 +47,8 @@ export enum ContentType { collections = 'collections', components = 'components', units = 'units', - subsections = 'subsections', sections = 'sections', + subsections = 'subsections', } export const allLibraryPageTabs: ContentType[] = Object.values(ContentType); @@ -57,6 +57,8 @@ export type NavigateToData = { selectedItemId?: string, collectionId?: string, contentType?: ContentType, + sectionId?: string, + subsectionId?: string, unitId?: string, }; @@ -116,6 +118,8 @@ export const useLibraryRoutes = (): LibraryRoutesData => { const navigateTo = useCallback(({ selectedItemId, collectionId, + sectionId, + subsectionId, unitId, contentType, }: NavigateToData = {}) => { @@ -123,6 +127,8 @@ export const useLibraryRoutes = (): LibraryRoutesData => { ...params, // Overwrite the params with the provided values. ...((selectedItemId !== undefined) && { selectedItemId }), + ...((sectionId !== undefined) && { sectionId }), + ...((subsectionId !== undefined) && { subsectionId }), ...((unitId !== undefined) && { unitId }), ...((collectionId !== undefined) && { collectionId }), }; @@ -134,21 +140,24 @@ export const useLibraryRoutes = (): LibraryRoutesData => { routeParams.selectedItemId = undefined; } - // Update unitId/collectionId in library context if is not undefined. + // Update sectionId/subsectionId/unitId/collectionId in library context if is not undefined. // Ids can be cleared from route by passing in empty string so we need to set it. - if (unitId !== undefined) { + if (unitId !== undefined || sectionId !== undefined || subsectionId !== undefined) { routeParams.selectedItemId = undefined; - // If we can have a unitId alongside a routeParams.collectionId, it means we are inside a collection - // trying to navigate to a unit, so we want to clear the collectionId to not have ambiquity. + // If we can have a unitId/subsectionId/sectionId alongside a routeParams.collectionId, + // it means we are inside a collection trying to navigate to a unit/section/subsection, + // so we want to clear the collectionId to not have ambiquity. if (routeParams.collectionId !== undefined) { routeParams.collectionId = undefined; } } else if (collectionId !== undefined) { routeParams.selectedItemId = undefined; } else if (contentType) { - // We are navigating to the library home, so we need to clear the unitId and collectionId + // We are navigating to the library home, so we need to clear the sectionId, subsectionId, unitId and collectionId routeParams.unitId = undefined; + routeParams.sectionId = undefined; + routeParams.subsectionId = undefined; routeParams.collectionId = undefined; } @@ -194,6 +203,10 @@ export const useLibraryRoutes = (): LibraryRoutesData => { route = ROUTES.HOME; } else if (routeParams.unitId) { route = ROUTES.UNIT; + } else if (routeParams.subsectionId) { + route = ROUTES.SUBSECTION; + } else if (routeParams.sectionId) { + route = ROUTES.SECTION; } else if (routeParams.collectionId) { route = ROUTES.COLLECTION; // From here, we will just stay in the current route diff --git a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx new file mode 100644 index 0000000000..3953a80df6 --- /dev/null +++ b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx @@ -0,0 +1,220 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + useCallback, useContext, useEffect, useState, +} from 'react'; +import { + ActionRow, Badge, Icon, Stack, +} from '@openedx/paragon'; +import { Description } from '@openedx/paragon/icons'; +import DraggableList, { SortableItem } from '../../generic/DraggableList'; +import Loading from '../../generic/Loading'; +import ErrorAlert from '../../generic/alert-error'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { + useContainerChildren, + useUpdateContainer, + useUpdateContainerChildren, +} from '../data/apiHooks'; +import { messages, subsectionMessages, sectionMessages } from './messages'; +import containerMessages from '../containers/messages'; +import { Container } from '../data/api'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; +import { ToastContext } from '../../generic/toast-context'; +import TagCount from '../../generic/tag-count'; +import { ContainerMenu } from '../components/ContainerCard'; +import { useLibraryRoutes } from '../routes'; +import { useSidebarContext } from '../common/context/SidebarContext'; + +interface LibraryContainerChildrenProps { + containerKey: string; + /** set to true if it is rendered as preview */ + readOnly?: boolean; +} + +interface LibraryContainerMetadataWithUniqueId extends Container { + originalId: string; +} + +interface ContainerRowProps extends LibraryContainerChildrenProps { + container: LibraryContainerMetadataWithUniqueId; +} + +const ContainerRow = ({ container, readOnly }: ContainerRowProps) => { + const intl = useIntl(); + const { showToast } = useContext(ToastContext); + const updateMutation = useUpdateContainer(container.originalId); + const { showOnlyPublished } = useLibraryContext(); + + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + displayName: newDisplayName, + }); + showToast(intl.formatMessage(containerMessages.updateContainerSuccessMsg)); + } catch (err) { + showToast(intl.formatMessage(containerMessages.updateContainerErrorMsg)); + } + }; + + return ( + <> + + + e.stopPropagation()} + > + {!showOnlyPublished && container.hasUnpublishedChanges && ( + + + + + + + )} + + + + + ); +}; + +/** Component to display container children subsections for section and units for subsection */ +export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryContainerChildrenProps) => { + const intl = useIntl(); + const [orderedChildren, setOrderedChildren] = useState([]); + const { showOnlyPublished, readOnly: libReadOnly } = useLibraryContext(); + const { navigateTo, insideSection, insideSubsection } = useLibraryRoutes(); + const { sidebarComponentInfo } = useSidebarContext(); + const [activeDraggingId, setActiveDraggingId] = useState(null); + const orderMutator = useUpdateContainerChildren(containerKey); + const { showToast } = useContext(ToastContext); + const handleReorder = useCallback(() => async (newOrder?: LibraryContainerMetadataWithUniqueId[]) => { + if (!newOrder) { + return; + } + const childrenKeys = newOrder.map((o) => o.originalId); + try { + await orderMutator.mutateAsync(childrenKeys); + showToast(intl.formatMessage(messages.orderUpdatedMsg)); + } catch (e) { + showToast(intl.formatMessage(messages.failedOrderUpdatedMsg)); + } + }, [orderMutator]); + + const { + data: children, + isLoading, + isError, + error, + } = useContainerChildren(containerKey, showOnlyPublished); + + useEffect(() => { + // Create new ids which are unique using index. + // This is required to support multiple components with same id under a container. + const newChildren = children?.map((child, idx) => { + const newChild: LibraryContainerMetadataWithUniqueId = { + ...child, + id: `${child.id}----${idx}`, + originalId: child.id, + }; + return newChild; + }); + return setOrderedChildren(newChildren || []); + }, [children, setOrderedChildren]); + + const handleChildClick = useCallback((child: LibraryContainerMetadataWithUniqueId, numberOfClicks: number) => { + const doubleClicked = numberOfClicks > 1; + if (!doubleClicked) { + navigateTo({ selectedItemId: child.originalId }); + } else if (insideSection) { + navigateTo({ subsectionId: child.originalId }); + } else if (insideSubsection) { + navigateTo({ unitId: child.originalId }); + } + }, [navigateTo]); + + const getComponentStyle = useCallback((childId: string) => { + const style: { marginBottom: string, borderRadius: string, outline?: string } = { + marginBottom: '1rem', + borderRadius: '8px', + }; + if (activeDraggingId === childId) { + style.outline = '2px dashed gray'; + } + return style; + }, [activeDraggingId]); + + if (isLoading) { + return ; + } + + if (isError) { + // istanbul ignore next + return ; + } + + return ( +
+ {children?.length === 0 && ( +

+ {insideSection ? ( + + ) : ( + + )} +

+ )} + + {orderedChildren?.map((child) => ( + // A container can have multiple instances of the same block + // eslint-disable-next-line react/no-array-index-key + handleChildClick(child, e.detail)} + disabled={readOnly || libReadOnly} + cardClassName={sidebarComponentInfo?.id === child.originalId ? 'selected' : undefined} + actions={( + + )} + /> + + ))} + +
+ ); +}; diff --git a/src/library-authoring/section-subsections/LibrarySectionPage.tsx b/src/library-authoring/section-subsections/LibrarySectionPage.tsx new file mode 100644 index 0000000000..dbe49cc93d --- /dev/null +++ b/src/library-authoring/section-subsections/LibrarySectionPage.tsx @@ -0,0 +1,126 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Helmet } from 'react-helmet'; +import { Breadcrumb, Container } from '@openedx/paragon'; +import { Link } from 'react-router-dom'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useSidebarContext } from '../common/context/SidebarContext'; +import { useContainer, useContentLibrary } from '../data/apiHooks'; +import Loading from '../../generic/Loading'; +import NotFoundAlert from '../../generic/NotFoundAlert'; +import ErrorAlert from '../../generic/alert-error'; +import Header from '../../header'; +import SubHeader from '../../generic/sub-header/SubHeader'; +import { SubHeaderTitle } from '../LibraryAuthoringPage'; +import { messages, sectionMessages } from './messages'; +import { LibrarySidebar } from '../library-sidebar'; +import { LibraryContainerChildren } from './LibraryContainerChildren'; +import { ContainerEditableTitle, FooterActions, HeaderActions } from '../containers'; +import { ContainerType } from '../../generic/key-utils'; + +/** Full library section page */ +export const LibrarySectionPage = () => { + const intl = useIntl(); + const { libraryId, sectionId } = useLibraryContext(); + const { + sidebarComponentInfo, + } = useSidebarContext(); + + if (!sectionId || !libraryId) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Rendered without sectionId or libraryId URL parameter'); + } + + const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId); + const { + data: sectionData, + isLoading, + isError, + error, + } = useContainer(sectionId); + + // show loading if sectionId or libraryId is not set or section or library data is not fetched from index yet + if (isLibLoading || isLoading) { + return ; + } + + if (!libraryData || !sectionData) { + return ; + } + + // istanbul ignore if + if (isError) { + return ; + } + + const breadcrumbs = ( + ` spacer. + { + label: '', + to: '', + }, + ]} + linkAs={Link} + /> + ); + + return ( +
+
+ + + {libraryData.title} | {sectionData.displayName} | {process.env.SITE_NAME} + + +
+ +
+ } />} + breadcrumbs={breadcrumbs} + headerActions={( + + )} + hideBorder + /> +
+ + + + +
+
+ {!!sidebarComponentInfo?.type && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx b/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx new file mode 100644 index 0000000000..b0d2352f57 --- /dev/null +++ b/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx @@ -0,0 +1,349 @@ +import userEvent from '@testing-library/user-event'; +import type MockAdapter from 'axios-mock-adapter'; + +import { act } from 'react'; +import { + initializeMocks, + fireEvent, + render, + screen, + waitFor, +} from '../../testUtils'; +import { + getLibraryContainerApiUrl, + getLibraryContainerChildrenApiUrl, +} from '../data/api'; +import { + mockContentLibrary, + mockXBlockFields, + mockGetContainerMetadata, + mockGetContainerChildren, + mockLibraryBlockMetadata, +} from '../data/api.mocks'; +import { mockContentSearchConfig, mockGetBlockTypes, mockSearchResult } from '../../search-manager/data/api.mock'; +import { mockClipboardEmpty } from '../../generic/data/api.mock'; +import LibraryLayout from '../LibraryLayout'; +import { ToastActionData } from '../../generic/toast-context'; +import mockResult from '../__mocks__/subsection-single.json'; +import { ContainerType } from '../../generic/key-utils'; + +const path = '/library/:libraryId/*'; +const libraryTitle = mockContentLibrary.libraryData.title; + +let axiosMock: MockAdapter; +let mockShowToast: (message: string, action?: ToastActionData | undefined) => void; + +mockClipboardEmpty.applyMock(); +mockGetContainerMetadata.applyMock(); +mockGetContainerChildren.applyMock(); +mockContentSearchConfig.applyMock(); +mockGetBlockTypes.applyMock(); +mockContentLibrary.applyMock(); +mockXBlockFields.applyMock(); +mockLibraryBlockMetadata.applyMock(); +const searchFilterfn = (requestData: any) => { + const queryFilter = requestData?.queries[0]?.filter?.[1]; + const subsectionId = queryFilter?.split('usage_key IN ["')[1].split('"]')[0]; + switch (subsectionId) { + case mockGetContainerMetadata.subsectionIdLoading: + return new Promise(() => {}); + case mockGetContainerMetadata.subsectionIdError: + return Promise.reject(new Error('Not found')); + default: + return mockResult; + } +}; +mockSearchResult(mockResult, searchFilterfn); + +const verticalSortableListCollisionDetection = jest.fn(); +jest.mock('../../generic/DraggableList/verticalSortableList', () => ({ + ...jest.requireActual('../../generic/DraggableList/verticalSortableList'), + // Since jsdom (used by jest) does not support getBoundingClientRect function + // which is required for drag-n-drop calculations, we mock closestCorners fn + // from dnd-kit to return collided elements as per the test. This allows us to + // test all drag-n-drop handlers. + verticalSortableListCollisionDetection: () => verticalSortableListCollisionDetection(), +})); + +describe('', () => { + beforeEach(() => { + ({ axiosMock, mockShowToast } = initializeMocks()); + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + }); + + const renderLibrarySectionPage = ( + containerId?: string, + libraryId?: string, + cType: ContainerType = ContainerType.Section, + ) => { + const libId = libraryId || mockContentLibrary.libraryId; + const defaultId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + const cId = containerId || defaultId; + render(, { + path, + routerProps: { + initialEntries: [`/library/${libId}/${cType}/${cId}`], + }, + }); + }; + + [ + ContainerType.Section, + ContainerType.Subsection, + ].forEach((cType) => { + const childType = cType === ContainerType.Section + ? ContainerType.Subsection + : ContainerType.Unit; + it(`shows the spinner before the query is complete in ${cType} page`, async () => { + // This mock will never return data about the collection (it loads forever): + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionIdLoading + : mockGetContainerMetadata.subsectionIdLoading; + renderLibrarySectionPage(cId, undefined, cType); + const spinner = screen.getByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + }); + + it(`shows an error component if no ${cType} returned`, async () => { + // This mock will simulate incorrect section id + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionIdError + : mockGetContainerMetadata.subsectionIdError; + renderLibrarySectionPage(cId, undefined, cType); + const errorMessage = 'Not found'; + expect(await screen.findByRole('alert')).toHaveTextContent(errorMessage); + }); + + it(`shows ${cType} data`, async () => { + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + renderLibrarySectionPage(cId, undefined, cType); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + // Unit title + expect((await screen.findAllByText(`Test ${cType}`))[0]).toBeInTheDocument(); + // unit info button + expect(await screen.findByRole('button', { name: new RegExp(`${cType} Info`, 'i') })).toBeInTheDocument(); + expect((await screen.findAllByRole('button', { name: 'Drag to reorder' })).length).toEqual(3); + // check all children components are rendered. + expect(await screen.findByText(`${childType} block 0`)).toBeInTheDocument(); + expect(await screen.findByText(`${childType} block 1`)).toBeInTheDocument(); + expect(await screen.findByText(`${childType} block 2`)).toBeInTheDocument(); + }); + + it(`shows ${cType} data with no children`, async () => { + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionIdEmpty + : mockGetContainerMetadata.subsectionIdEmpty; + renderLibrarySectionPage(cId, undefined, cType); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + // Unit title + expect((await screen.findAllByText(`Test ${cType}`))[0]).toBeInTheDocument(); + // unit info button + expect(await screen.findByRole('button', { name: new RegExp(`${cType} Info`, 'i') })).toBeInTheDocument(); + // check all children components are rendered. + expect(await screen.findByText(`This ${cType} is empty`)).toBeInTheDocument(); + }); + + it(`can rename ${cType}`, async () => { + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + renderLibrarySectionPage(cId, undefined, cType); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect(await screen.findByText(`Test ${cType}`)).toBeInTheDocument(); + + const editContainerTitleButton = (await screen.findAllByRole( + 'button', + { name: /edit/i }, + ))[0]; // 0 is the Section/Subsection Title, 1 is the first child on the list + fireEvent.click(editContainerTitleButton); + + const url = getLibraryContainerApiUrl(cId); + axiosMock.onPatch(url).reply(200); + + expect(await screen.findByRole('textbox', { name: /text input/i })).toBeInTheDocument(); + + const textBox = await screen.findByRole('textbox', { name: /text input/i }); + expect(textBox).toBeInTheDocument(); + fireEvent.change(textBox, { target: { value: `New ${cType} Title` } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(axiosMock.history.patch[0].url).toEqual(url); + }); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ display_name: `New ${cType} Title` })); + + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Container updated successfully.'); + }); + + it(`show error if renaming ${cType} fails`, async () => { + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + renderLibrarySectionPage(cId, undefined, cType); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect(await screen.findByText(`Test ${cType}`)).toBeInTheDocument(); + + const editContainerTitleButton = (await screen.findAllByRole( + 'button', + { name: /edit/i }, + ))[0]; // 0 is the Section/subsection Title, 1 is the first child on the list + fireEvent.click(editContainerTitleButton); + + const url = getLibraryContainerApiUrl(cId); + axiosMock.onPatch(url).reply(400); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument(); + }); + + const textBox = screen.getByRole('textbox', { name: /text input/i }); + expect(textBox).toBeInTheDocument(); + fireEvent.change(textBox, { target: { value: `New ${cType} Title` } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(axiosMock.history.patch[0].url).toEqual(url); + }); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ display_name: `New ${cType} Title` })); + + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Failed to update container.'); + }); + + it(`should rename child by clicking edit icon besides name in ${cType} page`, async () => { + const url = getLibraryContainerApiUrl(`lb:org1:Demo_course:${childType}:${childType}-0`); + axiosMock.onPatch(url).reply(200); + renderLibrarySectionPage(undefined, undefined, cType); + + // Wait loading of the component + await screen.findByText(`${childType} block 0`); + + const editButton = (await screen.findAllByRole( + 'button', + { name: /edit/i }, + ))[1]; // 0 is the Section Title, 1 is the first subsection on the list + fireEvent.click(editButton); + screen.debug(editButton); + + expect(await screen.findByRole('textbox', { name: /text input/i })).toBeInTheDocument(); + + const textBox = await screen.findByRole('textbox', { name: /text input/i }); + expect(textBox).toBeInTheDocument(); + fireEvent.change(textBox, { target: { value: `New ${childType} Title` } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(axiosMock.history.patch.length).toEqual(1); + }); + expect(axiosMock.history.patch[0].url).toEqual(url); + expect(axiosMock.history.patch[0].data).toStrictEqual(JSON.stringify({ + display_name: `New ${childType} Title`, + })); + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Container updated successfully.'); + }); + + it(`should show error while updating child name in ${cType} page`, async () => { + const url = getLibraryContainerApiUrl(`lb:org1:Demo_course:${childType}:${childType}-0`); + axiosMock.onPatch(url).reply(400); + renderLibrarySectionPage(undefined, undefined, cType); + + // Wait loading of the component + await screen.findByText(`${childType} block 0`); + + const editButton = screen.getAllByRole( + 'button', + { name: /edit/i }, + )[1]; // 0 is the Section Title, 1 is the first subsection on the list + fireEvent.click(editButton); + + expect(await screen.findByRole('textbox', { name: /text input/i })).toBeInTheDocument(); + + const textBox = await screen.findByRole('textbox', { name: /text input/i }); + expect(textBox).toBeInTheDocument(); + fireEvent.change(textBox, { target: { value: `New ${childType} Title` } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(axiosMock.history.patch.length).toEqual(1); + }); + expect(axiosMock.history.patch[0].url).toEqual(url); + expect(axiosMock.history.patch[0].data).toStrictEqual(JSON.stringify({ + display_name: `New ${childType} Title`, + })); + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Failed to update container.'); + }); + + it(`should call update order api on dragging children in ${cType} page`, async () => { + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + renderLibrarySectionPage(cId, undefined, cType); + const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0]; + axiosMock + .onPatch(getLibraryContainerChildrenApiUrl(cId)) + .reply(200); + verticalSortableListCollisionDetection.mockReturnValue([{ + id: `lb:org1:Demo_course:${childType}:${childType}-1----1`, + }]); + await act(async () => { + fireEvent.keyDown(firstDragHandle, { code: 'Space' }); + }); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' })); + await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Order updated')); + }); + + it(`should cancel update order api on cancelling dragging component in ${cType} page`, async () => { + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + renderLibrarySectionPage(cId, undefined, cType); + const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0]; + axiosMock + .onPatch(getLibraryContainerChildrenApiUrl(cId)) + .reply(200); + verticalSortableListCollisionDetection.mockReturnValue([{ id: `lb:org1:Demo_course:${childType}:${childType}-1----1` }]); + await act(async () => { + fireEvent.keyDown(firstDragHandle, { code: 'Space' }); + }); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Escape' })); + await waitFor(() => expect(mockShowToast).not.toHaveBeenLastCalledWith('Order updated')); + }); + + it(`should show toast error message on update order failure in ${cType} page`, async () => { + const cId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + renderLibrarySectionPage(cId, undefined, cType); + const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0]; + axiosMock + .onPatch(getLibraryContainerChildrenApiUrl(cId)) + .reply(500); + verticalSortableListCollisionDetection.mockReturnValue([{ id: `lb:org1:Demo_course:${childType}:${childType}-1----1` }]); + await act(async () => { + fireEvent.keyDown(firstDragHandle, { code: 'Space' }); + }); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' })); + await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Failed to update children order')); + }); + + it(`should open ${childType} page on double click`, async () => { + renderLibrarySectionPage(undefined, undefined, cType); + const subsection = await screen.findByText(`${childType} block 0`); + // trigger double click + userEvent.click(subsection.parentElement!, undefined, { clickCount: 2 }); + expect((await screen.findAllByText(new RegExp(`Test ${childType}`, 'i')))[0]).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: new RegExp(`${childType} Info`, 'i') })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx b/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx new file mode 100644 index 0000000000..5362f42d12 --- /dev/null +++ b/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx @@ -0,0 +1,177 @@ +import { ReactNode, useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Helmet } from 'react-helmet'; +import { + Breadcrumb, Container, MenuItem, SelectMenu, +} from '@openedx/paragon'; +import { Link } from 'react-router-dom'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useSidebarContext } from '../common/context/SidebarContext'; +import { useContentFromSearchIndex, useContentLibrary } from '../data/apiHooks'; +import Loading from '../../generic/Loading'; +import NotFoundAlert from '../../generic/NotFoundAlert'; +import ErrorAlert from '../../generic/alert-error'; +import Header from '../../header'; +import SubHeader from '../../generic/sub-header/SubHeader'; +import { SubHeaderTitle } from '../LibraryAuthoringPage'; +import { messages, subsectionMessages } from './messages'; +import { LibrarySidebar } from '../library-sidebar'; +import { LibraryContainerChildren } from './LibraryContainerChildren'; +import { ContainerEditableTitle, FooterActions, HeaderActions } from '../containers'; +import { ContainerType } from '../../generic/key-utils'; +import { ContainerHit } from '../../search-manager'; + +interface OverflowLinksProps { + to: string | string[]; + children: ReactNode | ReactNode[]; +} + +const OverflowLinks = ({ children, to }: OverflowLinksProps) => { + if (typeof to === 'string') { + return ( + + {children} + + ); + } + // to is string[] that should be converted to overflow menu + const items = to?.map((link, index) => ( + + {children?.[index]} + + )); + return ( + + {items} + + ); +}; + +/** Full library subsection page */ +export const LibrarySubsectionPage = () => { + const intl = useIntl(); + const { libraryId, subsectionId } = useLibraryContext(); + const { + sidebarComponentInfo, + } = useSidebarContext(); + + const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId); + // fetch subsectionData from index as it includes its parent sections as well. + const { + hits, isLoading, isError, error, + } = useContentFromSearchIndex(subsectionId ? [subsectionId] : []); + const subsectionData = (hits as ContainerHit[])?.[0]; + + const breadcrumbs = useMemo(() => { + const links: Array<{ label: string | string[], to: string | string[] }> = [ + { + label: libraryData?.title || '', + to: `/library/${libraryId}`, + }, + ]; + const sectionLength = subsectionData?.sections?.displayName?.length || 0; + if (sectionLength === 1) { + links.push({ + label: subsectionData.sections?.displayName?.[0] || '', + to: `/library/${libraryId}/section/${subsectionData?.sections?.key?.[0]}`, + }); + } else if (sectionLength > 1) { + // Add all sections as a single object containing list of links + // This is converted to overflow menu by OverflowLinks component + links.push({ + label: subsectionData?.sections?.displayName || '', + to: subsectionData?.sections?.key?.map((link) => `/library/${libraryId}/section/${link}`) || '', + }); + } else { + // Adding empty breadcrumb to add the last `>` spacer. + links.push({ + label: '', + to: '', + }); + } + return ( + + ); + }, [libraryData, subsectionData, libraryId]); + + if (!subsectionId || !libraryId) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Rendered without subsectionId or libraryId URL parameter'); + } + + // Only show loading if section or library data is not fetched from index yet + if (isLibLoading || isLoading) { + return ; + } + + if (!libraryData || !subsectionData) { + return ; + } + + // istanbul ignore if + if (isError) { + return ; + } + + return ( +
+
+ + + {libraryData.title} | {subsectionData.displayName} | {process.env.SITE_NAME} + + +
+ +
+ } />} + breadcrumbs={breadcrumbs} + headerActions={( + + )} + hideBorder + /> +
+ + + + +
+
+ {!!sidebarComponentInfo?.type && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/library-authoring/section-subsections/index.scss b/src/library-authoring/section-subsections/index.scss new file mode 100644 index 0000000000..9ccd176d51 --- /dev/null +++ b/src/library-authoring/section-subsections/index.scss @@ -0,0 +1,38 @@ +.library-container-children { + .pgn__card { + border-radius: 8px; + padding: 0; + margin-bottom: 1rem; + } + + .pgn__card.clickable { + box-shadow: none; + + &::before { + border: none !important; // Remove default focus + } + + &.selected:not(:focus) { + outline: 2px $gray-700 solid; + } + + &.selected:focus { + outline: 3px $gray-700 solid; + } + + &:not(.selected):focus { + outline: 1px $gray-200 solid; + outline-offset: 2px; + } + } + + .pgn__card.clickable:hover { + box-shadow: 0 .125rem .25rem rgb(0 0 0 / .15), 0 .125rem .5rem rgb(0 0 0 / .15); + } +} + +.breadcrumb-menu { + button { + padding: 0; + } +} diff --git a/src/library-authoring/section-subsections/index.tsx b/src/library-authoring/section-subsections/index.tsx new file mode 100644 index 0000000000..45e2222b2b --- /dev/null +++ b/src/library-authoring/section-subsections/index.tsx @@ -0,0 +1,2 @@ +export { LibrarySectionPage } from './LibrarySectionPage'; +export { LibrarySubsectionPage } from './LibrarySubsectionPage'; diff --git a/src/library-authoring/section-subsections/messages.ts b/src/library-authoring/section-subsections/messages.ts new file mode 100644 index 0000000000..896b4cbf6a --- /dev/null +++ b/src/library-authoring/section-subsections/messages.ts @@ -0,0 +1,80 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +export const messages = defineMessages({ + breadcrumbsAriaLabel: { + id: 'course-authoring.library-authoring.section-page.breadcrumbs.label.text', + defaultMessage: 'Navigation breadcrumbs', + description: 'Aria label for navigation breadcrumbs', + }, + orderUpdatedMsg: { + id: 'course-authoring.library-authoring.container-component.order-updated-msg.text', + defaultMessage: 'Order updated', + description: 'Toast message displayed when children items are successfully reordered in a container', + }, + failedOrderUpdatedMsg: { + id: 'course-authoring.library-authoring.container-component.failed-order-updated-msg.text', + defaultMessage: 'Failed to update children order', + description: 'Toast message displayed when reordering of children items in container fails', + }, + draftChipText: { + id: 'course-authoring.library-authoring.container-component.draft-chip.text', + defaultMessage: 'Draft', + description: 'Chip in children in section and subsection page that is shown when children has unpublished changes', + }, +}); + +export const sectionMessages = defineMessages({ + infoButtonText: { + id: 'course-authoring.library-authoring.section-header.buttons.info', + defaultMessage: 'Section Info', + description: 'Button text to section sidebar from section page', + }, + addContentButton: { + id: 'course-authoring.library-authoring.section-header.buttons.add-subsection', + defaultMessage: 'Add New Subsection', + description: 'Text of button to add subsection to section', + }, + addExistingContentButton: { + id: 'course-authoring.library-authoring.section-header.buttons.add-existing-subsection', + defaultMessage: 'Add Existing Subsection', + description: 'Text of button to add existing content to section', + }, + newContentButton: { + id: 'course-authoring.library-authoring.section-header.buttons.add-new-subsection', + defaultMessage: 'Add Subsection', + description: 'Text of button to add new subsection to section in header', + }, + noChildrenText: { + id: 'course-authoring.library-authoring.section.no-children.text', + defaultMessage: 'This section is empty', + description: 'Message to display when section has not children', + }, +}); + +export const subsectionMessages = defineMessages({ + infoButtonText: { + id: 'course-authoring.library-authoring.subsection-header.buttons.info', + defaultMessage: 'Subsection Info', + description: 'Button text to subsection sidebar from subsection page', + }, + addContentButton: { + id: 'course-authoring.library-authoring.subsection-header.buttons.add-subsection', + defaultMessage: 'Add New Unit', + description: 'Text of button to add subsection to subsection', + }, + addExistingContentButton: { + id: 'course-authoring.library-authoring.subsection-header.buttons.add-existing-subsection', + defaultMessage: 'Add Existing Unit', + description: 'Text of button to add existing content to subsection', + }, + newContentButton: { + id: 'course-authoring.library-authoring.subsection-header.buttons.add-new-subsection', + defaultMessage: 'Add Unit', + description: 'Text of button to add new subsection to subsection in header', + }, + noChildrenText: { + id: 'course-authoring.library-authoring.subsection.no-children.text', + defaultMessage: 'This subsection is empty', + description: 'Message to display when subsection has not children', + }, +}); diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 8d1791a64c..9151b59a97 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -1,8 +1,8 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { - ActionRow, Badge, Button, Icon, Stack, useToggle, + ActionRow, Badge, Icon, Stack, } from '@openedx/paragon'; -import { Add, Description } from '@openedx/paragon/icons'; +import { Description } from '@openedx/paragon/icons'; import classNames from 'classnames'; import { useCallback, useContext, useEffect, useState, @@ -18,7 +18,6 @@ import { InplaceTextEditor } from '../../generic/inplace-text-editor'; import Loading from '../../generic/Loading'; import TagCount from '../../generic/tag-count'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { PickLibraryContentModal } from '../add-content'; import ComponentMenu from '../components'; import { LibraryBlockMetadata } from '../data/api'; import { @@ -27,9 +26,9 @@ import { useUpdateXBlockFields, } from '../data/apiHooks'; import { LibraryBlock } from '../LibraryBlock'; -import { useLibraryRoutes, ContentType } from '../routes'; +import { useLibraryRoutes } from '../routes'; import messages from './messages'; -import { SidebarActions, SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; +import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; import { ToastContext } from '../../generic/toast-context'; import { canEditComponent } from '../components/ComponentEditorModal'; import { useRunOnNextRender } from '../../utils'; @@ -73,7 +72,6 @@ const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => { showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); } catch (err) { showToast(intl.formatMessage(messages.updateComponentErrorMsg)); - throw err; } }; @@ -103,7 +101,7 @@ const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => { @@ -173,9 +171,6 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) => return {}; }, [isDragging, block]); - const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.ComponentInfo - && sidebarComponentInfo?.id === block.originalId; - return ( borderBottom: 'solid 1px #E1DDDB', }} isClickable={!readOnly} - onClick={!readOnly ? (e: { detail: number; }) => handleComponentSelection(e.detail) : undefined} + onClick={!readOnly ? (e) => handleComponentSelection(e.detail) : undefined} disabled={readOnly} - cardClassName={selected ? 'selected' : undefined} + cardClassName={sidebarComponentInfo?.id === block.originalId ? 'selected' : undefined} > {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
{ const intl = useIntl(); const [orderedBlocks, setOrderedBlocks] = useState([]); - const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); const [hidePreviewFor, setHidePreviewFor] = useState(null); const { showToast } = useContext(ToastContext); @@ -233,8 +227,6 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra const readOnly = componentReadOnly || libraryReadOnly; - const { openAddContentSidebar } = useSidebarContext(); - const orderMutator = useUpdateContainerChildren(unitId); const { data: blocks, @@ -300,40 +292,6 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra /> ))} - {!readOnly && ( -
-
- -
-
- - -
-
- )}
); }; diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index 18671ca562..bc99eeabe4 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -187,6 +187,7 @@ describe('', () => { it('should open and close component sidebar on component selection', async () => { renderLibraryUnitPage(); + expect((await screen.findAllByText('Test Unit')).length).toBeGreaterThan(1); const component = await screen.findByText('text block 0'); // Card is 3 levels up the component name div userEvent.click(component.parentElement!.parentElement!.parentElement!); diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx index bdcb449ea4..6fdd142573 100644 --- a/src/library-authoring/units/LibraryUnitPage.tsx +++ b/src/library-authoring/units/LibraryUnitPage.tsx @@ -1,11 +1,9 @@ +import { useEffect } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Breadcrumb, - Button, Container, } from '@openedx/paragon'; -import { Add, InfoOutline } from '@openedx/paragon/icons'; -import { useCallback, useContext, useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; @@ -13,116 +11,18 @@ import Loading from '../../generic/Loading'; import NotFoundAlert from '../../generic/NotFoundAlert'; import SubHeader from '../../generic/sub-header/SubHeader'; import ErrorAlert from '../../generic/alert-error'; -import { InplaceTextEditor } from '../../generic/inplace-text-editor'; -import { ToastContext } from '../../generic/toast-context'; import Header from '../../header'; -import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; import { - COLLECTION_INFO_TABS, COMPONENT_INFO_TABS, SidebarBodyComponentId, UNIT_INFO_TABS, useSidebarContext, + COLLECTION_INFO_TABS, COMPONENT_INFO_TABS, UNIT_INFO_TABS, useSidebarContext, } from '../common/context/SidebarContext'; -import { useContainer, useUpdateContainer, useContentLibrary } from '../data/apiHooks'; +import { useContainer, useContentLibrary } from '../data/apiHooks'; import { LibrarySidebar } from '../library-sidebar'; import { SubHeaderTitle } from '../LibraryAuthoringPage'; -import { useLibraryRoutes } from '../routes'; import { LibraryUnitBlocks } from './LibraryUnitBlocks'; import messages from './messages'; - -interface EditableTitleProps { - unitId: string; -} - -const EditableTitle = ({ unitId }: EditableTitleProps) => { - const intl = useIntl(); - - const { readOnly } = useLibraryContext(); - - const { data: container } = useContainer(unitId); - - const updateMutation = useUpdateContainer(unitId); - const { showToast } = useContext(ToastContext); - - const handleSaveDisplayName = async (newDisplayName: string) => { - try { - await updateMutation.mutateAsync({ - displayName: newDisplayName, - }); - showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); - } catch (err) { - showToast(intl.formatMessage(messages.updateContainerErrorMsg)); - throw err; - } - }; - - // istanbul ignore if: this should never happen - if (!container) { - return null; - } - - return ( - - ); -}; - -const HeaderActions = () => { - const intl = useIntl(); - - const { componentPickerMode } = useComponentPickerContext(); - const { unitId, readOnly } = useLibraryContext(); - const { - openAddContentSidebar, - closeLibrarySidebar, - openUnitInfoSidebar, - sidebarComponentInfo, - } = useSidebarContext(); - const { navigateTo } = useLibraryRoutes(); - - // istanbul ignore if: this should never happen - if (!unitId) { - throw new Error('it should not be possible to render HeaderActions without a unitId'); - } - - const infoSidebarIsOpen = sidebarComponentInfo?.type === SidebarBodyComponentId.UnitInfo - && sidebarComponentInfo?.id === unitId; - - const handleOnClickInfoSidebar = useCallback(() => { - if (infoSidebarIsOpen) { - closeLibrarySidebar(); - } else { - openUnitInfoSidebar(unitId); - } - - if (!componentPickerMode) { - navigateTo({ unitId }); - } - }, [unitId, infoSidebarIsOpen]); - - return ( -
- - -
- ); -}; +import { ContainerEditableTitle, FooterActions, HeaderActions } from '../containers'; +import { ContainerType } from '../../generic/key-utils'; export const LibraryUnitPage = () => { const intl = useIntl(); @@ -160,11 +60,6 @@ export const LibraryUnitPage = () => { }; }, [setDefaultTab, setHiddenTabs]); - if (!unitId || !libraryId) { - // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. - throw new Error('Rendered without unitId or libraryId URL parameter'); - } - const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId); const { data: unitData, @@ -173,6 +68,11 @@ export const LibraryUnitPage = () => { error, } = useContainer(unitId); + if (!unitId || !libraryId) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Rendered without unitId or libraryId URL parameter'); + } + // Only show loading if unit or library data is not fetched from index yet if (isLibLoading || isLoading) { return ; @@ -222,14 +122,25 @@ export const LibraryUnitPage = () => {
} />} - headerActions={} + title={} />} + headerActions={( + + )} breadcrumbs={breadcrumbs} hideBorder />
+
diff --git a/src/library-authoring/units/index.scss b/src/library-authoring/units/index.scss index 749e60b447..1e188ce6a7 100644 --- a/src/library-authoring/units/index.scss +++ b/src/library-authoring/units/index.scss @@ -4,7 +4,6 @@ padding: 0; margin-bottom: 1rem; border: solid 1px $light-500; - } .pgn__card.clickable { diff --git a/src/library-authoring/units/messages.ts b/src/library-authoring/units/messages.ts index 3b9a8fdc32..5bee624405 100644 --- a/src/library-authoring/units/messages.ts +++ b/src/library-authoring/units/messages.ts @@ -41,16 +41,6 @@ const messages = defineMessages({ defaultMessage: 'There was an error updating the component.', description: 'Message when there is an error when updating the component', }, - updateContainerSuccessMsg: { - id: 'course-authoring.library-authoring.update-container-success-msg', - defaultMessage: 'Container updated successfully.', - description: 'Message displayed when container is updated successfully', - }, - updateContainerErrorMsg: { - id: 'course-authoring.library-authoring.update-container-error-msg', - defaultMessage: 'Failed to update container.', - description: 'Message displayed when container update fails', - }, orderUpdatedMsg: { id: 'course-authoring.library-authoring.unit-component.order-updated-msg.text', defaultMessage: 'Order updated', diff --git a/src/search-manager/data/api.mock.ts b/src/search-manager/data/api.mock.ts index 24fa9ae4d4..d11988a5e6 100644 --- a/src/search-manager/data/api.mock.ts +++ b/src/search-manager/data/api.mock.ts @@ -26,7 +26,10 @@ mockContentSearchConfig.applyMock = () => ( * For a given test suite, this mock will stay in effect until you call it with * a different mock response, or you call `fetchMock.mockReset()` */ -export function mockSearchResult(mockResponse: MultiSearchResponse) { +export function mockSearchResult( + mockResponse: MultiSearchResponse, + filterFn?: (requestData: any) => MultiSearchResponse, +) { fetchMock.post(mockContentSearchConfig.multisearchEndpointUrl, (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const query = requestData?.queries[0]?.q ?? ''; @@ -38,7 +41,7 @@ export function mockSearchResult(mockResponse: MultiSearchResponse) { // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign mockResponse.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); - return newMockResponse; + return filterFn?.(requestData) || newMockResponse; }, { overwriteRoutes: true }); } diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 79475b3874..55b3e0bc84 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -3,6 +3,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import type { Filter, MeiliSearch, MultiSearchQuery, } from 'meilisearch'; +import { ContainerType } from '../../generic/key-utils'; export const getContentSearchConfigUrl = () => new URL( 'api/content_search/v2/studio/', @@ -181,12 +182,14 @@ interface ContainerHitContent { } export interface ContainerHit extends BaseContentHit { type: 'library_container'; - blockType: 'unit'; // This should be expanded to include other container types + blockType: ContainerType; // This should be expanded to include other container types numChildren?: number; published?: ContentPublishedData; publishStatus: PublishStatus; formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, }; content?: ContainerHitContent; + sections?: { displayName?: string[], key?: string[] }; + subsections?: { displayName?: string[], key?: string[] }; } export type HitType = ContentHit | CollectionHit | ContainerHit;