From 68d62cd62f5f2b3a8262f89f414a029995b1b4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 7 Apr 2025 13:51:10 -0300 Subject: [PATCH] feat: library unit sidebar [FC-0083] (#1762) Implements the placeholder for the Unit Sidebar. --- .../LibraryAuthoringPage.test.tsx | 21 +++ .../LibraryAuthoringPage.tsx | 5 +- src/library-authoring/LibraryLayout.tsx | 33 ++-- .../collections/LibraryCollectionPage.tsx | 2 +- .../common/context/LibraryContext.tsx | 21 ++- .../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 | 26 ++- 19 files changed, 679 insertions(+), 38 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 2df4e39a9..89c481226 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 e3321c6a8..7871fd99b 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]); @@ -143,6 +143,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage showOnlyPublished, componentId, collectionId, + unitId, } = useLibraryContext(); const { openInfoSidebar, sidebarComponentInfo } = useSidebarContext(); @@ -173,7 +174,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage useEffect(() => { if (!componentPickerMode) { - openInfoSidebar(componentId, collectionId); + openInfoSidebar(componentId, collectionId, unitId); } }, []); diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 844b366e4..8c48bae7a 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 6dd1a5c0b..306a64ab5 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/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx index 76d7af288..9b467e19a 100644 --- a/src/library-authoring/common/context/LibraryContext.tsx +++ b/src/library-authoring/common/context/LibraryContext.tsx @@ -25,10 +25,13 @@ export type LibraryContextData = { libraryData?: ContentLibrary; readOnly: boolean; isLoadingLibraryData: boolean; + /** The ID of the current collection/component/unit, on the sidebar OR page */ collectionId: string | undefined; setCollectionId: (collectionId?: string) => void; componentId: string | undefined; setComponentId: (componentId?: string) => void; + unitId: string | undefined; + setUnitId: (unitId?: string) => void; // Only show published components showOnlyPublished: boolean; // "Create New Collection" modal @@ -106,11 +109,21 @@ export const LibraryProvider = ({ // Parse the initial collectionId and/or componentId from the current URL params const params = useParams(); + const { + collectionId: urlCollectionId, + componentId: urlComponentId, + unitId: urlUnitId, + selectedItemId: urlSelectedItemId, + } = params; + const selectedItemIdIsUnit = !!urlSelectedItemId?.startsWith('lct:'); const [componentId, setComponentId] = useState( - skipUrlUpdate ? undefined : params.componentId, + skipUrlUpdate ? undefined : urlComponentId, ); const [collectionId, setCollectionId] = useState( - skipUrlUpdate ? undefined : params.collectionId, + skipUrlUpdate ? undefined : urlCollectionId || (!selectedItemIdIsUnit ? urlSelectedItemId : undefined), + ); + const [unitId, setUnitId] = useState( + skipUrlUpdate ? undefined : urlUnitId || (selectedItemIdIsUnit ? urlSelectedItemId : undefined), ); const context = useMemo(() => { @@ -121,6 +134,8 @@ export const LibraryProvider = ({ setCollectionId, componentId, setComponentId, + unitId, + setUnitId, readOnly, isLoadingLibraryData, showOnlyPublished, @@ -144,6 +159,8 @@ export const LibraryProvider = ({ setCollectionId, componentId, setComponentId, + unitId, + setUnitId, readOnly, isLoadingLibraryData, showOnlyPublished, diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index 7dd7e9c7d..d82106e83 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 7855d82cd..42cd49dfc 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 000000000..3ac06045a --- /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 000000000..3dbaef414 --- /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 000000000..007a0eef7 --- /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 000000000..b1a0d3538 --- /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 21f697058..d84ba79c6 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 f0079ca13..352089a88 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 746e0139d..2f920b544 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 abab56ffc..31bbf1bbd 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 c329a49b8..77135e039 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 6f4b6c2f6..8a58f3a41 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 69080cc00..da97e2d9a 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,26 @@ export const useLibraryRoutes = (): LibraryRoutesData => { const navigateTo = useCallback(({ componentId, collectionId, + unitId, contentType, }: NavigateToData = {}) => { + const { + collectionId: urlCollectionId, + componentId: urlComponentId, + unitId: urlUnitId, + selectedItemId: urlSelectedItemId, + } = params; + 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 }), + ...(contentType === ContentType.home && { selectedItemId: urlCollectionId || urlUnitId }), + ...(contentType === ContentType.components && { componentId: urlComponentId || urlSelectedItemId }), + ...(contentType === ContentType.collections && { collectionId: urlCollectionId || urlSelectedItemId }), + ...(contentType === ContentType.units && { unitId: urlUnitId || urlSelectedItemId }), }; let route; @@ -90,7 +104,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => { } else if (insideCollections) { // We're inside the Collections tab, route = ( - (collectionId && collectionId === params.collectionId) + (collectionId && collectionId === (urlCollectionId || urlSelectedItemId)) // now open the previously-selected collection, ? ROUTES.COLLECTION // or stay there to list all collections, or a selected collection. @@ -107,16 +121,14 @@ 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) + (collectionId && collectionId === (urlCollectionId || urlSelectedItemId)) // now open the previously-selected collection ? ROUTES.COLLECTION // or stay there to list all content, or optionally select a collection.