feat: library unit sidebar [FC-0083] (#1762)

Implements the placeholder for the Unit Sidebar.
This commit is contained in:
Rômulo Penido
2025-04-07 13:51:10 -03:00
committed by GitHub
parent 2a31434a55
commit 68d62cd62f
19 changed files with 679 additions and 38 deletions

View File

@@ -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('<LibraryAuthoringPage />', () => {
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();

View File

@@ -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);
}
}, []);

View File

@@ -54,30 +54,23 @@ const LibraryLayout = () => {
return (
<Routes>
<Route
path={ROUTES.COMPONENTS}
element={context(<LibraryAuthoringPage />)}
/>
<Route
path={ROUTES.UNITS}
element={context(<LibraryAuthoringPage />)}
/>
<Route
path={ROUTES.COLLECTIONS}
element={context(<LibraryAuthoringPage />)}
/>
<Route
path={ROUTES.COMPONENT}
element={context(<LibraryAuthoringPage />)}
/>
{[
ROUTES.HOME,
ROUTES.COMPONENT,
ROUTES.COMPONENTS,
ROUTES.COLLECTIONS,
ROUTES.UNITS,
].map((route) => (
<Route
key={route}
path={route}
element={context(<LibraryAuthoringPage />)}
/>
))}
<Route
path={ROUTES.COLLECTION}
element={context(<LibraryCollectionPage />)}
/>
<Route
path={ROUTES.HOME}
element={context(<LibraryAuthoringPage />)}
/>
</Routes>
);
};

View File

@@ -120,7 +120,7 @@ const LibraryCollectionPage = () => {
} = useCollection(libraryId, collectionId);
useEffect(() => {
openInfoSidebar(componentId, collectionId);
openInfoSidebar(componentId, collectionId, '');
}, []);
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);

View File

@@ -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<LibraryContextData>(() => {
@@ -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,

View File

@@ -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<string>(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<string>(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: () => {},

View File

@@ -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 (
<BaseCard

View File

@@ -0,0 +1,174 @@
import type MockAdapter from 'axios-mock-adapter';
import userEvent from '@testing-library/user-event';
import {
initializeMocks,
fireEvent,
render as baseRender,
screen,
waitFor,
} from '../../testUtils';
import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks';
import * as api from '../data/api';
import ContainerInfoHeader from './ContainerInfoHeader';
let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
mockGetContainerMetadata.applyMock();
mockContentLibrary.applyMock();
const {
libraryId: mockLibraryId,
libraryIdReadOnly,
} = mockContentLibrary;
const { containerId } = mockGetContainerMetadata;
const render = (libraryId: string = mockLibraryId) => baseRender(<ContainerInfoHeader />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId}>
<SidebarProvider
initialSidebarComponentInfo={{
id: containerId,
type: SidebarBodyComponentId.UnitInfo,
}}
>
{ children }
</SidebarProvider>
</LibraryProvider>
),
});
describe('<ContainerInfoHeader />', () => {
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.');
});
});

View File

@@ -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<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSaveDisplayName(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
return (
<Stack direction="horizontal">
{inputIsActive
? (
<Form.Control
autoFocus
name="title"
id="title"
type="text"
aria-label="Title input"
defaultValue={container.displayName}
onBlur={handleSaveDisplayName}
onKeyDown={handleOnKeyDown}
/>
)
: (
<>
<span className="font-weight-bold m-1.5">
{container.displayName}
</span>
{!readOnly && (
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editTitleButtonAlt)}
onClick={handleClick}
size="inline"
/>
)}
</>
)}
</Stack>
);
};
export default ContainerInfoHeader;

View File

@@ -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 (
<Stack>
{showOpenCollectionButton && (
<div className="d-flex flex-wrap">
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
disabled
>
{intl.formatMessage(messages.openUnitButton)}
</Button>
</div>
)}
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey={UNIT_INFO_TABS.Preview}
activeKey={tab}
onSelect={setSidebarTab}
>
<Tab eventKey={UNIT_INFO_TABS.Preview} title={intl.formatMessage(messages.previewTabTitle)}>
Unit Preview
</Tab>
<Tab eventKey={UNIT_INFO_TABS.Organize} title={intl.formatMessage(messages.organizeTabTitle)}>
Organize Unit
</Tab>
<Tab eventKey={UNIT_INFO_TABS.Settings} title={intl.formatMessage(messages.settingsTabTitle)}>
Unit Settings
</Tab>
</Tabs>
</Stack>
);
};
export default UnitInfo;

View File

@@ -0,0 +1,2 @@
export { default as UnitInfo } from './UnitInfo';
export { default as ContainerInfoHeader } from './ContainerInfoHeader';

View File

@@ -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;

View File

@@ -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<api.Container> {
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()`
*

View File

@@ -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<Container> {
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));
}

View File

@@ -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);
});
});

View File

@@ -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) });
},
});
};

View File

@@ -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]: <LibraryInfo />,
[SidebarBodyComponentId.ComponentInfo]: <ComponentInfo />,
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfo />,
[SidebarBodyComponentId.UnitInfo]: <UnitInfo />,
unknown: null,
};
@@ -40,6 +42,7 @@ const LibrarySidebar = () => {
[SidebarBodyComponentId.Info]: <LibraryInfoHeader />,
[SidebarBodyComponentId.ComponentInfo]: <ComponentInfoHeader />,
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfoHeader />,
[SidebarBodyComponentId.UnitInfo]: <ContainerInfoHeader />,
unknown: null,
};

View File

@@ -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,
},
});

View File

@@ -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.