From 7410cf7bc65d2788274d60737f717ce56ac60a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 2 Apr 2025 11:26:23 -0300 Subject: [PATCH 1/5] feat: library unit sidebar --- .../LibraryAuthoringPage.test.tsx | 21 +++ .../LibraryAuthoringPage.tsx | 4 +- src/library-authoring/LibraryLayout.tsx | 33 ++-- .../collections/LibraryCollectionPage.tsx | 2 +- .../common/context/SidebarContext.tsx | 33 +++- .../components/ContainerCard.tsx | 15 +- .../containers/ContainerInfoHeader.test.tsx | 174 ++++++++++++++++++ .../containers/ContainerInfoHeader.tsx | 106 +++++++++++ src/library-authoring/containers/UnitInfo.tsx | 70 +++++++ src/library-authoring/containers/index.tsx | 2 + src/library-authoring/containers/messages.ts | 41 +++++ src/library-authoring/data/api.mocks.ts | 41 +++++ src/library-authoring/data/api.ts | 42 +++++ src/library-authoring/data/apiHooks.test.tsx | 15 ++ src/library-authoring/data/apiHooks.ts | 38 ++++ .../library-sidebar/LibrarySidebar.tsx | 3 + src/library-authoring/routes.test.tsx | 29 ++- src/library-authoring/routes.ts | 11 +- 18 files changed, 646 insertions(+), 34 deletions(-) create mode 100644 src/library-authoring/containers/ContainerInfoHeader.test.tsx create mode 100644 src/library-authoring/containers/ContainerInfoHeader.tsx create mode 100644 src/library-authoring/containers/UnitInfo.tsx create mode 100644 src/library-authoring/containers/index.tsx create mode 100644 src/library-authoring/containers/messages.ts diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 2df4e39a93..89c4812264 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -14,6 +14,7 @@ import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json' import { mockContentLibrary, mockGetCollectionMetadata, + mockGetContainerMetadata, mockGetLibraryTeam, mockXBlockFields, } from './data/api.mocks'; @@ -28,6 +29,7 @@ let axiosMock; let mockShowToast; mockGetCollectionMetadata.applyMock(); +mockGetContainerMetadata.applyMock(); mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockGetLibraryTeam.applyMock(); @@ -436,6 +438,25 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); + it('should open and close the unit sidebar', async () => { + await renderLibraryPage(); + + // Click on the first unit + fireEvent.click((await screen.findByText('Test Unit'))); + + const sidebar = screen.getByTestId('library-sidebar'); + + const { getByRole, getByText } = within(sidebar); + + // The mock data for the sidebar has a title of "Test Unit" + await waitFor(() => expect(getByText('Test Unit')).toBeInTheDocument()); + + const closeButton = getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); + }); + it('should preserve the tab while switching from a component to a collection', async () => { await renderLibraryPage(); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index e3321c6a8a..fdce2e83fe 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -68,7 +68,7 @@ const HeaderActions = () => { if (!componentPickerMode) { // Reset URL to library home - navigateTo({ componentId: '', collectionId: '' }); + navigateTo({ componentId: '', collectionId: '', unitId: '' }); } }, [navigateTo, sidebarComponentInfo, closeLibrarySidebar, openLibrarySidebar]); @@ -173,7 +173,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage useEffect(() => { if (!componentPickerMode) { - openInfoSidebar(componentId, collectionId); + openInfoSidebar(componentId, collectionId, ''); } }, []); diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 844b366e44..8c48bae7a6 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -54,30 +54,23 @@ const LibraryLayout = () => { return ( - )} - /> - )} - /> - )} - /> - )} - /> + {[ + ROUTES.HOME, + ROUTES.COMPONENT, + ROUTES.COMPONENTS, + ROUTES.COLLECTIONS, + ROUTES.UNITS, + ].map((route) => ( + )} + /> + ))} )} /> - )} - /> ); }; diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx index 6dd1a5c0be..306a64ab5b 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.tsx @@ -120,7 +120,7 @@ const LibraryCollectionPage = () => { } = useCollection(libraryId, collectionId); useEffect(() => { - openInfoSidebar(componentId, collectionId); + openInfoSidebar(componentId, collectionId, ''); }, []); const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId); diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index 7dd7e9c7db..d82106e83f 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -12,6 +12,7 @@ export enum SidebarBodyComponentId { Info = 'info', ComponentInfo = 'component-info', CollectionInfo = 'collection-info', + UnitInfo = 'unit-info', } export const COLLECTION_INFO_TABS = { @@ -33,9 +34,20 @@ export const isComponentInfoTab = (tab: string): tab is ComponentInfoTab => ( Object.values(COMPONENT_INFO_TABS).includes(tab) ); -type SidebarInfoTab = ComponentInfoTab | CollectionInfoTab; +export const UNIT_INFO_TABS = { + Preview: 'preview', + Organize: 'organize', + Usage: 'usage', + Settings: 'settings', +} as const; +export type UnitInfoTab = typeof UNIT_INFO_TABS[keyof typeof UNIT_INFO_TABS]; +export const isUnitInfoTab = (tab: string): tab is UnitInfoTab => ( + Object.values(UNIT_INFO_TABS).includes(tab) +); + +type SidebarInfoTab = ComponentInfoTab | CollectionInfoTab | UnitInfoTab; const toSidebarInfoTab = (tab: string): SidebarInfoTab | undefined => ( - isComponentInfoTab(tab) || isCollectionInfoTab(tab) + isComponentInfoTab(tab) || isCollectionInfoTab(tab) || isUnitInfoTab(tab) ? tab : undefined ); @@ -53,10 +65,11 @@ export enum SidebarActions { export type SidebarContextData = { closeLibrarySidebar: () => void; openAddContentSidebar: () => void; - openInfoSidebar: (componentId?: string, collectionId?: string) => void; + openInfoSidebar: (componentId?: string, collectionId?: string, unitId?: string) => void; openLibrarySidebar: () => void; openCollectionInfoSidebar: (collectionId: string) => void; openComponentInfoSidebar: (usageKey: string) => void; + openUnitInfoSidebar: (usageKey: string) => void; sidebarComponentInfo?: SidebarComponentInfo; sidebarAction: SidebarActions; setSidebarAction: (action: SidebarActions) => void; @@ -131,11 +144,20 @@ export const SidebarProvider = ({ }); }, []); - const openInfoSidebar = useCallback((componentId?: string, collectionId?: string) => { + const openUnitInfoSidebar = useCallback((usageKey: string) => { + setSidebarComponentInfo({ + id: usageKey, + type: SidebarBodyComponentId.UnitInfo, + }); + }, []); + + const openInfoSidebar = useCallback((componentId?: string, collectionId?: string, unitId?: string) => { if (componentId) { openComponentInfoSidebar(componentId); } else if (collectionId) { openCollectionInfoSidebar(collectionId); + } else if (unitId) { + openUnitInfoSidebar(unitId); } else { openLibrarySidebar(); } @@ -150,6 +172,7 @@ export const SidebarProvider = ({ openComponentInfoSidebar, sidebarComponentInfo, openCollectionInfoSidebar, + openUnitInfoSidebar, sidebarAction, setSidebarAction, resetSidebarAction, @@ -166,6 +189,7 @@ export const SidebarProvider = ({ openComponentInfoSidebar, sidebarComponentInfo, openCollectionInfoSidebar, + openUnitInfoSidebar, sidebarAction, setSidebarAction, resetSidebarAction, @@ -191,6 +215,7 @@ export function useSidebarContext(): SidebarContextData { openLibrarySidebar: () => {}, openComponentInfoSidebar: () => {}, openCollectionInfoSidebar: () => {}, + openUnitInfoSidebar: () => {}, sidebarAction: SidebarActions.None, setSidebarAction: () => {}, resetSidebarAction: () => {}, diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 7855d82cd0..42cd49dfc4 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, @@ -11,6 +12,8 @@ import { Link } from 'react-router-dom'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; +import { useSidebarContext } from '../common/context/SidebarContext'; +import { useLibraryRoutes } from '../routes'; import BaseCard from './BaseCard'; import messages from './messages'; @@ -53,6 +56,7 @@ type ContainerCardProps = { const ContainerCard = ({ hit } : ContainerCardProps) => { const { componentPickerMode } = useComponentPickerContext(); const { showOnlyPublished } = useLibraryContext(); + const { openUnitInfoSidebar } = useSidebarContext(); const { blockType: itemType, @@ -61,6 +65,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { numChildren, published, publishStatus, + usageKey: unitId, } = hit; const numChildrenCount = showOnlyPublished ? ( @@ -71,7 +76,15 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { showOnlyPublished ? formatted.published?.displayName : formatted.displayName ) ?? ''; - const openContainer = () => {}; + const { navigateTo } = useLibraryRoutes(); + + const openContainer = useCallback(() => { + if (itemType === 'unit') { + openUnitInfoSidebar(unitId); + + navigateTo({ unitId }); + } + }, [unitId, itemType, openUnitInfoSidebar, navigateTo]); return ( void; + +mockGetContainerMetadata.applyMock(); +mockContentLibrary.applyMock(); + +const { + libraryId: mockLibraryId, + libraryIdReadOnly, +} = mockContentLibrary; + +const { containerId } = mockGetContainerMetadata; + +const render = (libraryId: string = mockLibraryId) => baseRender(, { + extraWrapper: ({ children }) => ( + + + { children } + + + ), +}); + +describe('', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + }); + + it('should render container info Header', async () => { + render(); + expect(await screen.findByText('Test Unit')).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: /edit container title/i })).toBeInTheDocument(); + }); + + it('should not render edit title button without permission', async () => { + render(libraryIdReadOnly); + expect(await screen.findByText('Test Unit')).toBeInTheDocument(); + + expect(screen.queryByRole('button', { name: /edit container title/i })).not.toBeInTheDocument(); + }); + + it('should update container title', async () => { + render(); + + expect(await screen.findByText('Test Unit')).toBeInTheDocument(); + + const url = api.getLibraryContainerApiUrl(containerId); + axiosMock.onPatch(url).reply(200); + + fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + userEvent.clear(textBox); + userEvent.type(textBox, 'New Unit Title{enter}'); + + await waitFor(() => { + expect(axiosMock.history.patch[0].url).toEqual(url); + }); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ display_name: 'New Unit Title' })); + + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Container updated successfully.'); + }); + + it('should not update container title if title is the same', async () => { + render(); + expect(await screen.findByText('Test Unit')).toBeInTheDocument(); + + const url = api.getLibraryContainerApiUrl(containerId); + axiosMock.onPatch(url).reply(200); + + fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + userEvent.clear(textBox); + userEvent.type(textBox, `${mockGetContainerMetadata.containerData.displayName}{enter}`); + + await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0)); + + expect(textBox).not.toBeInTheDocument(); + }); + + it('should not update container title if title is empty', async () => { + render(); + expect(await screen.findByText('Test Unit')).toBeInTheDocument(); + + const url = api.getLibraryContainerApiUrl(containerId); + axiosMock.onPatch(url).reply(200); + + fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + userEvent.clear(textBox); + userEvent.type(textBox, '{enter}'); + + await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0)); + + expect(textBox).not.toBeInTheDocument(); + }); + + it('should close edit container title on press Escape', async () => { + render(); + expect(await screen.findByText('Test Unit')).toBeInTheDocument(); + + const url = api.getLibraryContainerApiUrl(containerId); + axiosMock.onPatch(url).reply(200); + + fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + userEvent.clear(textBox); + userEvent.type(textBox, 'New Unit Title{esc}'); + + await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0)); + + expect(textBox).not.toBeInTheDocument(); + }); + + it('should show error on edit container title', async () => { + render(); + expect(await screen.findByText('Test Unit')).toBeInTheDocument(); + + const url = api.getLibraryContainerApiUrl(containerId); + axiosMock.onPatch(url).reply(500); + + fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + userEvent.clear(textBox); + userEvent.type(textBox, 'New Unit Title{enter}'); + + await waitFor(() => { + expect(axiosMock.history.patch[0].url).toEqual(url); + }); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ display_name: 'New Unit Title' })); + + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Failed to update container.'); + }); +}); diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx new file mode 100644 index 0000000000..3ac06045a2 --- /dev/null +++ b/src/library-authoring/containers/ContainerInfoHeader.tsx @@ -0,0 +1,106 @@ +import React, { useState, useContext, useCallback } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Icon, + IconButton, + Stack, + Form, +} from '@openedx/paragon'; +import { Edit } from '@openedx/paragon/icons'; + +import { ToastContext } from '../../generic/toast-context'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useSidebarContext } from '../common/context/SidebarContext'; +import { useContainer, useUpdateContainer } from '../data/apiHooks'; +import messages from './messages'; + +const ContainerInfoHeader = () => { + const intl = useIntl(); + const [inputIsActive, setIsActive] = useState(false); + + const { readOnly } = useLibraryContext(); + const { sidebarComponentInfo } = useSidebarContext(); + + const containerId = sidebarComponentInfo?.id; + // istanbul ignore if: this should never happen + if (!containerId) { + throw new Error('containerId is required'); + } + + const { data: container } = useContainer(containerId); + + const updateMutation = useUpdateContainer(containerId); + const { showToast } = useContext(ToastContext); + + const handleSaveDisplayName = useCallback( + (event) => { + const newDisplayName = event.target.value; + if (newDisplayName && newDisplayName !== container?.displayName) { + updateMutation.mutateAsync({ + displayName: newDisplayName, + }).then(() => { + showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateContainerErrorMsg)); + }).finally(() => { + setIsActive(false); + }); + } else { + setIsActive(false); + } + }, + [container, showToast, intl], + ); + + if (!container) { + return null; + } + + const handleClick = () => { + setIsActive(true); + }; + + const handleOnKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleSaveDisplayName(event); + } else if (event.key === 'Escape') { + setIsActive(false); + } + }; + + return ( + + {inputIsActive + ? ( + + ) + : ( + <> + + {container.displayName} + + {!readOnly && ( + + )} + + )} + + ); +}; + +export default ContainerInfoHeader; diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx new file mode 100644 index 0000000000..3dbaef4148 --- /dev/null +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -0,0 +1,70 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Stack, + Tab, + Tabs, +} from '@openedx/paragon'; + +import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; +import { + type UnitInfoTab, + UNIT_INFO_TABS, + isUnitInfoTab, + useSidebarContext, +} from '../common/context/SidebarContext'; +import messages from './messages'; + +const UnitInfo = () => { + const intl = useIntl(); + + const { componentPickerMode } = useComponentPickerContext(); + const { sidebarComponentInfo, sidebarTab, setSidebarTab } = useSidebarContext(); + + const tab: UnitInfoTab = ( + sidebarTab && isUnitInfoTab(sidebarTab) + ) ? sidebarTab : UNIT_INFO_TABS.Preview; + + const unitId = sidebarComponentInfo?.id; + // istanbul ignore if: this should never happen + if (!unitId) { + throw new Error('unitId is required'); + } + + const showOpenCollectionButton = !componentPickerMode; + + return ( + + {showOpenCollectionButton && ( +
+ +
+ )} + + + Unit Preview + + + Organize Unit + + + Unit Settings + + +
+ ); +}; + +export default UnitInfo; diff --git a/src/library-authoring/containers/index.tsx b/src/library-authoring/containers/index.tsx new file mode 100644 index 0000000000..007a0eef72 --- /dev/null +++ b/src/library-authoring/containers/index.tsx @@ -0,0 +1,2 @@ +export { default as UnitInfo } from './UnitInfo'; +export { default as ContainerInfoHeader } from './ContainerInfoHeader'; diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts new file mode 100644 index 0000000000..b1a0d35385 --- /dev/null +++ b/src/library-authoring/containers/messages.ts @@ -0,0 +1,41 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + openUnitButton: { + id: 'course-authoring.library-authoring.container-sidebar.open-button', + defaultMessage: 'Open', + description: 'Button text to open unit', + }, + previewTabTitle: { + id: 'course-authoring.library-authoring.container-sidebar.preview-tab.title', + defaultMessage: 'Preview', + description: 'Title for preview tab', + }, + organizeTabTitle: { + id: 'course-authoring.library-authoring.container-sidebar.organize-tab.title', + defaultMessage: 'Organize', + description: 'Title for organize tab', + }, + settingsTabTitle: { + id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title', + defaultMessage: 'Settings', + description: 'Title for settings tab', + }, + 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', + }, + editTitleButtonAlt: { + id: 'course-authoring.library-authoring.container.sidebar.edit-name.alt', + defaultMessage: 'Edit container title', + description: 'Alt text for edit container title icon button', + }, +}); + +export default messages; diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 21f6970582..d84ba79c62 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -457,6 +457,47 @@ mockGetCollectionMetadata.applyMock = () => { jest.spyOn(api, 'getCollectionMetadata').mockImplementation(mockGetCollectionMetadata); }; +/** + * Mock for `getContainerMetadata()` + * + * This mock returns a fixed response for the container ID *container_1*. + */ +export async function mockGetContainerMetadata(containerId: string): Promise { + switch (containerId) { + case mockGetCollectionMetadata.collectionIdError: + throw createAxiosError({ + code: 404, + message: 'Not found.', + path: api.getLibraryContainerApiUrl(containerId), + }); + case mockGetContainerMetadata.containerIdLoading: + return new Promise(() => { }); + default: + return Promise.resolve(mockGetContainerMetadata.containerData); + } +} +mockGetContainerMetadata.containerId = 'lct:org:lib:unit:test-unit-9a207'; +mockGetContainerMetadata.containerIdError = 'lct:org:lib:unit:container_error'; +mockGetContainerMetadata.containerIdLoading = 'lct:org:lib:unit:container_loading'; +mockGetContainerMetadata.containerData = { + containerKey: 'lct:org:lib:unit:test-unit-9a2072', + containerType: 'unit', + displayName: 'Test Unit', + created: '2024-09-19T10:00:00Z', + createdBy: 'test_author', + lastPublished: '2024-09-20T10:00:00Z', + publishedBy: 'test_publisher', + lastDraftCreated: '2024-09-20T10:00:00Z', + lastDraftCreatedBy: 'test_author', + modified: '2024-09-20T11:00:00Z', + hasUnpublishedChanges: true, + collections: [], +} satisfies api.Container; +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockGetContainerMetadata.applyMock = () => { + jest.spyOn(api, 'getContainerMetadata').mockImplementation(mockGetContainerMetadata); +}; + /** * Mock for `getXBlockOLX()` * diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index f0079ca134..352089a880 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -107,6 +107,10 @@ export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/ * Get the URL for the library container api. */ export const getLibraryContainersApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/containers/`; +/** + * Get the URL for the container detail api. + */ +export const getLibraryContainerApiUrl = (containerId: string) => `${getApiBaseUrl()}/api/libraries/v2/containers/${containerId}/`; export interface ContentLibrary { id: string; @@ -574,3 +578,41 @@ export async function createLibraryContainer(libraryId: string, containerData: C const client = getAuthenticatedHttpClient(); await client.post(getLibraryContainersApiUrl(libraryId), snakeCaseObject(containerData)); } + +export interface Container { + containerKey: string; + containerType: 'unit'; + displayName: string; + lastPublished: string | null; + publishedBy: string | null; + createdBy: string | null; + lastDraftCreated: string | null; + lastDraftCreatedBy: string | null, + hasUnpublishedChanges: boolean; + created: string; + modified: string; + collections: CollectionMetadata[]; +} + +/** + * Get the container metadata. + */ +export async function getContainerMetadata(containerId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerApiUrl(containerId)); + return camelCaseObject(data); +} + +export interface UpdateContainerDataRequest { + displayName: string; +} + +/** + * Update container metadata. + */ +export async function updateContainerMetadata( + containerId: string, + containerData: UpdateContainerDataRequest, +) { + const client = getAuthenticatedHttpClient(); + await client.patch(getLibraryContainerApiUrl(containerId), snakeCaseObject(containerData)); +} diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 746e0139d6..2f920b544d 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -12,6 +12,7 @@ import { getLibraryCollectionsApiUrl, getLibraryCollectionApiUrl, getBlockTypesMetaDataUrl, + getLibraryContainerApiUrl, } from './api'; import { useCommitLibraryChanges, @@ -21,6 +22,7 @@ import { useAddComponentsToCollection, useCollection, useBlockTypesMetadata, + useContainer, } from './apiHooks'; let axiosMock; @@ -137,4 +139,17 @@ describe('library api hooks', () => { expect(result.current.data).toEqual({ testData: 'test-value' }); expect(axiosMock.history.get[0].url).toEqual(url); }); + + it('should get container metadata', async () => { + const containerId = 'lct:lib:org:unit:unit1'; + const url = getLibraryContainerApiUrl(containerId); + + axiosMock.onGet(url).reply(200, { 'test-data': 'test-value' }); + const { result } = renderHook(() => useContainer(containerId), { wrapper }); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + }); + expect(result.current.data).toEqual({ testData: 'test-value' }); + expect(axiosMock.history.get[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index abab56ffca..31bbf1bbd9 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -48,6 +48,9 @@ import { getBlockTypes, createLibraryContainer, type CreateLibraryContainerDataRequest, + getContainerMetadata, + updateContainerMetadata, + type UpdateContainerDataRequest, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -110,6 +113,14 @@ export const xblockQueryKeys = { componentDownstreamLinks: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'downstreamLinks'], }; +export const containerQueryKeys = { + all: ['container'], + /** + * Base key for data specific to a container + */ + container: (usageKey?: string) => [...containerQueryKeys.all, usageKey], +}; + /** * Tell react-query to refresh its cache of any data related to the given * component (XBlock). @@ -575,3 +586,30 @@ export const useCreateLibraryContainer = (libraryId: string) => { }, }); }; + +/** + * Get the metadata for a container in a library + */ +export const useContainer = (containerId: string) => ( + useQuery({ + queryKey: containerQueryKeys.container(containerId), + queryFn: containerId ? () => getContainerMetadata(containerId) : undefined, + }) +); + +/** + * Use this mutation to update the fields of a container in a library + */ +export const useUpdateContainer = (containerId: string) => { + const libraryId = getLibraryId(containerId); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UpdateContainerDataRequest) => updateContainerMetadata(containerId, data), + onSettled: () => { + // NOTE: We invalidate the library query here because we need to update the library's + // container list. + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + queryClient.invalidateQueries({ queryKey: containerQueryKeys.container(containerId) }); + }, + }); +}; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index c329a49b87..77135e0395 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -9,6 +9,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { AddContentContainer, AddContentHeader } from '../add-content'; import { CollectionInfo, CollectionInfoHeader } from '../collections'; +import { ContainerInfoHeader, UnitInfo } from '../containers'; import { SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; import { ComponentInfo, ComponentInfoHeader } from '../component-info'; import { LibraryInfo, LibraryInfoHeader } from '../library-info'; @@ -32,6 +33,7 @@ const LibrarySidebar = () => { [SidebarBodyComponentId.Info]: , [SidebarBodyComponentId.ComponentInfo]: , [SidebarBodyComponentId.CollectionInfo]: , + [SidebarBodyComponentId.UnitInfo]: , unknown: null, }; @@ -40,6 +42,7 @@ const LibrarySidebar = () => { [SidebarBodyComponentId.Info]: , [SidebarBodyComponentId.ComponentInfo]: , [SidebarBodyComponentId.CollectionInfo]: , + [SidebarBodyComponentId.UnitInfo]: , unknown: null, }; diff --git a/src/library-authoring/routes.test.tsx b/src/library-authoring/routes.test.tsx index 6f4b6c2f66..8a58f3a41f 100644 --- a/src/library-authoring/routes.test.tsx +++ b/src/library-authoring/routes.test.tsx @@ -108,6 +108,19 @@ describe('Library Authoring routes', () => { path: '/clctnId', }, }, + { + label: 'from All Content tab, select a Unit', + origin: { + path: '', + params: {}, + }, + destination: { + params: { + unitId: 'lct:org:lib:unit:unitId', + }, + path: '/lct:org:lib:unit:unitId', + }, + }, { label: 'navigate from All Content > selected Collection to the Collection page', origin: { @@ -228,7 +241,7 @@ describe('Library Authoring routes', () => { label: 'from Collections tab > selected Collection, navigate to the Collection page', origin: { params: { - collectionId: 'clctnId', + selectedItemId: 'clctnId', }, path: '/collections/clctnId', }, @@ -272,6 +285,19 @@ describe('Library Authoring routes', () => { }, }, }, + { + label: 'from Unit tab, select a Unit', + origin: { + path: '/units', + params: {}, + }, + destination: { + params: { + unitId: 'unitId', + }, + path: '/units/unitId', + }, + }, { label: 'navigate from Units tab to All Content tab', origin: { @@ -303,6 +329,7 @@ describe('Library Authoring routes', () => { params: { libraryId: mockContentLibrary.libraryId, collectionId: '', + selectedItemId: '', ...origin.params, }, }); diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index 69080cc003..9e2b527c8a 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -24,8 +24,8 @@ export const ROUTES = { UNITS: '/units/:unitId?', // * All Content tab, with an optionally selected componentId in the sidebar. COMPONENT: '/component/:componentId', - // * All Content tab, with an optionally selected collectionId in the sidebar. - HOME: '/:collectionId?', + // * All Content tab, with an optionally selected collection or unit in the sidebar. + HOME: '/:selectedItemId?', // LibraryCollectionPage route: // * with a selected collectionId and/or an optionally selected componentId. COLLECTION: '/collection/:collectionId/:componentId?', @@ -41,6 +41,7 @@ export enum ContentType { export type NavigateToData = { componentId?: string, collectionId?: string, + unitId?: string, contentType?: ContentType, }; @@ -68,13 +69,15 @@ export const useLibraryRoutes = (): LibraryRoutesData => { const navigateTo = useCallback(({ componentId, collectionId, + unitId, contentType, }: NavigateToData = {}) => { const routeParams = { ...params, // Overwrite the current componentId/collectionId params if provided ...((componentId !== undefined) && { componentId }), - ...((collectionId !== undefined) && { collectionId }), + ...((collectionId !== undefined) && { collectionId, selectedItemId: collectionId }), + ...((unitId !== undefined) && { unitId, selectedItemId: unitId }), }; let route; @@ -107,14 +110,12 @@ export const useLibraryRoutes = (): LibraryRoutesData => { } else if (insideUnits) { // We're inside the Units tab, so stay there, // optionally selecting a unit. - // istanbul ignore next: this will be covered when we add unit selection route = ROUTES.UNITS; } else if (componentId) { // We're inside the All Content tab, so stay there, // and select a component. route = ROUTES.COMPONENT; } else { - // We're inside the All Content tab, route = ( (collectionId && collectionId === params.collectionId) // now open the previously-selected collection From 4283abba625d733c59231a4153376938e475f139 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 2 Apr 2025 18:02:23 +1030 Subject: [PATCH 2/5] feat: show container's child block types in preview --- src/library-authoring/components/BaseCard.tsx | 9 ++- .../components/ContainerCard.test.tsx | 39 +++++++++++- .../components/ContainerCard.tsx | 60 ++++++++++++++++++- src/library-authoring/components/messages.ts | 5 ++ src/library-authoring/data/api.mocks.ts | 50 ++++++++++++++++ src/library-authoring/data/api.ts | 13 ++++ src/library-authoring/data/apiHooks.ts | 20 ++++++- 7 files changed, 190 insertions(+), 6 deletions(-) diff --git a/src/library-authoring/components/BaseCard.tsx b/src/library-authoring/components/BaseCard.tsx index f327c78011..df486751ed 100644 --- a/src/library-authoring/components/BaseCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -17,6 +17,7 @@ type BaseCardProps = { itemType: string; displayName: string; description?: string; + preview?: React.ReactNode; numChildren?: number; tags: ContentHitTags; actions: React.ReactNode; @@ -70,10 +71,14 @@ const BaseCard = ({ /> -
+
- + { + props.preview + ? props.preview + : + } diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx index 657a2e112f..dd5da2f20c 100644 --- a/src/library-authoring/components/ContainerCard.test.tsx +++ b/src/library-authoring/components/ContainerCard.test.tsx @@ -1,9 +1,10 @@ import userEvent from '@testing-library/user-event'; import { - initializeMocks, render as baseRender, screen, + initializeMocks, render as baseRender, screen, waitFor, } from '../../testUtils'; import { LibraryProvider } from '../common/context/LibraryContext'; +import { mockContentLibrary, mockGetContainerChildren } from '../data/api.mocks'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import ContainerCard from './ContainerCard'; @@ -33,6 +34,9 @@ const containerHitSample: ContainerHit = { publishStatus: PublishStatus.Published, }; +mockContentLibrary.applyMock(); +mockGetContainerChildren.applyMock(); + const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, { extraWrapper: ({ children }) => ( ', () => { // '/library/lb:org1:Demo_Course/container/container-display-name-123', // ); }); + + it('should render no child blocks in card preview', async () => { + render(); + + expect(screen.queryByTitle('text block')).not.toBeInTheDocument(); + expect(screen.queryByText('+0')).not.toBeInTheDocument(); + }); + + it('should render <=5 child blocks in card preview', async () => { + const containerWith5Children = { + ...containerHitSample, + usageKey: mockGetContainerChildren.fiveChildren, + }; + render(); + + await waitFor(() => { + expect(screen.getAllByTitle('text block').length).toBe(5); + }); + expect(screen.queryByText('+0')).not.toBeInTheDocument(); + }); + + it('should render >5 child blocks with +N in card preview', async () => { + const containerWith6Children = { + ...containerHitSample, + usageKey: mockGetContainerChildren.sixChildren, + }; + render(); + + await waitFor(() => { + expect(screen.getAllByTitle('text block').length).toBe(4); + }); + expect(screen.queryByText('+2')).toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 42cd49dfc4..4e4311e1a1 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, @@ -7,14 +6,17 @@ import { IconButton, } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; +import { ReactNode, useCallback } from 'react'; import { Link } from 'react-router-dom'; +import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useLibraryRoutes } from '../routes'; import BaseCard from './BaseCard'; +import { useContainerChildren } from '../data/apiHooks'; +import { useLibraryRoutes } from '../routes'; import messages from './messages'; type ContainerMenuProps = { @@ -49,6 +51,59 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { ); }; +type ContainerCardPreviewProps = { + containerId: string; + showMaxChildren?: number; +}; + +const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCardPreviewProps) => { + const { data, isLoading, isError } = useContainerChildren(containerId); + if (isLoading || isError) { + return null; + } + + const hiddenChildren = data.length - showMaxChildren; + return ( +
+ { + data.slice(0, showMaxChildren).map(({ id, blockType, displayName }, idx) => { + let classNames = 'd-inline-flex rounded align-items-center m-1 p-1'; + let blockPreview: ReactNode; + + if (idx < showMaxChildren - 1 || hiddenChildren <= 0) { + // Show the first N-1 blocks as item icons + // (or all N blocks if no hidden children) + classNames = `${classNames} ${getComponentStyleColor(blockType)}`; + blockPreview = ( + + ); + } else { + // Container has more blocks than can fit in the preview, so show "+N" + blockPreview = ( + + ); + } + return ( +
+ {blockPreview} +
+ ); + }) + } +
+ ); +}; + type ContainerCardProps = { hit: ContainerHit, }; @@ -90,6 +145,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { } tags={tags} numChildren={numChildrenCount} actions={!componentPickerMode && ( diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 40276ca7ce..831855abc4 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -176,5 +176,10 @@ const messages = defineMessages({ defaultMessage: 'This component can be synced in courses after publish.', description: 'Alert text of the modal to confirm publish a component in a library.', }, + containerPreviewMoreBlocks: { + id: 'course-authoring.library-authoring.component.container-card-preview.more-blocks', + defaultMessage: '+{count}', + description: 'Count shown when a container has more blocks than will fit on the card preview.', + }, }); export default messages; diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index d84ba79c62..73df11bfc7 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -498,6 +498,56 @@ mockGetContainerMetadata.applyMock = () => { jest.spyOn(api, 'getContainerMetadata').mockImplementation(mockGetContainerMetadata); }; +/** + * Mock for `getContainerChildren()` + * + * This mock returns a fixed response for the given container ID. + */ +export async function mockGetContainerChildren(containerId: string): Promise { + let numChildren: number; + switch (containerId) { + case mockGetContainerChildren.fiveChildren: + numChildren = 5; + break; + case mockGetContainerChildren.sixChildren: + numChildren = 6; + break; + default: + numChildren = 0; + break; + } + return Promise.resolve( + Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => ( + { + ...child, + // Generate a unique ID for each child block to avoid "duplicate key" errors in tests + id: `lb:org1:Demo_course:html:text-${idx}`, + } + )), + ); +} +mockGetContainerChildren.fiveChildren = 'lct:org1:Demo_Course:unit:unit-5'; +mockGetContainerChildren.sixChildren = 'lct:org1:Demo_Course:unit:unit-6'; +mockGetContainerChildren.childTemplate = { + id: 'lb:org1:Demo_course:html:text', + blockType: 'html', + defKey: 'def_key', + displayName: 'text block', + lastPublished: null, + publishedBy: null, + lastDraftCreated: null, + lastDraftCreatedBy: null, + hasUnpublishedChanges: false, + created: null, + modified: null, + tagsCount: 0, + collections: [] as api.CollectionMetadata[], +} satisfies api.LibraryBlockMetadata; +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockGetContainerChildren.applyMock = () => { + jest.spyOn(api, 'getContainerChildren').mockImplementation(mockGetContainerChildren); +}; + /** * Mock for `getXBlockOLX()` * diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 352089a880..66b36f0c58 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -111,6 +111,10 @@ export const getLibraryContainersApiUrl = (libraryId: string) => `${getApiBaseUr * Get the URL for the container detail api. */ export const getLibraryContainerApiUrl = (containerId: string) => `${getApiBaseUrl()}/api/libraries/v2/containers/${containerId}/`; +/** + * Get the URL for a single container children api. + */ +export const getLibraryContainerChildrenApiUrl = (containerId: string) => `${getApiBaseUrl()}/api/libraries/v2/containers/${containerId}/children/`; export interface ContentLibrary { id: string; @@ -616,3 +620,12 @@ export async function updateContainerMetadata( const client = getAuthenticatedHttpClient(); await client.patch(getLibraryContainerApiUrl(containerId), snakeCaseObject(containerData)); } + +/** + * Fetch a library container's children's metadata. + */ +export async function getContainerChildren(containerId: string): Promise { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getLibraryContainerChildrenApiUrl(containerId)); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 31bbf1bbd9..8394927481 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -51,6 +51,7 @@ import { getContainerMetadata, updateContainerMetadata, type UpdateContainerDataRequest, + getContainerChildren, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -95,6 +96,11 @@ export const libraryAuthoringQueryKeys = { 'blockTypes', libraryId, ], + container: (libraryId?: string, containerId?: string) => [ + ...libraryAuthoringQueryKeys.all, + libraryId, + containerId, + ], }; export const xblockQueryKeys = { @@ -114,11 +120,12 @@ export const xblockQueryKeys = { }; export const containerQueryKeys = { - all: ['container'], + all: ['container', 'children'], /** * Base key for data specific to a container */ container: (usageKey?: string) => [...containerQueryKeys.all, usageKey], + children: (usageKey?: string) => [...containerQueryKeys.all, usageKey, 'children'], }; /** @@ -613,3 +620,14 @@ export const useUpdateContainer = (containerId: string) => { }, }); }; + +/** + * Get the metadata and children for a container in a library + */ +export const useContainerChildren = (containerId: string) => ( + useQuery({ + enabled: !!containerId, + queryKey: containerQueryKeys.children(containerId), + queryFn: () => getContainerChildren(containerId!), + }) +); From 4c1fadf7166602a2f881feacf73b0ab8846f1adf Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 2 Apr 2025 20:08:32 +1030 Subject: [PATCH 3/5] fix: ensure card is wide enough for Container preview block tiles --- src/library-authoring/components/BaseCard.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/components/BaseCard.scss b/src/library-authoring/components/BaseCard.scss index 9346618a31..3a13bd14cb 100644 --- a/src/library-authoring/components/BaseCard.scss +++ b/src/library-authoring/components/BaseCard.scss @@ -1,6 +1,7 @@ .library-item-card { .pgn__card { - height: 100% + height: 100%; + min-width: 15rem; } .library-item-header { From e81c97310b15161b84001a7ef7eac624776bb4c9 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 3 Apr 2025 12:45:14 +1030 Subject: [PATCH 4/5] fix: address PR review --- src/library-authoring/components/BaseCard.tsx | 6 +----- src/library-authoring/components/ContainerCard.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/library-authoring/components/BaseCard.tsx b/src/library-authoring/components/BaseCard.tsx index df486751ed..2b15937891 100644 --- a/src/library-authoring/components/BaseCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -74,11 +74,7 @@ const BaseCard = ({
- { - props.preview - ? props.preview - : - } + {props.preview || } diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 4e4311e1a1..30506daabd 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -4,6 +4,7 @@ import { Dropdown, Icon, IconButton, + Stack, } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; import { ReactNode, useCallback } from 'react'; @@ -64,16 +65,16 @@ const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCar const hiddenChildren = data.length - showMaxChildren; return ( -
+ { data.slice(0, showMaxChildren).map(({ id, blockType, displayName }, idx) => { - let classNames = 'd-inline-flex rounded align-items-center m-1 p-1'; let blockPreview: ReactNode; + let classNames; if (idx < showMaxChildren - 1 || hiddenChildren <= 0) { // Show the first N-1 blocks as item icons // (or all N blocks if no hidden children) - classNames = `${classNames} ${getComponentStyleColor(blockType)}`; + classNames = `rounded p-1 ${getComponentStyleColor(blockType)}`; blockPreview = ( + ); }; From aa2e85eefe8e7317c4a6dcd47e8de8e4735ed91b Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Tue, 8 Apr 2025 15:55:19 +0930 Subject: [PATCH 5/5] test: fix test coverage and remove a line accidentally added when resolving conflicts. --- src/library-authoring/data/api.mocks.ts | 1 - src/library-authoring/data/apiHooks.test.tsx | 77 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 52b01e1693..73df11bfc7 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -549,7 +549,6 @@ mockGetContainerChildren.applyMock = () => { }; /** -======= * Mock for `getXBlockOLX()` * * This mock returns different data/responses depending on the ID of the block diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 2f920b544d..ddc263f371 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -13,6 +13,7 @@ import { getLibraryCollectionApiUrl, getBlockTypesMetaDataUrl, getLibraryContainerApiUrl, + getLibraryContainerChildrenApiUrl, } from './api'; import { useCommitLibraryChanges, @@ -23,6 +24,7 @@ import { useCollection, useBlockTypesMetadata, useContainer, + useContainerChildren, } from './apiHooks'; let axiosMock; @@ -152,4 +154,79 @@ describe('library api hooks', () => { expect(result.current.data).toEqual({ testData: 'test-value' }); expect(axiosMock.history.get[0].url).toEqual(url); }); + + it('should get container children', async () => { + const containerId = 'lct:lib:org:unit:unit1'; + const url = getLibraryContainerChildrenApiUrl(containerId); + + axiosMock.onGet(url).reply(200, [ + { + id: 'lb:org1:Demo_course:html:text', + block_type: 'html', + def_key: 'def_key', + display_name: 'text block', + last_published: null, + published_by: null, + last_draft_created: null, + last_draft_created_by: null, + has_unpublished_changes: false, + created: null, + modified: null, + tags_count: 0, + collections: ['col1', 'col2'], + }, + { + id: 'lb:org1:Demo_course:video:video1', + block_type: 'video', + def_key: 'def_key', + display_name: 'video block', + last_published: null, + published_by: null, + last_draft_created: null, + last_draft_created_by: null, + has_unpublished_changes: false, + created: null, + modified: null, + tags_count: 0, + collections: ['col2'], + }, + ]); + const { result } = renderHook(() => useContainerChildren(containerId), { wrapper }); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + }); + expect(result.current.data).toEqual([ + { + id: 'lb:org1:Demo_course:html:text', + blockType: 'html', + defKey: 'def_key', + displayName: 'text block', + lastPublished: null, + publishedBy: null, + lastDraftCreated: null, + lastDraftCreatedBy: null, + hasUnpublishedChanges: false, + created: null, + modified: null, + tagsCount: 0, + collections: ['col1', 'col2'], + }, + { + id: 'lb:org1:Demo_course:video:video1', + blockType: 'video', + defKey: 'def_key', + displayName: 'video block', + lastPublished: null, + publishedBy: null, + lastDraftCreated: null, + lastDraftCreatedBy: null, + hasUnpublishedChanges: false, + created: null, + modified: null, + tagsCount: 0, + collections: ['col2'], + }, + ]); + expect(axiosMock.history.get[0].url).toEqual(url); + }); });