feat: library unit sidebar [FC-0083] (#1762)
Implements the placeholder for the Unit Sidebar.
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -120,7 +120,7 @@ const LibraryCollectionPage = () => {
|
||||
} = useCollection(libraryId, collectionId);
|
||||
|
||||
useEffect(() => {
|
||||
openInfoSidebar(componentId, collectionId);
|
||||
openInfoSidebar(componentId, collectionId, '');
|
||||
}, []);
|
||||
|
||||
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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
|
||||
|
||||
174
src/library-authoring/containers/ContainerInfoHeader.test.tsx
Normal file
174
src/library-authoring/containers/ContainerInfoHeader.test.tsx
Normal 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.');
|
||||
});
|
||||
});
|
||||
106
src/library-authoring/containers/ContainerInfoHeader.tsx
Normal file
106
src/library-authoring/containers/ContainerInfoHeader.tsx
Normal 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;
|
||||
70
src/library-authoring/containers/UnitInfo.tsx
Normal file
70
src/library-authoring/containers/UnitInfo.tsx
Normal 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;
|
||||
2
src/library-authoring/containers/index.tsx
Normal file
2
src/library-authoring/containers/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as UnitInfo } from './UnitInfo';
|
||||
export { default as ContainerInfoHeader } from './ContainerInfoHeader';
|
||||
41
src/library-authoring/containers/messages.ts
Normal file
41
src/library-authoring/containers/messages.ts
Normal 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;
|
||||
@@ -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()`
|
||||
*
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user