feat: library section and subsection page (#2032)

* Adds section and subsection library pages. 
* Refactors routing to support them and fix routing from collections page to other pages.
* Refactors library context to reliably set component, unit, and other container ids even when the url changes when user goes back in history rapidly.
This commit is contained in:
Navin Karkera
2025-06-04 17:32:29 +00:00
committed by GitHub
parent 99e11d3534
commit dd6780ff41
46 changed files with 1798 additions and 453 deletions

View File

@@ -1,14 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import { intlShape, injectIntl } from '@edx/frontend-platform/i18n';
import React, { MouseEventHandler } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
ActionRow, Card, Icon, IconButtonWithTooltip,
} from '@openedx/paragon';
import { DragIndicator } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
interface SortableItemProps {
id: string,
children?: React.ReactNode,
actions: React.ReactNode,
actionStyle?: {},
componentStyle?: {},
isClickable?: boolean,
onClick?: MouseEventHandler,
disabled?: boolean,
cardClassName?: string,
}
const SortableItem = ({
id,
componentStyle,
@@ -19,9 +30,8 @@ const SortableItem = ({
onClick,
disabled,
cardClassName = '',
// injected
intl,
}) => {
}: SortableItemProps) => {
const intl = useIntl();
const {
attributes,
listeners,
@@ -78,26 +88,5 @@ const SortableItem = ({
</div>
);
};
SortableItem.defaultProps = {
componentStyle: null,
actions: null,
actionStyle: null,
isClickable: false,
onClick: null,
disabled: false,
};
SortableItem.propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
actions: PropTypes.node,
actionStyle: PropTypes.shape({}),
componentStyle: PropTypes.shape({}),
isClickable: PropTypes.bool,
onClick: PropTypes.func,
disabled: PropTypes.bool,
cardClassName: PropTypes.string,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(SortableItem);
export default SortableItem;

View File

@@ -4,8 +4,6 @@ import {
getLibraryId,
isLibraryKey,
isLibraryV1Key,
getContainerTypeFromId,
ContainerType,
} from './key-utils';
describe('component utils', () => {
@@ -14,6 +12,9 @@ describe('component utils', () => {
['lb:org:lib:html:id', 'html'],
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'html'],
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'problem'],
['lct:org:lib:unit:my-unit-9284e2', 'unit'],
['lct:org:lib:section:my-section-9284e2', 'section'],
['lct:org:lib:subsection:my-section-9284e2', 'subsection'],
]) {
it(`returns '${expected}' for usage key '${input}'`, () => {
expect(getBlockType(input)).toStrictEqual(expected);
@@ -99,16 +100,4 @@ describe('component utils', () => {
});
}
});
describe('getContainerTypeFromId', () => {
for (const [input, expected] of [
['lct:org:lib:unit:my-unit-9284e2', ContainerType.Unit],
['lct:OpenCraftX:ALPHA:my-unit-a3223f', undefined],
['', undefined],
]) {
it(`returns '${expected}' for container key '${input}'`, () => {
expect(getContainerTypeFromId(input!)).toStrictEqual(expected);
});
}
});
});

View File

@@ -4,7 +4,7 @@
* @returns The block type as a string
*/
export function getBlockType(usageKey: string): string {
if (usageKey && usageKey.startsWith('lb:')) {
if (usageKey && (usageKey.startsWith('lb:') || usageKey.startsWith('lct:'))) {
const blockType = usageKey.split(':')[3];
if (blockType) {
return blockType;
@@ -66,22 +66,3 @@ export enum ContainerType {
Sequential = 'sequential',
Vertical = 'vertical',
}
/**
* Given a container key like `ltc:org:lib:unit:id`
* get the container type
*/
export function getContainerTypeFromId(containerId: string): ContainerType | undefined {
const parts = containerId.split(':');
if (parts.length < 2) {
return undefined;
}
const maybeType = parts[parts.length - 2];
if (Object.values(ContainerType).includes(maybeType as ContainerType)) {
return maybeType as ContainerType;
}
return undefined;
}

View File

@@ -16,9 +16,12 @@ import LibraryCollectionPage from './collections/LibraryCollectionPage';
import { ComponentPicker } from './component-picker';
import { ComponentEditorModal } from './components/ComponentEditorModal';
import { LibraryUnitPage } from './units';
import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections';
const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
const { libraryId, collectionId, unitId } = useParams();
const {
libraryId, collectionId, unitId, sectionId, subsectionId,
} = useParams();
if (libraryId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
@@ -31,7 +34,7 @@ const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) =
* when we navigate to a collection or unit page. This is necessary to make the back/forward navigation
* work correctly, as the LibraryProvider needs to rebuild the state from the URL.
* */
key={collectionId || unitId}
key={collectionId || sectionId || subsectionId || unitId}
libraryId={libraryId}
/** NOTE: The component picker modal to use. We need to pass it as a reference instead of
* directly importing it to avoid the import cycle:
@@ -70,6 +73,14 @@ const LibraryLayout = () => (
path={ROUTES.COLLECTION}
Component={LibraryCollectionPage}
/>
<Route
path={ROUTES.SECTION}
Component={LibrarySectionPage}
/>
<Route
path={ROUTES.SUBSECTION}
Component={LibrarySubsectionPage}
/>
<Route
path={ROUTES.UNIT}
Component={LibraryUnitPage}

View File

@@ -0,0 +1,148 @@
{
"comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts",
"note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.",
"results": [
{
"indexUid": "studio_content",
"hits": [
{
"display_name": "Test Subsection",
"block_id": "subsection-1",
"content": {
"child_usage_keys": [
"lb:org1:Demo_course:unit:unit-0",
"lb:org1:Demo_course:unit:unit-1",
"lb:org1:Demo_course:unit:unit-2"
],
"child_display_names": [
"unit block 0",
"unit block 1",
"unit block 2"
]
},
"tags": {},
"collections": {
"display_name": [
"Collection 1"
],
"key": [
"collection-1"
]
},
"id": "lctunixcs1subsectiontest-subsection-4-861b09-bc0507a2",
"type": "library_container",
"breadcrumbs": [
{
"display_name": "CS 1"
}
],
"created": 1748341802.87277,
"modified": 1748790449.393821,
"last_published": 1748536425.317183,
"publish_status": "modified",
"usage_key": "lct:UNIX:CS1:subsection:test-subsection-4-861b09",
"block_type": "subsection",
"context_key": "lib:UNIX:CS1",
"org": "UNIX",
"access_id": 1,
"published": {
"display_name": "Test subsection 42",
"num_children": 2,
"content": {
"child_usage_keys": [
"lb:org1:Demo_course:unit:unit-0",
"lb:org1:Demo_course:unit:unit-1"
],
"child_display_names": [
"unit block 0",
"unit block 1"
]
}
},
"num_children": 4,
"sections": {
"display_name": [
"Test section",
"Test section 51"
],
"key": [
"lct:org:lib:section:test-section-1",
"lct:org:lib:section:test-section-2"
]
},
"_formatted": {
"display_name": "Test Subsection",
"block_id": "subsection-1",
"content": {
"child_usage_keys": [
"lb:org1:Demo_course:unit:unit-0",
"lb:org1:Demo_course:unit:unit-1",
"lb:org1:Demo_course:unit:unit-2"
],
"child_display_names": [
"unit block 0",
"unit block 1",
"unit block 2"
]
},
"tags": {},
"collections": {
"display_name": [
"Collection 1"
],
"key": [
"collection-1"
]
},
"id": "lctunixcs1subsectiontest-subsection-4-861b09-bc0507a2",
"type": "library_container",
"breadcrumbs": [
{
"display_name": "CS 1"
}
],
"created": "1748341802.87277",
"modified": "1748790449.393821",
"last_published": "1748536425.317183",
"publish_status": "modified",
"usage_key": "lct:UNIX:CS1:subsection:test-subsection-4-861b09",
"block_type": "subsection",
"context_key": "lib:UNIX:CS1",
"org": "UNIX",
"access_id": "1",
"published": {
"display_name": "Test subsection 42",
"num_children": "2",
"content": {
"child_usage_keys": [
"lb:org1:Demo_course:unit:unit-0",
"lb:org1:Demo_course:unit:unit-1"
],
"child_display_names": [
"unit block 0",
"unit block 1"
]
}
},
"num_children": "4",
"sections": {
"display_name": [
"Test section 11",
"Test section 51"
],
"key": [
"lct:UNIX:CS1:section:test-section-1-415565",
"lct:UNIX:CS1:section:test-section-5-0c65f3"
]
}
}
}
],
"query": "",
"processingTimeMs": 1,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 10
}
]
}

View File

@@ -23,13 +23,13 @@ import {
useLibraryPasteClipboard,
useBlockTypesMetadata,
useAddItemsToCollection,
useAddComponentsToContainer,
useAddItemsToContainer,
} from '../data/apiHooks';
import { useLibraryContext } from '../common/context/LibraryContext';
import { PickLibraryContentModal } from './PickLibraryContentModal';
import { blockTypes } from '../../editors/data/constants/app';
import { ContentType as LibraryContentTypes, useLibraryRoutes } from '../routes';
import { useLibraryRoutes } from '../routes';
import genericMessages from '../generic/messages';
import messages from './messages';
import type { BlockTypeMetadata } from '../data/api';
@@ -88,10 +88,7 @@ const AddContentView = ({
closeAddLibraryContentModal,
}: AddContentViewProps) => {
const intl = useIntl();
const {
componentPicker,
unitId,
} = useLibraryContext();
const { componentPicker } = useLibraryContext();
const {
insideCollection,
insideUnit,
@@ -129,9 +126,6 @@ const AddContentView = ({
blockType: 'libraryContent',
};
const extraFilter = unitId ? ['NOT block_type = "unit"', 'NOT type = "collections"'] : undefined;
const visibleTabs = unitId ? [LibraryContentTypes.components] : undefined;
/** List container content types that should be displayed based on current path */
const visibleContentTypes = useMemo(() => {
if (insideCollection) {
@@ -182,8 +176,6 @@ const AddContentView = ({
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
extraFilter={extraFilter}
visibleTabs={visibleTabs}
/>
)}
<hr className="w-100 bg-gray-500" />
@@ -276,7 +268,7 @@ const AddContent = () => {
insideUnit,
} = useLibraryRoutes();
const addComponentsToCollectionMutation = useAddItemsToCollection(libraryId, collectionId);
const addComponentsToContainerMutation = useAddComponentsToContainer(unitId);
const addComponentsToContainerMutation = useAddItemsToContainer(unitId);
const createBlockMutation = useCreateLibraryBlock();
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);

View File

@@ -33,25 +33,44 @@ const mockAddComponentsToContainer = jest.fn();
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
jest.spyOn(api, 'addComponentsToContainer').mockImplementation(mockAddComponentsToContainer);
const unitId = 'lct:Axim:TEST:unit:test-unit-1';
const sectionId = 'lct:Axim:TEST:section:test-section-1';
const subsectionId = 'lct:Axim:TEST:subsection:test-subsection-1';
type ContextType = 'collection' | 'unit' | 'section' | 'subsection';
const render = (context: 'collection' | 'unit') => baseRender(<PickLibraryContentModal isOpen onClose={onClose} />, {
path: context === 'collection'
? '/library/:libraryId/collection/:collectionId/*'
: '/library/:libraryId/container/:unitId/*',
params: {
libraryId,
...(context === 'collection' && { collectionId: 'collectionId' }),
...(context === 'unit' && { unitId }),
const getIdFromContext = (context: ContextType) => {
switch (context) {
case 'section':
return sectionId;
case 'subsection':
return subsectionId;
case 'unit':
return unitId;
default:
return '';
}
};
const render = (context: ContextType) => baseRender(
<PickLibraryContentModal isOpen onClose={onClose} />,
{
path: `/library/:libraryId/${context}/:${context}Id/*`,
params: {
libraryId,
...(context === 'collection' && { collectionId: 'collectionId' }),
...(context === 'unit' && { unitId }),
...(context === 'section' && { sectionId }),
...(context === 'subsection' && { subsectionId }),
},
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
componentPicker={ComponentPicker}
>
{children}
</LibraryProvider>
),
},
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
componentPicker={ComponentPicker}
>
{children}
</LibraryProvider>
),
});
);
describe('<PickLibraryContentModal />', () => {
beforeEach(async () => {
@@ -61,7 +80,12 @@ describe('<PickLibraryContentModal />', () => {
jest.clearAllMocks();
});
['collection' as const, 'unit' as const].forEach((context) => {
[
'collection' as const,
'unit' as const,
'section' as const,
'subsection' as const,
].forEach((context) => {
it(`can pick components from the modal (${context})`, async () => {
render(context);
@@ -78,17 +102,24 @@ describe('<PickLibraryContentModal />', () => {
fireEvent.click(screen.getByRole('button', { name: /add to .*/i }));
await waitFor(() => {
if (context === 'collection') {
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
} else {
expect(mockAddComponentsToContainer).toHaveBeenCalledWith(
unitId,
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
switch (context) {
case 'collection':
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
break;
case 'unit':
case 'section':
case 'subsection':
expect(mockAddComponentsToContainer).toHaveBeenCalledWith(
getIdFromContext(context),
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
break;
default:
break;
}
});
expect(onClose).toHaveBeenCalled();
@@ -119,17 +150,24 @@ describe('<PickLibraryContentModal />', () => {
fireEvent.click(screen.getByRole('button', { name: /add to .*/i }));
await waitFor(() => {
if (context === 'collection') {
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
} else {
expect(mockAddComponentsToContainer).toHaveBeenCalledWith(
unitId,
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
switch (context) {
case 'collection':
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
break;
case 'unit':
case 'section':
case 'subsection':
expect(mockAddComponentsToContainer).toHaveBeenCalledWith(
getIdFromContext(context),
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
break;
default:
break;
}
});
expect(onClose).toHaveBeenCalled();

View File

@@ -5,9 +5,9 @@ import { ActionRow, Button, StandardModal } from '@openedx/paragon';
import { ToastContext } from '../../generic/toast-context';
import { useLibraryContext } from '../common/context/LibraryContext';
import type { SelectedComponent } from '../common/context/ComponentPickerContext';
import { useAddItemsToCollection, useAddComponentsToContainer } from '../data/apiHooks';
import { useAddItemsToCollection, useAddItemsToContainer } from '../data/apiHooks';
import genericMessages from '../generic/messages';
import type { ContentType } from '../routes';
import { allLibraryPageTabs, ContentType, useLibraryRoutes } from '../routes';
import messages from './messages';
interface PickLibraryContentModalFooterProps {
@@ -33,21 +33,16 @@ const PickLibraryContentModalFooter: React.FC<PickLibraryContentModalFooterProps
interface PickLibraryContentModalProps {
isOpen: boolean;
onClose: () => void;
extraFilter?: string[];
visibleTabs?: ContentType[],
}
export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ({
isOpen,
onClose,
extraFilter,
visibleTabs,
}) => {
export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ({ isOpen, onClose }) => {
const intl = useIntl();
const {
libraryId,
collectionId,
sectionId,
subsectionId,
unitId,
/** We need to get it as a reference instead of directly importing it to avoid the import cycle:
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
@@ -55,13 +50,17 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
componentPicker: ComponentPicker,
} = useLibraryContext();
// istanbul ignore if: this should never happen
if (!(collectionId || unitId) || !ComponentPicker) {
throw new Error('collectionId/unitId and componentPicker are required');
}
const {
insideCollection, insideUnit, insideSection, insideSubsection,
} = useLibraryRoutes();
const updateCollectionItemsMutation = useAddItemsToCollection(libraryId, collectionId);
const updateUnitComponentsMutation = useAddComponentsToContainer(unitId);
const updateContainerChildrenMutation = useAddItemsToContainer(
(insideSection && sectionId)
|| (insideSubsection && subsectionId)
|| (insideUnit && unitId)
|| '',
);
const { showToast } = useContext(ToastContext);
@@ -70,7 +69,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
const onSubmit = useCallback(() => {
const usageKeys = selectedComponents.map(({ usageKey }) => usageKey);
onClose();
if (collectionId) {
if (insideCollection && collectionId) {
updateCollectionItemsMutation.mutateAsync(usageKeys)
.then(() => {
showToast(intl.formatMessage(genericMessages.manageCollectionsSuccess));
@@ -78,9 +77,8 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
.catch(() => {
showToast(intl.formatMessage(genericMessages.manageCollectionsFailed));
});
}
if (unitId) {
updateUnitComponentsMutation.mutateAsync(usageKeys)
} else if (insideSection || insideSubsection || insideUnit) {
updateContainerChildrenMutation.mutateAsync(usageKeys)
.then(() => {
showToast(intl.formatMessage(messages.successAssociateComponentToContainerMessage));
})
@@ -88,7 +86,46 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
});
}
}, [selectedComponents]);
}, [
selectedComponents,
insideSection,
insideSubsection,
insideUnit,
collectionId,
sectionId,
subsectionId,
unitId,
]);
// determine filter an visibleTabs based on current location
let extraFilter = ['NOT type = "collection"'];
let visibleTabs = allLibraryPageTabs.filter((tab) => tab !== ContentType.collections);
let addBtnText = messages.addToCollectionButton;
if (insideSection) {
// show only subsections
extraFilter = ['block_type = "subsection"'];
addBtnText = messages.addToSectionButton;
visibleTabs = [ContentType.subsections];
} else if (insideSubsection) {
// show only units
extraFilter = ['block_type = "unit"'];
addBtnText = messages.addToSubsectionButton;
visibleTabs = [ContentType.units];
} else if (insideUnit) {
// show only components
extraFilter = [
'NOT block_type = "unit"',
'NOT block_type = "subsection"',
'NOT block_type = "section"',
];
addBtnText = messages.addToUnitButton;
visibleTabs = [ContentType.components];
}
// istanbul ignore if: this should never happen, just here to satisfy type checker
if (!(collectionId || unitId || sectionId || subsectionId) || !ComponentPicker) {
throw new Error('collectionId/sectionId/unitId and componentPicker are required');
}
return (
<StandardModal
@@ -101,10 +138,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
<PickLibraryContentModalFooter
onSubmit={onSubmit}
selectedComponents={selectedComponents}
buttonText={(collectionId
? intl.formatMessage(messages.addToCollectionButton)
: intl.formatMessage(messages.addToUnitButton)
)}
buttonText={intl.formatMessage(addBtnText)}
/>
)}
>

View File

@@ -36,6 +36,16 @@ const messages = defineMessages({
defaultMessage: 'Add to Unit',
description: 'Button to add library content to a unit.',
},
addToSectionButton: {
id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-section',
defaultMessage: 'Add to Section',
description: 'Button to add library content to a section.',
},
addToSubsectionButton: {
id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-subsection',
defaultMessage: 'Add to Subsection',
description: 'Button to add library content to a subsection.',
},
selectedComponents: {
id: 'course-authoring.library-authoring.add-content.selected-components',
defaultMessage: '{count, plural, one {# Selected Component} other {# Selected Components}}',

View File

@@ -34,7 +34,6 @@ const CollectionInfoHeader = () => {
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
} catch (err) {
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
throw err;
}
};

View File

@@ -139,7 +139,7 @@ describe('<LibraryCollectionPage />', () => {
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
expect((await screen.findAllByText(title))[0]).toBeInTheDocument();
expect(screen.getAllByText('This collection is currently empty.').length).toEqual(2);
expect((await screen.findAllByText('This collection is currently empty.')).length).toEqual(2);
const addComponentButton = screen.getAllByRole('button', { name: /new/i })[1];
fireEvent.click(addComponentButton);

View File

@@ -100,16 +100,13 @@ const HeaderActions = () => {
const LibraryCollectionPage = () => {
const intl = useIntl();
const { libraryId, collectionId } = useLibraryContext();
if (!collectionId || !libraryId) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Rendered without collectionId or libraryId URL parameter');
}
const { componentPickerMode } = useComponentPickerContext();
const {
showOnlyPublished, extraFilter: contextExtraFilter, setCollectionId,
libraryId,
collectionId,
showOnlyPublished,
extraFilter: contextExtraFilter,
setCollectionId,
} = useLibraryContext();
const { sidebarComponentInfo } = useSidebarContext();
@@ -122,6 +119,10 @@ const LibraryCollectionPage = () => {
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
if (!collectionId || !libraryId) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Rendered without collectionId or libraryId URL parameter');
}
// Only show loading if collection data is not fetched from index yet
// Loading info for search results will be handled by LibraryCollectionComponents component.
if (isLibLoading || isLoading) {

View File

@@ -31,6 +31,10 @@ export type LibraryContextData = {
setCollectionId: (collectionId?: string) => void;
unitId: string | undefined;
setUnitId: (unitId?: string) => void;
sectionId: string | undefined;
setSectionId: (sectionId?: string) => void;
subsectionId: string | undefined;
setSubsectionId: (sectionId?: string) => void;
// Only show published components
showOnlyPublished: boolean;
// Additional filtering
@@ -114,6 +118,8 @@ export const LibraryProvider = ({
const {
collectionId: urlCollectionId,
unitId: urlUnitId,
sectionId: urlSectionId,
subsectionId: urlSubsectionId,
} = params;
const [collectionId, setCollectionId] = useState(
skipUrlUpdate ? undefined : urlCollectionId,
@@ -121,6 +127,12 @@ export const LibraryProvider = ({
const [unitId, setUnitId] = useState(
skipUrlUpdate ? undefined : urlUnitId,
);
const [sectionId, setSectionId] = useState(
skipUrlUpdate ? undefined : urlSectionId,
);
const [subsectionId, setSubsectionId] = useState(
skipUrlUpdate ? undefined : urlSubsectionId,
);
const context = useMemo<LibraryContextData>(() => {
const contextValue = {
@@ -130,6 +142,10 @@ export const LibraryProvider = ({
setCollectionId,
unitId,
setUnitId,
sectionId,
setSectionId,
subsectionId,
setSubsectionId,
readOnly,
isLoadingLibraryData,
showOnlyPublished,

View File

@@ -8,7 +8,7 @@ import {
} from 'react';
import { useParams } from 'react-router-dom';
import { useStateWithUrlSearchParam } from '../../../hooks';
import { getContainerTypeFromId } from '../../../generic/key-utils';
import { getBlockType } from '../../../generic/key-utils';
import { useComponentPickerContext } from './ComponentPickerContext';
import { useLibraryContext } from './LibraryContext';
@@ -18,6 +18,8 @@ export enum SidebarBodyComponentId {
ComponentInfo = 'component-info',
CollectionInfo = 'collection-info',
UnitInfo = 'unit-info',
SectionInfo = 'section-info',
SubsectionInfo = 'subsection-info',
}
export const COLLECTION_INFO_TABS = {
@@ -190,7 +192,12 @@ export const SidebarProvider = ({
// Handle selected item id changes
if (selectedItemId) {
const containerType = getContainerTypeFromId(selectedItemId);
let containerType: undefined | string;
try {
containerType = getBlockType(selectedItemId);
} catch {
// ignore
}
if (containerType === 'unit') {
openUnitInfoSidebar(selectedItemId);
} else if (containerType === 'section') {

View File

@@ -36,7 +36,6 @@ const ComponentInfoHeader = () => {
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
} catch (err) {
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
throw err;
}
};

View File

@@ -7,7 +7,7 @@ import { useEntityLinks } from '../../course-libraries/data/apiHooks';
import AlertError from '../../generic/alert-error';
import Loading from '../../generic/Loading';
import messages from './messages';
import { useComponentsFromSearchIndex } from '../data/apiHooks';
import { useContentFromSearchIndex } from '../data/apiHooks';
interface ComponentUsageProps {
usageKey: string;
@@ -46,7 +46,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
isError: isErrorIndexDocuments,
error: errorIndexDocuments,
isLoading: isLoadingIndexDocuments,
} = useComponentsFromSearchIndex(downstreamKeys);
} = useContentFromSearchIndex(downstreamKeys);
if (isErrorDownstreamLinks || isErrorIndexDocuments) {
return <AlertError error={errorDownstreamLinks || errorIndexDocuments} />;

View File

@@ -5,7 +5,7 @@ import { School, Warning, Info } from '@openedx/paragon/icons';
import { useSidebarContext } from '../common/context/SidebarContext';
import {
useComponentsFromSearchIndex,
useContentFromSearchIndex,
useDeleteLibraryBlock,
useLibraryBlockMetadata,
useRestoreLibraryBlock,
@@ -69,7 +69,7 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => {
}
}, [usageKey, sidebarComponentUsageKey, closeLibrarySidebar]);
const { hits } = useComponentsFromSearchIndex([usageKey]);
const { hits } = useContentFromSearchIndex([usageKey]);
const componentHit = (hits as ContentHit[])?.[0];
if (!props.isConfirmingDelete) {

View File

@@ -13,7 +13,7 @@ import { SidebarActions, useSidebarContext } from '../common/context/SidebarCont
import { useClipboard } from '../../generic/clipboard';
import { ToastContext } from '../../generic/toast-context';
import {
useAddComponentsToContainer,
useAddItemsToContainer,
useRemoveContainerChildren,
useRemoveItemsFromCollection,
} from '../data/apiHooks';
@@ -38,11 +38,11 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
closeLibrarySidebar,
setSidebarAction,
} = useSidebarContext();
const { navigateTo } = useLibraryRoutes();
const { navigateTo, insideCollection } = useLibraryRoutes();
const canEdit = usageKey && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const addComponentToContainerMutation = useAddComponentsToContainer(unitId);
const addComponentToContainerMutation = useAddItemsToContainer(unitId);
const removeCollectionComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
const removeContainerComponentsMutation = useRemoveContainerChildren(unitId);
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
@@ -137,7 +137,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
<Dropdown.Item onClick={confirmDelete}>
<FormattedMessage {...messages.menuDelete} />
</Dropdown.Item>
{collectionId && (
{insideCollection && (
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...messages.menuRemoveFromCollection} />
</Dropdown.Item>

View File

@@ -10,28 +10,27 @@ import { mockContentLibrary } from '../data/api.mocks';
import { type ContainerHit, PublishStatus } from '../../search-manager';
import ContainerCard from './ContainerCard';
import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api';
import { ContainerType } from '../../generic/key-utils';
let axiosMock: MockAdapter;
let mockShowToast;
const mockNavigate = jest.fn();
const libraryId = 'lib:Axim:TEST';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useNavigate: () => mockNavigate,
}));
const containerHitSample: ContainerHit = {
id: 'lctorg1democourse-unit-display-name-123',
const getContainerHitSample = (containerType: ContainerType = ContainerType.Unit) => ({
id: `lctorg1democourse-${containerType}-display-name-123`,
type: 'library_container',
contextKey: 'lb:org1:Demo_Course',
usageKey: 'lct:org1:Demo_Course:unit:unit-display-name-123',
contextKey: libraryId,
usageKey: `lct:org1:Demo_Course:${containerType}:${containerType}-display-name-123`,
org: 'org1',
blockId: 'unit-display-name-123',
blockType: 'unit',
blockId: `${containerType}-display-name-123`,
blockType: containerType,
breadcrumbs: [{ displayName: 'Demo Lib' }],
displayName: 'Unit Display Name',
displayName: `${containerType} Display Name`,
formatted: {
displayName: 'Unit Display Formated Name',
displayName: `${containerType} Display Formated Name`,
published: {
displayName: 'Published Unit Display Name',
displayName: `Published ${containerType} Display Name`,
},
},
created: 1722434322294,
@@ -42,15 +41,15 @@ const containerHitSample: ContainerHit = {
},
tags: {},
publishStatus: PublishStatus.Published,
};
const libraryId = 'lib:Axim:TEST';
let axiosMock: MockAdapter;
let mockShowToast;
} as ContainerHit);
mockContentLibrary.applyMock();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, {
path: '/library/:libraryId',
params: { libraryId },
@@ -70,56 +69,79 @@ describe('<ContainerCard />', () => {
});
it('should render the card with title', () => {
render(<ContainerCard hit={containerHitSample} />);
render(<ContainerCard hit={getContainerHitSample()} />);
expect(screen.getByText('Unit Display Formated Name')).toBeInTheDocument();
expect(screen.getByText('unit Display Formated Name')).toBeInTheDocument();
expect(screen.queryByText('2')).toBeInTheDocument(); // Component count
});
it('should render published content', () => {
render(<ContainerCard hit={containerHitSample} />, true);
render(<ContainerCard hit={getContainerHitSample()} />, true);
expect(screen.getByText('Published Unit Display Name')).toBeInTheDocument();
expect(screen.getByText('Published unit Display Name')).toBeInTheDocument();
expect(screen.queryByText('1')).toBeInTheDocument(); // Published Component Count
});
it('should navigate to the container if the open menu clicked', async () => {
render(<ContainerCard hit={containerHitSample} />);
test.each([
{
label: 'should navigate to the unit if the open menu clicked',
containerType: ContainerType.Unit,
},
{
label: 'should navigate to the section if the open menu clicked',
containerType: ContainerType.Section,
},
{
label: 'should navigate to the subsection if the open menu clicked',
containerType: ContainerType.Subsection,
},
])('$label', async ({ containerType }) => {
render(<ContainerCard hit={getContainerHitSample(containerType)} />);
// Open menu
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
// Open menu item
const openMenuItem = screen.getByRole('button', { name: 'Open' });
const openMenuItem = await screen.findByRole('button', { name: 'Open' });
expect(openMenuItem).toBeInTheDocument();
fireEvent.click(openMenuItem);
userEvent.click(openMenuItem);
expect(mockNavigate).toHaveBeenCalledWith({
pathname: `/library/${libraryId}/unit/${containerHitSample.usageKey}`,
pathname: `/library/${libraryId}/${containerType}/${getContainerHitSample(containerType).usageKey}`,
search: '',
});
});
it('should navigate to the container if double clicked', async () => {
render(<ContainerCard hit={containerHitSample} />);
// Card title
const cardTitle = screen.getByText('Unit Display Formated Name');
expect(cardTitle).toBeInTheDocument();
userEvent.dblClick(cardTitle);
test.each([
{
label: 'should navigate to the unit if the card is double clicked',
containerType: ContainerType.Unit,
},
{
label: 'should navigate to the section if the card is double clicked',
containerType: ContainerType.Section,
},
{
label: 'should navigate to the subsection if the card is double clicked',
containerType: ContainerType.Subsection,
},
])('$label', async ({ containerType }) => {
render(<ContainerCard hit={getContainerHitSample(containerType)} />);
// Open menu item
const cardItem = await screen.findByText(`${containerType} Display Formated Name`);
expect(cardItem).toBeInTheDocument();
userEvent.click(cardItem, undefined, { clickCount: 2 });
expect(mockNavigate).toHaveBeenCalledWith({
pathname: `/library/${libraryId}/unit/${containerHitSample.usageKey}`,
pathname: `/library/${libraryId}/${containerType}/${getContainerHitSample(containerType).usageKey}`,
search: '',
});
});
it('should delete the container from the menu & restore the container', async () => {
axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(200);
axiosMock.onDelete(getLibraryContainerApiUrl(getContainerHitSample().usageKey)).reply(200);
render(<ContainerCard hit={containerHitSample} />);
render(<ContainerCard hit={getContainerHitSample()} />);
// Open menu
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
@@ -143,7 +165,7 @@ describe('<ContainerCard />', () => {
// Get restore / undo func from the toast
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
const restoreUrl = getLibraryContainerRestoreApiUrl(containerHitSample.usageKey);
const restoreUrl = getLibraryContainerRestoreApiUrl(getContainerHitSample().usageKey);
axiosMock.onPost(restoreUrl).reply(200);
// restore collection
restoreFn();
@@ -154,9 +176,9 @@ describe('<ContainerCard />', () => {
});
it('should show error on delete the container from the menu', async () => {
axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(400);
axiosMock.onDelete(getLibraryContainerApiUrl(getContainerHitSample().usageKey)).reply(400);
render(<ContainerCard hit={containerHitSample} />);
render(<ContainerCard hit={getContainerHitSample()} />);
// Open menu
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
@@ -179,7 +201,7 @@ describe('<ContainerCard />', () => {
});
it('should render no child blocks in card preview', async () => {
render(<ContainerCard hit={containerHitSample} />);
render(<ContainerCard hit={getContainerHitSample()} />);
expect(screen.queryByTitle('lb:org1:Demo_course:html:text-0')).not.toBeInTheDocument();
expect(screen.queryByText('+0')).not.toBeInTheDocument();
@@ -187,7 +209,7 @@ describe('<ContainerCard />', () => {
it('should render <=5 child blocks in card preview', async () => {
const containerWith5Children = {
...containerHitSample,
...getContainerHitSample(),
content: {
childUsageKeys: Array(5).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`),
},
@@ -200,7 +222,7 @@ describe('<ContainerCard />', () => {
it('should render >5 child blocks with +N in card preview', async () => {
const containerWith6Children = {
...containerHitSample,
...getContainerHitSample(),
content: {
childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`),
},
@@ -213,7 +235,7 @@ describe('<ContainerCard />', () => {
it('should render published child blocks when rendering a published card preview', async () => {
const containerWithPublishedChildren = {
...containerHitSample,
...getContainerHitSample(),
content: {
childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`),
},

View File

@@ -11,12 +11,12 @@ import {
import { MoreVert } from '@openedx/paragon/icons';
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
import { getBlockType } from '../../generic/key-utils';
import { ContainerType, getBlockType } from '../../generic/key-utils';
import { ToastContext } from '../../generic/toast-context';
import { type ContainerHit, PublishStatus } from '../../search-manager';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useRemoveItemsFromCollection } from '../data/apiHooks';
import { useLibraryRoutes } from '../routes';
import AddComponentWidget from './AddComponentWidget';
@@ -26,31 +26,28 @@ import ContainerDeleter from './ContainerDeleter';
import { useRunOnNextRender } from '../../utils';
type ContainerMenuProps = {
hit: ContainerHit,
containerKey: string;
containerType: ContainerType;
displayName: string;
};
const ContainerMenu = ({ hit } : ContainerMenuProps) => {
export const ContainerMenu = ({ containerKey, containerType, displayName } : ContainerMenuProps) => {
const intl = useIntl();
const {
usageKey: containerId,
displayName,
} = hit;
const { libraryId, collectionId } = useLibraryContext();
const {
sidebarComponentInfo,
openUnitInfoSidebar,
closeLibrarySidebar,
setSidebarAction,
} = useSidebarContext();
const { showToast } = useContext(ToastContext);
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
const { navigateTo } = useLibraryRoutes();
const { navigateTo, insideCollection } = useLibraryRoutes();
const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
const removeFromCollection = () => {
removeComponentsMutation.mutateAsync([containerId]).then(() => {
if (sidebarComponentInfo?.id === containerId) {
removeComponentsMutation.mutateAsync([containerKey]).then(() => {
if (sidebarComponentInfo?.id === containerKey) {
// Close sidebar if current component is open
closeLibrarySidebar();
}
@@ -67,13 +64,13 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
});
const showManageCollections = useCallback(() => {
navigateTo({ selectedItemId: containerId });
navigateTo({ selectedItemId: containerKey });
scheduleJumpToCollection();
}, [scheduleJumpToCollection, navigateTo, openUnitInfoSidebar, containerId]);
}, [scheduleJumpToCollection, navigateTo, containerKey]);
const openContainer = useCallback(() => {
navigateTo({ unitId: containerId });
}, [navigateTo, containerId]);
navigateTo({ [`${containerType}Id`]: containerKey });
}, [navigateTo, containerKey]);
return (
<>
@@ -94,7 +91,7 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
<Dropdown.Item onClick={confirmDelete}>
<FormattedMessage {...messages.menuDeleteContainer} />
</Dropdown.Item>
{collectionId && (
{insideCollection && (
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...messages.menuRemoveFromCollection} />
</Dropdown.Item>
@@ -107,7 +104,7 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
<ContainerDeleter
isOpen={isConfirmingDelete}
close={cancelDelete}
containerId={containerId}
containerId={containerKey}
displayName={displayName}
/>
</>
@@ -181,7 +178,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
numChildren,
published,
publishStatus,
usageKey: containerId,
usageKey: containerKey,
content,
} = hit;
@@ -197,26 +194,44 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
showOnlyPublished ? published?.content?.childUsageKeys : content?.childUsageKeys
) ?? [];
const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.UnitInfo
&& sidebarComponentInfo.id === containerId;
const selected = sidebarComponentInfo?.id === containerKey;
const { navigateTo } = useLibraryRoutes();
const selectContainer = useCallback((e?: React.MouseEvent) => {
const doubleClicked = (e?.detail || 0) > 1;
if (!componentPickerMode) {
if (doubleClicked) {
navigateTo({ unitId: containerId });
} else {
navigateTo({ selectedItemId: containerId });
if (componentPickerMode) {
switch (itemType) {
case ContainerType.Unit:
openUnitInfoSidebar(containerKey);
break;
case ContainerType.Section:
// TODO: open section sidebar
break;
case ContainerType.Subsection:
// TODO: open subsection sidebar
break;
default:
break;
}
} else if (!doubleClicked) {
navigateTo({ selectedItemId: containerKey });
} else {
// In component picker mode, we want to open the sidebar
// without changing the URL
openUnitInfoSidebar(containerId);
switch (itemType) {
case ContainerType.Unit:
navigateTo({ unitId: containerKey });
break;
case ContainerType.Section:
navigateTo({ sectionId: containerKey });
break;
case ContainerType.Subsection:
navigateTo({ subsectionId: containerKey });
break;
default:
break;
}
}
}, [containerId, itemType, openUnitInfoSidebar, navigateTo]);
}, [containerKey, itemType, openUnitInfoSidebar, navigateTo]);
return (
<BaseCard
@@ -228,9 +243,13 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
actions={(
<ActionRow>
{componentPickerMode ? (
<AddComponentWidget usageKey={containerId} blockType={itemType} />
<AddComponentWidget usageKey={containerKey} blockType={itemType} />
) : (
<ContainerMenu hit={hit} />
<ContainerMenu
containerKey={containerKey}
containerType={itemType}
displayName={hit.displayName}
/>
)}
</ActionRow>
)}

View File

@@ -0,0 +1,46 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { useContext } from 'react';
import { InplaceTextEditor } from '../../generic/inplace-text-editor';
import { ToastContext } from '../../generic/toast-context';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useContainer, useUpdateContainer } from '../data/apiHooks';
import messages from './messages';
interface EditableTitleProps {
containerId: string;
}
export const ContainerEditableTitle = ({ containerId }: EditableTitleProps) => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
const { data: container } = useContainer(containerId);
const updateMutation = useUpdateContainer(containerId);
const { showToast } = useContext(ToastContext);
const handleSaveDisplayName = async (newDisplayName: string) => {
try {
await updateMutation.mutateAsync({
displayName: newDisplayName,
});
showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
} catch (err) {
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
}
};
// istanbul ignore if: this should never happen
if (!container) {
return null;
}
return (
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={container.displayName}
readOnly={readOnly}
/>
);
};

View File

@@ -33,7 +33,6 @@ const ContainerInfoHeader = () => {
showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
} catch (err) {
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
throw err;
}
};

View File

@@ -0,0 +1,51 @@
import { Button, useToggle } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
import { PickLibraryContentModal } from '../add-content';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
interface FooterActionsProps {
addContentBtnText: string;
addExistingContentBtnText: string;
}
export const FooterActions = ({
addContentBtnText,
addExistingContentBtnText,
}: FooterActionsProps) => {
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const { openAddContentSidebar } = useSidebarContext();
const { readOnly } = useLibraryContext();
return (
<div className="d-flex">
<div className="w-100 mr-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
onClick={openAddContentSidebar}
disabled={readOnly}
block
>
{addContentBtnText}
</Button>
</div>
<div className="w-100 ml-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
onClick={showAddLibraryContentModal}
disabled={readOnly}
block
>
{addExistingContentBtnText}
</Button>
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,70 @@
import { Button } from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import { useCallback } from 'react';
import { ContainerType } from '../../generic/key-utils';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryRoutes } from '../routes';
interface HeaderActionsProps {
containerKey: string;
containerType: ContainerType;
infoBtnText: string;
addContentBtnText: string;
}
export const HeaderActions = ({
containerKey,
containerType,
infoBtnText,
addContentBtnText,
}: HeaderActionsProps) => {
const { readOnly } = useLibraryContext();
const {
closeLibrarySidebar,
sidebarComponentInfo,
openUnitInfoSidebar,
openAddContentSidebar,
} = useSidebarContext();
const { navigateTo } = useLibraryRoutes();
const infoSidebarIsOpen = sidebarComponentInfo?.id === containerKey;
const handleOnClickInfoSidebar = useCallback(() => {
if (infoSidebarIsOpen) {
closeLibrarySidebar();
} else {
switch (containerType) {
case ContainerType.Unit:
openUnitInfoSidebar(containerKey);
break;
/* istanbul ignore next */
default:
break;
}
}
navigateTo({ [`${containerType}Id`]: containerKey });
}, [containerKey, infoSidebarIsOpen, navigateTo]);
return (
<div className="header-actions">
<Button
className="normal-border"
iconBefore={InfoOutline}
variant="outline-primary rounded-0"
onClick={handleOnClickInfoSidebar}
>
{infoBtnText}
</Button>
<Button
className="ml-2"
iconBefore={Add}
variant="primary rounded-0"
disabled={readOnly}
onClick={openAddContentSidebar}
>
{addContentBtnText}
</Button>
</div>
);
};

View File

@@ -1,2 +1,5 @@
export { default as UnitInfo } from './UnitInfo';
export { default as ContainerInfoHeader } from './ContainerInfoHeader';
export { ContainerEditableTitle } from './ContainerEditableTitle';
export { HeaderActions } from './HeaderActions';
export { FooterActions } from './FooterActions';

View File

@@ -6,7 +6,6 @@ import {
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik } from 'formik';
import { useNavigate } from 'react-router';
import * as Yup from 'yup';
import FormikControl from '../../generic/FormikControl';
import { useLibraryContext } from '../common/context/LibraryContext';
@@ -20,14 +19,13 @@ import { useLibraryRoutes } from '../routes';
/** Common modal to create section, subsection or unit in library */
const CreateContainerModal = () => {
const intl = useIntl();
const navigate = useNavigate();
const {
collectionId,
libraryId,
createContainerModalType,
setCreateContainerModalType,
} = useLibraryContext();
const { insideCollection } = useLibraryRoutes();
const { navigateTo, insideCollection } = useLibraryRoutes();
const create = useCreateLibraryContainer(libraryId);
const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId);
const { showToast } = React.useContext(ToastContext);
@@ -89,14 +87,14 @@ const CreateContainerModal = () => {
await updateItemsMutation.mutateAsync([container.id]);
}
// Navigate to the new container
navigate(`/library/${libraryId}/${containerType}/${container.id}`);
navigateTo({ [`${containerType}Id`]: container.id });
showToast(labels.successMsg);
} catch (error) {
showToast(labels.errorMsg);
} finally {
handleClose();
}
}, [containerType, labels, handleClose]);
}, [containerType, labels, handleClose, navigateTo]);
return (
<ModalDialog

View File

@@ -1,7 +1,7 @@
/* istanbul ignore file */
import { camelCaseObject } from '@edx/frontend-platform';
import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
import { getBlockType } from '../../generic/key-utils';
import { ContainerType, getBlockType } from '../../generic/key-utils';
import { createAxiosError } from '../../testUtils';
import contentLibrariesListV2 from '../__mocks__/contentLibrariesListV2';
import downstreamLinkInfo from '../../search-manager/data/__mocks__/downstream-links.json';
@@ -473,28 +473,47 @@ mockGetCollectionMetadata.applyMock = () => {
export async function mockGetContainerMetadata(containerId: string): Promise<api.Container> {
switch (containerId) {
case mockGetContainerMetadata.containerIdError:
case mockGetContainerMetadata.sectionIdError:
case mockGetContainerMetadata.subsectionIdError:
throw createAxiosError({
code: 404,
message: 'Not found.',
path: api.getLibraryContainerApiUrl(containerId),
});
case mockGetContainerMetadata.containerIdLoading:
case mockGetContainerMetadata.sectionIdLoading:
case mockGetContainerMetadata.subsectionIdLoading:
return new Promise(() => { });
case mockGetContainerMetadata.containerIdWithCollections:
return Promise.resolve(mockGetContainerMetadata.containerDataWithCollections);
case mockGetContainerMetadata.sectionId:
case mockGetContainerMetadata.sectionIdEmpty:
return Promise.resolve(mockGetContainerMetadata.sectionData);
case mockGetContainerMetadata.subsectionId:
case mockGetContainerMetadata.subsectionIdEmpty:
return Promise.resolve(mockGetContainerMetadata.subsectionData);
default:
return Promise.resolve(mockGetContainerMetadata.containerData);
}
}
mockGetContainerMetadata.containerId = 'lct:org:lib:unit:test-unit-9a207';
mockGetContainerMetadata.sectionId = 'lct:org:lib:section:test-section-1';
mockGetContainerMetadata.subsectionId = 'lb:org1:Demo_course:subsection:subsection-0';
mockGetContainerMetadata.sectionIdEmpty = 'lct:org:lib:section:test-section-empty';
mockGetContainerMetadata.subsectionIdEmpty = 'lb:org1:Demo_course:subsection:subsection-empty';
mockGetContainerMetadata.containerIdError = 'lct:org:lib:unit:container_error';
mockGetContainerMetadata.sectionIdError = 'lct:org:lib:section:section_error';
mockGetContainerMetadata.subsectionIdError = 'lct:org:lib:section:section_error';
mockGetContainerMetadata.containerIdLoading = 'lct:org:lib:unit:container_loading';
mockGetContainerMetadata.sectionIdLoading = 'lct:org:lib:section:section_loading';
mockGetContainerMetadata.subsectionIdLoading = 'lct:org:lib:subsection:subsection_loading';
mockGetContainerMetadata.containerIdForTags = mockContentTaxonomyTagsData.containerTagsId;
mockGetContainerMetadata.containerIdWithCollections = 'lct:org:lib:unit:container_collections';
mockGetContainerMetadata.containerData = {
id: 'lct:org:lib:unit:test-unit-9a2072',
containerType: 'unit',
containerType: ContainerType.Unit,
displayName: 'Test Unit',
publishedDisplayName: 'Test Unit',
created: '2024-09-19T10:00:00Z',
createdBy: 'test_author',
lastPublished: '2024-09-20T10:00:00Z',
@@ -504,6 +523,21 @@ mockGetContainerMetadata.containerData = {
modified: '2024-09-20T11:00:00Z',
hasUnpublishedChanges: true,
collections: [],
tagsCount: 0,
} satisfies api.Container;
mockGetContainerMetadata.sectionData = {
...mockGetContainerMetadata.containerData,
id: 'lct:org:lib:section:test-section-1',
containerType: ContainerType.Section,
displayName: 'Test section',
publishedDisplayName: 'Test section',
} satisfies api.Container;
mockGetContainerMetadata.subsectionData = {
...mockGetContainerMetadata.containerData,
id: 'lb:org1:Demo_course:subsection:subsection-0',
containerType: ContainerType.Subsection,
displayName: 'Test subsection',
publishedDisplayName: 'Test subsection',
} satisfies api.Container;
mockGetContainerMetadata.containerDataWithCollections = {
...mockGetContainerMetadata.containerData,
@@ -524,6 +558,8 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
let numChildren: number;
switch (containerId) {
case mockGetContainerMetadata.containerId:
case mockGetContainerMetadata.sectionId:
case mockGetContainerMetadata.subsectionId:
numChildren = 3;
break;
case mockGetContainerChildren.fiveChildren:
@@ -536,14 +572,23 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
numChildren = 0;
break;
}
let blockType = 'html';
let name = 'text';
if (containerId.includes('subsection')) {
blockType = 'unit';
name = blockType;
} else if (containerId.includes('section')) {
blockType = 'subsection';
name = blockType;
}
return Promise.resolve(
Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
{
...child,
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
id: `lb:org1:Demo_course:html:text-${idx}`,
displayName: `text block ${idx}`,
publishedDisplayName: `text block published ${idx}`,
id: `lb:org1:Demo_course:${blockType}:${name}-${idx}`,
displayName: `${name} block ${idx}`,
publishedDisplayName: `${name} block published ${idx}`,
}
)),
);

View File

@@ -598,8 +598,9 @@ export async function createLibraryContainer(
export interface Container {
id: string;
containerType: 'unit';
containerType: ContainerType;
displayName: string;
publishedDisplayName: string | null;
lastPublished: string | null;
publishedBy: string | null;
createdBy: string | null;
@@ -609,6 +610,7 @@ export interface Container {
created: string;
modified: string;
collections: CollectionMetadata[];
tagsCount: number;
}
/**
@@ -656,7 +658,7 @@ export async function restoreContainer(containerId: string) {
export async function getLibraryContainerChildren(
containerId: string,
published: boolean = false,
): Promise<LibraryBlockMetadata[]> {
): Promise<LibraryBlockMetadata[] | Container[]> {
const { data } = await getAuthenticatedHttpClient().get(
getLibraryContainerChildrenApiUrl(containerId, published),
);

View File

@@ -29,7 +29,7 @@ import {
useDeleteContainer,
useRestoreContainer,
useContainerChildren,
useAddComponentsToContainer,
useAddItemsToContainer,
useUpdateContainerChildren,
useRemoveContainerChildren,
usePublishContainer,
@@ -265,7 +265,7 @@ describe('library api hooks', () => {
const url = getLibraryContainerChildrenApiUrl(containerId);
axiosMock.onPost(url).reply(200);
const { result } = renderHook(() => useAddComponentsToContainer(containerId), { wrapper });
const { result } = renderHook(() => useAddItemsToContainer(containerId), { wrapper });
await result.current.mutateAsync([componentId]);
expect(axiosMock.history.post[0].url).toEqual(url);

View File

@@ -61,13 +61,16 @@ export const libraryAuthoringQueryKeys = {
'blockTypes',
libraryId,
],
allContainers: (libraryId?: string) => [
...libraryAuthoringQueryKeys.contentLibraryContent(libraryId),
'container',
],
container: (containerId?: string) => {
const baseKey = containerId
? libraryAuthoringQueryKeys.contentLibraryContent(getLibraryId(containerId))
: libraryAuthoringQueryKeys.all;
? libraryAuthoringQueryKeys.allContainers(getLibraryId(containerId))
: [...libraryAuthoringQueryKeys.all, 'container'];
return [
...baseKey,
'container',
containerId,
];
},
@@ -460,7 +463,7 @@ export const useDeleteXBlockAsset = (usageKey: string) => {
/**
* Get the metadata for a collection in a library
*/
export const useCollection = (libraryId: string, collectionId: string) => (
export const useCollection = (libraryId: string, collectionId?: string) => (
useQuery({
enabled: !!libraryId && !!collectionId,
queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId),
@@ -632,6 +635,8 @@ export const useUpdateContainer = (containerId: string) => {
// container list.
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
queryClient.invalidateQueries({ queryKey: containerQueryKey });
// NOTE: We invalidate all container query to update names in children list of containers
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.allContainers(libraryId) });
},
});
};
@@ -693,17 +698,17 @@ export const useContainerChildren = (containerId?: string, published: boolean =
);
/**
* Use this mutation to add components to a container
* Use this mutation to add items to a container
*/
export const useAddComponentsToContainer = (containerId?: string) => {
export const useAddItemsToContainer = (containerId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (componentIds: string[]) => {
mutationFn: async (itemIds: string[]) => {
// istanbul ignore if: this should never happen
if (!containerId) {
return undefined;
}
return api.addComponentsToContainer(containerId, componentIds);
return api.addComponentsToContainer(containerId, itemIds);
},
onSettled: () => {
// istanbul ignore if: this should never happen
@@ -805,17 +810,17 @@ export const usePublishContainer = (containerId: string) => {
};
/**
* Use this mutations to get a list of components from the search index
* Use this mutations to get a list of objects from the search index
*/
export const useComponentsFromSearchIndex = (componentIds: string[]) => {
export const useContentFromSearchIndex = (contentIds: string[]) => {
const { client, indexName } = useContentSearchConnection();
return useContentSearchResults({
client,
indexName,
searchKeywords: '',
extraFilter: [`usage_key IN ["${componentIds.join('","')}"]`],
limit: componentIds.length,
enabled: !!componentIds.length,
extraFilter: [`usage_key IN ["${contentIds.join('","')}"]`],
limit: contentIds.length,
enabled: !!contentIds.length,
skipBlockTypeFetch: true,
});
};

View File

@@ -3,6 +3,7 @@
@import "./generic";
@import "./LibraryAuthoringPage";
@import "./units";
@import "./section-subsections";
.library-cards-grid {
display: grid;

View File

@@ -33,10 +33,10 @@ export const ROUTES = {
COLLECTION: '/collection/:collectionId/:selectedItemId?',
// LibrarySectionPage route:
// * with a selected sectionId and/or an optionally selected subsectionId.
SECTION: '/section/:sectionId/:subsectionId?',
SECTION: '/section/:sectionId/:selectedItemId?',
// LibrarySubsectionPage route:
// * with a selected subsectionId and/or an optionally selected unitId.
SUBSECTION: '/subsection/:subsectionId/:unitId?',
SUBSECTION: '/subsection/:subsectionId/:selectedItemId?',
// LibraryUnitPage route:
// * with a selected unitId and/or an optionally selected componentId.
UNIT: '/unit/:unitId/:selectedItemId?',
@@ -47,8 +47,8 @@ export enum ContentType {
collections = 'collections',
components = 'components',
units = 'units',
subsections = 'subsections',
sections = 'sections',
subsections = 'subsections',
}
export const allLibraryPageTabs: ContentType[] = Object.values(ContentType);
@@ -57,6 +57,8 @@ export type NavigateToData = {
selectedItemId?: string,
collectionId?: string,
contentType?: ContentType,
sectionId?: string,
subsectionId?: string,
unitId?: string,
};
@@ -116,6 +118,8 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
const navigateTo = useCallback(({
selectedItemId,
collectionId,
sectionId,
subsectionId,
unitId,
contentType,
}: NavigateToData = {}) => {
@@ -123,6 +127,8 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
...params,
// Overwrite the params with the provided values.
...((selectedItemId !== undefined) && { selectedItemId }),
...((sectionId !== undefined) && { sectionId }),
...((subsectionId !== undefined) && { subsectionId }),
...((unitId !== undefined) && { unitId }),
...((collectionId !== undefined) && { collectionId }),
};
@@ -134,21 +140,24 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
routeParams.selectedItemId = undefined;
}
// Update unitId/collectionId in library context if is not undefined.
// Update sectionId/subsectionId/unitId/collectionId in library context if is not undefined.
// Ids can be cleared from route by passing in empty string so we need to set it.
if (unitId !== undefined) {
if (unitId !== undefined || sectionId !== undefined || subsectionId !== undefined) {
routeParams.selectedItemId = undefined;
// If we can have a unitId alongside a routeParams.collectionId, it means we are inside a collection
// trying to navigate to a unit, so we want to clear the collectionId to not have ambiquity.
// If we can have a unitId/subsectionId/sectionId alongside a routeParams.collectionId,
// it means we are inside a collection trying to navigate to a unit/section/subsection,
// so we want to clear the collectionId to not have ambiquity.
if (routeParams.collectionId !== undefined) {
routeParams.collectionId = undefined;
}
} else if (collectionId !== undefined) {
routeParams.selectedItemId = undefined;
} else if (contentType) {
// We are navigating to the library home, so we need to clear the unitId and collectionId
// We are navigating to the library home, so we need to clear the sectionId, subsectionId, unitId and collectionId
routeParams.unitId = undefined;
routeParams.sectionId = undefined;
routeParams.subsectionId = undefined;
routeParams.collectionId = undefined;
}
@@ -194,6 +203,10 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
route = ROUTES.HOME;
} else if (routeParams.unitId) {
route = ROUTES.UNIT;
} else if (routeParams.subsectionId) {
route = ROUTES.SUBSECTION;
} else if (routeParams.sectionId) {
route = ROUTES.SECTION;
} else if (routeParams.collectionId) {
route = ROUTES.COLLECTION;
// From here, we will just stay in the current route

View File

@@ -0,0 +1,220 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
useCallback, useContext, useEffect, useState,
} from 'react';
import {
ActionRow, Badge, Icon, Stack,
} from '@openedx/paragon';
import { Description } from '@openedx/paragon/icons';
import DraggableList, { SortableItem } from '../../generic/DraggableList';
import Loading from '../../generic/Loading';
import ErrorAlert from '../../generic/alert-error';
import { useLibraryContext } from '../common/context/LibraryContext';
import {
useContainerChildren,
useUpdateContainer,
useUpdateContainerChildren,
} from '../data/apiHooks';
import { messages, subsectionMessages, sectionMessages } from './messages';
import containerMessages from '../containers/messages';
import { Container } from '../data/api';
import { InplaceTextEditor } from '../../generic/inplace-text-editor';
import { ToastContext } from '../../generic/toast-context';
import TagCount from '../../generic/tag-count';
import { ContainerMenu } from '../components/ContainerCard';
import { useLibraryRoutes } from '../routes';
import { useSidebarContext } from '../common/context/SidebarContext';
interface LibraryContainerChildrenProps {
containerKey: string;
/** set to true if it is rendered as preview */
readOnly?: boolean;
}
interface LibraryContainerMetadataWithUniqueId extends Container {
originalId: string;
}
interface ContainerRowProps extends LibraryContainerChildrenProps {
container: LibraryContainerMetadataWithUniqueId;
}
const ContainerRow = ({ container, readOnly }: ContainerRowProps) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const updateMutation = useUpdateContainer(container.originalId);
const { showOnlyPublished } = useLibraryContext();
const handleSaveDisplayName = async (newDisplayName: string) => {
try {
await updateMutation.mutateAsync({
displayName: newDisplayName,
});
showToast(intl.formatMessage(containerMessages.updateContainerSuccessMsg));
} catch (err) {
showToast(intl.formatMessage(containerMessages.updateContainerErrorMsg));
}
};
return (
<>
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={showOnlyPublished ? (container.publishedDisplayName ?? container.displayName) : container.displayName}
textClassName="font-weight-bold small"
readOnly={readOnly || showOnlyPublished}
/>
<ActionRow.Spacer />
<Stack
direction="horizontal"
gap={3}
// Prevent parent card from being clicked.
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
onClick={(e) => e.stopPropagation()}
>
{!showOnlyPublished && container.hasUnpublishedChanges && (
<Badge
className="px-2 py-1"
variant="warning"
>
<Stack direction="horizontal" gap={1}>
<Icon size="xs" src={Description} />
<FormattedMessage {...messages.draftChipText} />
</Stack>
</Badge>
)}
<TagCount size="sm" count={container.tagsCount} />
<ContainerMenu
containerKey={container.originalId}
containerType={container.containerType}
displayName={container.displayName}
/>
</Stack>
</>
);
};
/** Component to display container children subsections for section and units for subsection */
export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryContainerChildrenProps) => {
const intl = useIntl();
const [orderedChildren, setOrderedChildren] = useState<LibraryContainerMetadataWithUniqueId[]>([]);
const { showOnlyPublished, readOnly: libReadOnly } = useLibraryContext();
const { navigateTo, insideSection, insideSubsection } = useLibraryRoutes();
const { sidebarComponentInfo } = useSidebarContext();
const [activeDraggingId, setActiveDraggingId] = useState<string | null>(null);
const orderMutator = useUpdateContainerChildren(containerKey);
const { showToast } = useContext(ToastContext);
const handleReorder = useCallback(() => async (newOrder?: LibraryContainerMetadataWithUniqueId[]) => {
if (!newOrder) {
return;
}
const childrenKeys = newOrder.map((o) => o.originalId);
try {
await orderMutator.mutateAsync(childrenKeys);
showToast(intl.formatMessage(messages.orderUpdatedMsg));
} catch (e) {
showToast(intl.formatMessage(messages.failedOrderUpdatedMsg));
}
}, [orderMutator]);
const {
data: children,
isLoading,
isError,
error,
} = useContainerChildren(containerKey, showOnlyPublished);
useEffect(() => {
// Create new ids which are unique using index.
// This is required to support multiple components with same id under a container.
const newChildren = children?.map((child, idx) => {
const newChild: LibraryContainerMetadataWithUniqueId = {
...child,
id: `${child.id}----${idx}`,
originalId: child.id,
};
return newChild;
});
return setOrderedChildren(newChildren || []);
}, [children, setOrderedChildren]);
const handleChildClick = useCallback((child: LibraryContainerMetadataWithUniqueId, numberOfClicks: number) => {
const doubleClicked = numberOfClicks > 1;
if (!doubleClicked) {
navigateTo({ selectedItemId: child.originalId });
} else if (insideSection) {
navigateTo({ subsectionId: child.originalId });
} else if (insideSubsection) {
navigateTo({ unitId: child.originalId });
}
}, [navigateTo]);
const getComponentStyle = useCallback((childId: string) => {
const style: { marginBottom: string, borderRadius: string, outline?: string } = {
marginBottom: '1rem',
borderRadius: '8px',
};
if (activeDraggingId === childId) {
style.outline = '2px dashed gray';
}
return style;
}, [activeDraggingId]);
if (isLoading) {
return <Loading />;
}
if (isError) {
// istanbul ignore next
return <ErrorAlert error={error} />;
}
return (
<div className="ml-2 library-container-children">
{children?.length === 0 && (
<h4 className="ml-2">
{insideSection ? (
<FormattedMessage {...sectionMessages.noChildrenText} />
) : (
<FormattedMessage {...subsectionMessages.noChildrenText} />
)}
</h4>
)}
<DraggableList
itemList={orderedChildren}
setState={setOrderedChildren}
updateOrder={handleReorder}
activeId={activeDraggingId}
setActiveId={setActiveDraggingId}
>
{orderedChildren?.map((child) => (
// A container can have multiple instances of the same block
// eslint-disable-next-line react/no-array-index-key
<SortableItem
id={child.id}
key={child.id}
componentStyle={getComponentStyle(child.id)}
actionStyle={{
padding: '0.5rem 1rem',
background: '#FBFAF9',
borderRadius: '8px',
borderLeft: '8px solid #E1DDDB',
}}
isClickable
onClick={(e) => handleChildClick(child, e.detail)}
disabled={readOnly || libReadOnly}
cardClassName={sidebarComponentInfo?.id === child.originalId ? 'selected' : undefined}
actions={(
<ContainerRow
containerKey={containerKey}
container={child}
readOnly={readOnly || libReadOnly}
/>
)}
/>
))}
</DraggableList>
</div>
);
};

View File

@@ -0,0 +1,126 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { Breadcrumb, Container } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useContainer, useContentLibrary } from '../data/apiHooks';
import Loading from '../../generic/Loading';
import NotFoundAlert from '../../generic/NotFoundAlert';
import ErrorAlert from '../../generic/alert-error';
import Header from '../../header';
import SubHeader from '../../generic/sub-header/SubHeader';
import { SubHeaderTitle } from '../LibraryAuthoringPage';
import { messages, sectionMessages } from './messages';
import { LibrarySidebar } from '../library-sidebar';
import { LibraryContainerChildren } from './LibraryContainerChildren';
import { ContainerEditableTitle, FooterActions, HeaderActions } from '../containers';
import { ContainerType } from '../../generic/key-utils';
/** Full library section page */
export const LibrarySectionPage = () => {
const intl = useIntl();
const { libraryId, sectionId } = useLibraryContext();
const {
sidebarComponentInfo,
} = useSidebarContext();
if (!sectionId || !libraryId) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Rendered without sectionId or libraryId URL parameter');
}
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
const {
data: sectionData,
isLoading,
isError,
error,
} = useContainer(sectionId);
// show loading if sectionId or libraryId is not set or section or library data is not fetched from index yet
if (isLibLoading || isLoading) {
return <Loading />;
}
if (!libraryData || !sectionData) {
return <NotFoundAlert />;
}
// istanbul ignore if
if (isError) {
return <ErrorAlert error={error} />;
}
const breadcrumbs = (
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
links={[
{
label: libraryData.title,
to: `/library/${libraryId}`,
},
// Adding empty breadcrumb to add the last `>` spacer.
{
label: '',
to: '',
},
]}
linkAs={Link}
/>
);
return (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet>
<title>
{libraryData.title} | {sectionData.displayName} | {process.env.SITE_NAME}
</title>
</Helmet>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryData.id}
isLibrary
containerProps={{
size: undefined,
}}
/>
<Container className="px-0 mt-4 mb-5 library-authoring-page bg-white">
<div className="px-4 bg-light-200 border-bottom mb-2">
<SubHeader
title={<SubHeaderTitle title={<ContainerEditableTitle containerId={sectionId} />} />}
breadcrumbs={breadcrumbs}
headerActions={(
<HeaderActions
containerKey={sectionId}
containerType={ContainerType.Section}
infoBtnText={intl.formatMessage(sectionMessages.infoButtonText)}
addContentBtnText={intl.formatMessage(sectionMessages.newContentButton)}
/>
)}
hideBorder
/>
</div>
<Container className="px-4 py-4">
<LibraryContainerChildren containerKey={sectionId} />
<FooterActions
addContentBtnText={intl.formatMessage(sectionMessages.addContentButton)}
addExistingContentBtnText={intl.formatMessage(sectionMessages.addExistingContentButton)}
/>
</Container>
</Container>
</div>
{!!sidebarComponentInfo?.type && (
<div
className="library-authoring-sidebar box-shadow-left-1 bg-white"
data-testid="library-sidebar"
>
<LibrarySidebar />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,349 @@
import userEvent from '@testing-library/user-event';
import type MockAdapter from 'axios-mock-adapter';
import { act } from 'react';
import {
initializeMocks,
fireEvent,
render,
screen,
waitFor,
} from '../../testUtils';
import {
getLibraryContainerApiUrl,
getLibraryContainerChildrenApiUrl,
} from '../data/api';
import {
mockContentLibrary,
mockXBlockFields,
mockGetContainerMetadata,
mockGetContainerChildren,
mockLibraryBlockMetadata,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockGetBlockTypes, mockSearchResult } from '../../search-manager/data/api.mock';
import { mockClipboardEmpty } from '../../generic/data/api.mock';
import LibraryLayout from '../LibraryLayout';
import { ToastActionData } from '../../generic/toast-context';
import mockResult from '../__mocks__/subsection-single.json';
import { ContainerType } from '../../generic/key-utils';
const path = '/library/:libraryId/*';
const libraryTitle = mockContentLibrary.libraryData.title;
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
mockClipboardEmpty.applyMock();
mockGetContainerMetadata.applyMock();
mockGetContainerChildren.applyMock();
mockContentSearchConfig.applyMock();
mockGetBlockTypes.applyMock();
mockContentLibrary.applyMock();
mockXBlockFields.applyMock();
mockLibraryBlockMetadata.applyMock();
const searchFilterfn = (requestData: any) => {
const queryFilter = requestData?.queries[0]?.filter?.[1];
const subsectionId = queryFilter?.split('usage_key IN ["')[1].split('"]')[0];
switch (subsectionId) {
case mockGetContainerMetadata.subsectionIdLoading:
return new Promise<any>(() => {});
case mockGetContainerMetadata.subsectionIdError:
return Promise.reject(new Error('Not found'));
default:
return mockResult;
}
};
mockSearchResult(mockResult, searchFilterfn);
const verticalSortableListCollisionDetection = jest.fn();
jest.mock('../../generic/DraggableList/verticalSortableList', () => ({
...jest.requireActual('../../generic/DraggableList/verticalSortableList'),
// Since jsdom (used by jest) does not support getBoundingClientRect function
// which is required for drag-n-drop calculations, we mock closestCorners fn
// from dnd-kit to return collided elements as per the test. This allows us to
// test all drag-n-drop handlers.
verticalSortableListCollisionDetection: () => verticalSortableListCollisionDetection(),
}));
describe('<LibrarySectionPage / LibrarySubsectionPage />', () => {
beforeEach(() => {
({ axiosMock, mockShowToast } = initializeMocks());
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
});
const renderLibrarySectionPage = (
containerId?: string,
libraryId?: string,
cType: ContainerType = ContainerType.Section,
) => {
const libId = libraryId || mockContentLibrary.libraryId;
const defaultId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionId
: mockGetContainerMetadata.subsectionId;
const cId = containerId || defaultId;
render(<LibraryLayout />, {
path,
routerProps: {
initialEntries: [`/library/${libId}/${cType}/${cId}`],
},
});
};
[
ContainerType.Section,
ContainerType.Subsection,
].forEach((cType) => {
const childType = cType === ContainerType.Section
? ContainerType.Subsection
: ContainerType.Unit;
it(`shows the spinner before the query is complete in ${cType} page`, async () => {
// This mock will never return data about the collection (it loads forever):
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionIdLoading
: mockGetContainerMetadata.subsectionIdLoading;
renderLibrarySectionPage(cId, undefined, cType);
const spinner = screen.getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it(`shows an error component if no ${cType} returned`, async () => {
// This mock will simulate incorrect section id
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionIdError
: mockGetContainerMetadata.subsectionIdError;
renderLibrarySectionPage(cId, undefined, cType);
const errorMessage = 'Not found';
expect(await screen.findByRole('alert')).toHaveTextContent(errorMessage);
});
it(`shows ${cType} data`, async () => {
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionId
: mockGetContainerMetadata.subsectionId;
renderLibrarySectionPage(cId, undefined, cType);
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
// Unit title
expect((await screen.findAllByText(`Test ${cType}`))[0]).toBeInTheDocument();
// unit info button
expect(await screen.findByRole('button', { name: new RegExp(`${cType} Info`, 'i') })).toBeInTheDocument();
expect((await screen.findAllByRole('button', { name: 'Drag to reorder' })).length).toEqual(3);
// check all children components are rendered.
expect(await screen.findByText(`${childType} block 0`)).toBeInTheDocument();
expect(await screen.findByText(`${childType} block 1`)).toBeInTheDocument();
expect(await screen.findByText(`${childType} block 2`)).toBeInTheDocument();
});
it(`shows ${cType} data with no children`, async () => {
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionIdEmpty
: mockGetContainerMetadata.subsectionIdEmpty;
renderLibrarySectionPage(cId, undefined, cType);
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
// Unit title
expect((await screen.findAllByText(`Test ${cType}`))[0]).toBeInTheDocument();
// unit info button
expect(await screen.findByRole('button', { name: new RegExp(`${cType} Info`, 'i') })).toBeInTheDocument();
// check all children components are rendered.
expect(await screen.findByText(`This ${cType} is empty`)).toBeInTheDocument();
});
it(`can rename ${cType}`, async () => {
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionId
: mockGetContainerMetadata.subsectionId;
renderLibrarySectionPage(cId, undefined, cType);
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
expect(await screen.findByText(`Test ${cType}`)).toBeInTheDocument();
const editContainerTitleButton = (await screen.findAllByRole(
'button',
{ name: /edit/i },
))[0]; // 0 is the Section/Subsection Title, 1 is the first child on the list
fireEvent.click(editContainerTitleButton);
const url = getLibraryContainerApiUrl(cId);
axiosMock.onPatch(url).reply(200);
expect(await screen.findByRole('textbox', { name: /text input/i })).toBeInTheDocument();
const textBox = await screen.findByRole('textbox', { name: /text input/i });
expect(textBox).toBeInTheDocument();
fireEvent.change(textBox, { target: { value: `New ${cType} Title` } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
await waitFor(() => {
expect(axiosMock.history.patch[0].url).toEqual(url);
});
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ display_name: `New ${cType} Title` }));
expect(textBox).not.toBeInTheDocument();
expect(mockShowToast).toHaveBeenCalledWith('Container updated successfully.');
});
it(`show error if renaming ${cType} fails`, async () => {
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionId
: mockGetContainerMetadata.subsectionId;
renderLibrarySectionPage(cId, undefined, cType);
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
expect(await screen.findByText(`Test ${cType}`)).toBeInTheDocument();
const editContainerTitleButton = (await screen.findAllByRole(
'button',
{ name: /edit/i },
))[0]; // 0 is the Section/subsection Title, 1 is the first child on the list
fireEvent.click(editContainerTitleButton);
const url = getLibraryContainerApiUrl(cId);
axiosMock.onPatch(url).reply(400);
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument();
});
const textBox = screen.getByRole('textbox', { name: /text input/i });
expect(textBox).toBeInTheDocument();
fireEvent.change(textBox, { target: { value: `New ${cType} Title` } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
await waitFor(() => {
expect(axiosMock.history.patch[0].url).toEqual(url);
});
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ display_name: `New ${cType} Title` }));
expect(textBox).not.toBeInTheDocument();
expect(mockShowToast).toHaveBeenCalledWith('Failed to update container.');
});
it(`should rename child by clicking edit icon besides name in ${cType} page`, async () => {
const url = getLibraryContainerApiUrl(`lb:org1:Demo_course:${childType}:${childType}-0`);
axiosMock.onPatch(url).reply(200);
renderLibrarySectionPage(undefined, undefined, cType);
// Wait loading of the component
await screen.findByText(`${childType} block 0`);
const editButton = (await screen.findAllByRole(
'button',
{ name: /edit/i },
))[1]; // 0 is the Section Title, 1 is the first subsection on the list
fireEvent.click(editButton);
screen.debug(editButton);
expect(await screen.findByRole('textbox', { name: /text input/i })).toBeInTheDocument();
const textBox = await screen.findByRole('textbox', { name: /text input/i });
expect(textBox).toBeInTheDocument();
fireEvent.change(textBox, { target: { value: `New ${childType} Title` } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(1);
});
expect(axiosMock.history.patch[0].url).toEqual(url);
expect(axiosMock.history.patch[0].data).toStrictEqual(JSON.stringify({
display_name: `New ${childType} Title`,
}));
expect(textBox).not.toBeInTheDocument();
expect(mockShowToast).toHaveBeenCalledWith('Container updated successfully.');
});
it(`should show error while updating child name in ${cType} page`, async () => {
const url = getLibraryContainerApiUrl(`lb:org1:Demo_course:${childType}:${childType}-0`);
axiosMock.onPatch(url).reply(400);
renderLibrarySectionPage(undefined, undefined, cType);
// Wait loading of the component
await screen.findByText(`${childType} block 0`);
const editButton = screen.getAllByRole(
'button',
{ name: /edit/i },
)[1]; // 0 is the Section Title, 1 is the first subsection on the list
fireEvent.click(editButton);
expect(await screen.findByRole('textbox', { name: /text input/i })).toBeInTheDocument();
const textBox = await screen.findByRole('textbox', { name: /text input/i });
expect(textBox).toBeInTheDocument();
fireEvent.change(textBox, { target: { value: `New ${childType} Title` } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(1);
});
expect(axiosMock.history.patch[0].url).toEqual(url);
expect(axiosMock.history.patch[0].data).toStrictEqual(JSON.stringify({
display_name: `New ${childType} Title`,
}));
expect(textBox).not.toBeInTheDocument();
expect(mockShowToast).toHaveBeenCalledWith('Failed to update container.');
});
it(`should call update order api on dragging children in ${cType} page`, async () => {
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionId
: mockGetContainerMetadata.subsectionId;
renderLibrarySectionPage(cId, undefined, cType);
const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0];
axiosMock
.onPatch(getLibraryContainerChildrenApiUrl(cId))
.reply(200);
verticalSortableListCollisionDetection.mockReturnValue([{
id: `lb:org1:Demo_course:${childType}:${childType}-1----1`,
}]);
await act(async () => {
fireEvent.keyDown(firstDragHandle, { code: 'Space' });
});
setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' }));
await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Order updated'));
});
it(`should cancel update order api on cancelling dragging component in ${cType} page`, async () => {
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionId
: mockGetContainerMetadata.subsectionId;
renderLibrarySectionPage(cId, undefined, cType);
const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0];
axiosMock
.onPatch(getLibraryContainerChildrenApiUrl(cId))
.reply(200);
verticalSortableListCollisionDetection.mockReturnValue([{ id: `lb:org1:Demo_course:${childType}:${childType}-1----1` }]);
await act(async () => {
fireEvent.keyDown(firstDragHandle, { code: 'Space' });
});
setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Escape' }));
await waitFor(() => expect(mockShowToast).not.toHaveBeenLastCalledWith('Order updated'));
});
it(`should show toast error message on update order failure in ${cType} page`, async () => {
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionId
: mockGetContainerMetadata.subsectionId;
renderLibrarySectionPage(cId, undefined, cType);
const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0];
axiosMock
.onPatch(getLibraryContainerChildrenApiUrl(cId))
.reply(500);
verticalSortableListCollisionDetection.mockReturnValue([{ id: `lb:org1:Demo_course:${childType}:${childType}-1----1` }]);
await act(async () => {
fireEvent.keyDown(firstDragHandle, { code: 'Space' });
});
setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' }));
await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Failed to update children order'));
});
it(`should open ${childType} page on double click`, async () => {
renderLibrarySectionPage(undefined, undefined, cType);
const subsection = await screen.findByText(`${childType} block 0`);
// trigger double click
userEvent.click(subsection.parentElement!, undefined, { clickCount: 2 });
expect((await screen.findAllByText(new RegExp(`Test ${childType}`, 'i')))[0]).toBeInTheDocument();
expect(await screen.findByRole('button', { name: new RegExp(`${childType} Info`, 'i') })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,177 @@
import { ReactNode, useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import {
Breadcrumb, Container, MenuItem, SelectMenu,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useContentFromSearchIndex, useContentLibrary } from '../data/apiHooks';
import Loading from '../../generic/Loading';
import NotFoundAlert from '../../generic/NotFoundAlert';
import ErrorAlert from '../../generic/alert-error';
import Header from '../../header';
import SubHeader from '../../generic/sub-header/SubHeader';
import { SubHeaderTitle } from '../LibraryAuthoringPage';
import { messages, subsectionMessages } from './messages';
import { LibrarySidebar } from '../library-sidebar';
import { LibraryContainerChildren } from './LibraryContainerChildren';
import { ContainerEditableTitle, FooterActions, HeaderActions } from '../containers';
import { ContainerType } from '../../generic/key-utils';
import { ContainerHit } from '../../search-manager';
interface OverflowLinksProps {
to: string | string[];
children: ReactNode | ReactNode[];
}
const OverflowLinks = ({ children, to }: OverflowLinksProps) => {
if (typeof to === 'string') {
return (
<Link className="link-muted" to={to}>
{children}
</Link>
);
}
// to is string[] that should be converted to overflow menu
const items = to?.map((link, index) => (
<MenuItem key={link} to={link} as={Link}>
{children?.[index]}
</MenuItem>
));
return (
<SelectMenu
className="breadcrumb-menu"
variant="link"
defaultMessage={`${items.length} Sections`}
>
{items}
</SelectMenu>
);
};
/** Full library subsection page */
export const LibrarySubsectionPage = () => {
const intl = useIntl();
const { libraryId, subsectionId } = useLibraryContext();
const {
sidebarComponentInfo,
} = useSidebarContext();
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
// fetch subsectionData from index as it includes its parent sections as well.
const {
hits, isLoading, isError, error,
} = useContentFromSearchIndex(subsectionId ? [subsectionId] : []);
const subsectionData = (hits as ContainerHit[])?.[0];
const breadcrumbs = useMemo(() => {
const links: Array<{ label: string | string[], to: string | string[] }> = [
{
label: libraryData?.title || '',
to: `/library/${libraryId}`,
},
];
const sectionLength = subsectionData?.sections?.displayName?.length || 0;
if (sectionLength === 1) {
links.push({
label: subsectionData.sections?.displayName?.[0] || '',
to: `/library/${libraryId}/section/${subsectionData?.sections?.key?.[0]}`,
});
} else if (sectionLength > 1) {
// Add all sections as a single object containing list of links
// This is converted to overflow menu by OverflowLinks component
links.push({
label: subsectionData?.sections?.displayName || '',
to: subsectionData?.sections?.key?.map((link) => `/library/${libraryId}/section/${link}`) || '',
});
} else {
// Adding empty breadcrumb to add the last `>` spacer.
links.push({
label: '',
to: '',
});
}
return (
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
links={links}
linkAs={OverflowLinks}
/>
);
}, [libraryData, subsectionData, libraryId]);
if (!subsectionId || !libraryId) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Rendered without subsectionId or libraryId URL parameter');
}
// Only show loading if section or library data is not fetched from index yet
if (isLibLoading || isLoading) {
return <Loading />;
}
if (!libraryData || !subsectionData) {
return <NotFoundAlert />;
}
// istanbul ignore if
if (isError) {
return <ErrorAlert error={error} />;
}
return (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet>
<title>
{libraryData.title} | {subsectionData.displayName} | {process.env.SITE_NAME}
</title>
</Helmet>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryData.id}
isLibrary
containerProps={{
size: undefined,
}}
/>
<Container className="px-0 mt-4 mb-5 library-authoring-page bg-white">
<div className="px-4 bg-light-200 border-bottom mb-2">
<SubHeader
title={<SubHeaderTitle title={<ContainerEditableTitle containerId={subsectionId} />} />}
breadcrumbs={breadcrumbs}
headerActions={(
<HeaderActions
containerKey={subsectionId}
containerType={ContainerType.Subsection}
infoBtnText={intl.formatMessage(subsectionMessages.infoButtonText)}
addContentBtnText={intl.formatMessage(subsectionMessages.newContentButton)}
/>
)}
hideBorder
/>
</div>
<Container className="px-4 py-4">
<LibraryContainerChildren containerKey={subsectionId} />
<FooterActions
addContentBtnText={intl.formatMessage(subsectionMessages.addContentButton)}
addExistingContentBtnText={intl.formatMessage(subsectionMessages.addExistingContentButton)}
/>
</Container>
</Container>
</div>
{!!sidebarComponentInfo?.type && (
<div
className="library-authoring-sidebar box-shadow-left-1 bg-white"
data-testid="library-sidebar"
>
<LibrarySidebar />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,38 @@
.library-container-children {
.pgn__card {
border-radius: 8px;
padding: 0;
margin-bottom: 1rem;
}
.pgn__card.clickable {
box-shadow: none;
&::before {
border: none !important; // Remove default focus
}
&.selected:not(:focus) {
outline: 2px $gray-700 solid;
}
&.selected:focus {
outline: 3px $gray-700 solid;
}
&:not(.selected):focus {
outline: 1px $gray-200 solid;
outline-offset: 2px;
}
}
.pgn__card.clickable:hover {
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .15), 0 .125rem .5rem rgb(0 0 0 / .15);
}
}
.breadcrumb-menu {
button {
padding: 0;
}
}

View File

@@ -0,0 +1,2 @@
export { LibrarySectionPage } from './LibrarySectionPage';
export { LibrarySubsectionPage } from './LibrarySubsectionPage';

View File

@@ -0,0 +1,80 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
export const messages = defineMessages({
breadcrumbsAriaLabel: {
id: 'course-authoring.library-authoring.section-page.breadcrumbs.label.text',
defaultMessage: 'Navigation breadcrumbs',
description: 'Aria label for navigation breadcrumbs',
},
orderUpdatedMsg: {
id: 'course-authoring.library-authoring.container-component.order-updated-msg.text',
defaultMessage: 'Order updated',
description: 'Toast message displayed when children items are successfully reordered in a container',
},
failedOrderUpdatedMsg: {
id: 'course-authoring.library-authoring.container-component.failed-order-updated-msg.text',
defaultMessage: 'Failed to update children order',
description: 'Toast message displayed when reordering of children items in container fails',
},
draftChipText: {
id: 'course-authoring.library-authoring.container-component.draft-chip.text',
defaultMessage: 'Draft',
description: 'Chip in children in section and subsection page that is shown when children has unpublished changes',
},
});
export const sectionMessages = defineMessages({
infoButtonText: {
id: 'course-authoring.library-authoring.section-header.buttons.info',
defaultMessage: 'Section Info',
description: 'Button text to section sidebar from section page',
},
addContentButton: {
id: 'course-authoring.library-authoring.section-header.buttons.add-subsection',
defaultMessage: 'Add New Subsection',
description: 'Text of button to add subsection to section',
},
addExistingContentButton: {
id: 'course-authoring.library-authoring.section-header.buttons.add-existing-subsection',
defaultMessage: 'Add Existing Subsection',
description: 'Text of button to add existing content to section',
},
newContentButton: {
id: 'course-authoring.library-authoring.section-header.buttons.add-new-subsection',
defaultMessage: 'Add Subsection',
description: 'Text of button to add new subsection to section in header',
},
noChildrenText: {
id: 'course-authoring.library-authoring.section.no-children.text',
defaultMessage: 'This section is empty',
description: 'Message to display when section has not children',
},
});
export const subsectionMessages = defineMessages({
infoButtonText: {
id: 'course-authoring.library-authoring.subsection-header.buttons.info',
defaultMessage: 'Subsection Info',
description: 'Button text to subsection sidebar from subsection page',
},
addContentButton: {
id: 'course-authoring.library-authoring.subsection-header.buttons.add-subsection',
defaultMessage: 'Add New Unit',
description: 'Text of button to add subsection to subsection',
},
addExistingContentButton: {
id: 'course-authoring.library-authoring.subsection-header.buttons.add-existing-subsection',
defaultMessage: 'Add Existing Unit',
description: 'Text of button to add existing content to subsection',
},
newContentButton: {
id: 'course-authoring.library-authoring.subsection-header.buttons.add-new-subsection',
defaultMessage: 'Add Unit',
description: 'Text of button to add new subsection to subsection in header',
},
noChildrenText: {
id: 'course-authoring.library-authoring.subsection.no-children.text',
defaultMessage: 'This subsection is empty',
description: 'Message to display when subsection has not children',
},
});

View File

@@ -1,8 +1,8 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Badge, Button, Icon, Stack, useToggle,
ActionRow, Badge, Icon, Stack,
} from '@openedx/paragon';
import { Add, Description } from '@openedx/paragon/icons';
import { Description } from '@openedx/paragon/icons';
import classNames from 'classnames';
import {
useCallback, useContext, useEffect, useState,
@@ -18,7 +18,6 @@ import { InplaceTextEditor } from '../../generic/inplace-text-editor';
import Loading from '../../generic/Loading';
import TagCount from '../../generic/tag-count';
import { useLibraryContext } from '../common/context/LibraryContext';
import { PickLibraryContentModal } from '../add-content';
import ComponentMenu from '../components';
import { LibraryBlockMetadata } from '../data/api';
import {
@@ -27,9 +26,9 @@ import {
useUpdateXBlockFields,
} from '../data/apiHooks';
import { LibraryBlock } from '../LibraryBlock';
import { useLibraryRoutes, ContentType } from '../routes';
import { useLibraryRoutes } from '../routes';
import messages from './messages';
import { SidebarActions, SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { ToastContext } from '../../generic/toast-context';
import { canEditComponent } from '../components/ComponentEditorModal';
import { useRunOnNextRender } from '../../utils';
@@ -73,7 +72,6 @@ const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => {
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
} catch (err) {
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
throw err;
}
};
@@ -103,7 +101,7 @@ const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => {
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={showOnlyPublished ? (block.publishedDisplayName ?? block.displayName) : block.displayName}
readOnly={readOnly}
readOnly={readOnly || showOnlyPublished}
/>
</Stack>
<ActionRow.Spacer />
@@ -173,9 +171,6 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
return {};
}, [isDragging, block]);
const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.ComponentInfo
&& sidebarComponentInfo?.id === block.originalId;
return (
<IframeProvider>
<SortableItem
@@ -189,9 +184,9 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
borderBottom: 'solid 1px #E1DDDB',
}}
isClickable={!readOnly}
onClick={!readOnly ? (e: { detail: number; }) => handleComponentSelection(e.detail) : undefined}
onClick={!readOnly ? (e) => handleComponentSelection(e.detail) : undefined}
disabled={readOnly}
cardClassName={selected ? 'selected' : undefined}
cardClassName={sidebarComponentInfo?.id === block.originalId ? 'selected' : undefined}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
@@ -224,7 +219,6 @@ interface LibraryUnitBlocksProps {
export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: LibraryUnitBlocksProps) => {
const intl = useIntl();
const [orderedBlocks, setOrderedBlocks] = useState<LibraryBlockMetadataWithUniqueId[]>([]);
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [hidePreviewFor, setHidePreviewFor] = useState<string | null>(null);
const { showToast } = useContext(ToastContext);
@@ -233,8 +227,6 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
const readOnly = componentReadOnly || libraryReadOnly;
const { openAddContentSidebar } = useSidebarContext();
const orderMutator = useUpdateContainerChildren(unitId);
const {
data: blocks,
@@ -300,40 +292,6 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
/>
))}
</DraggableList>
{!readOnly && (
<div className="d-flex">
<div className="w-100 mr-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
disabled={readOnly}
onClick={openAddContentSidebar}
block
>
{intl.formatMessage(messages.newContentButton)}
</Button>
</div>
<div className="w-100 ml-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
disabled={readOnly}
onClick={showAddLibraryContentModal}
block
>
{intl.formatMessage(messages.addExistingContentButton)}
</Button>
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
extraFilter={['NOT block_type = "unit"', 'NOT type = "collection"']}
visibleTabs={[ContentType.components]}
/>
</div>
</div>
)}
</div>
);
};

View File

@@ -187,6 +187,7 @@ describe('<LibraryUnitPage />', () => {
it('should open and close component sidebar on component selection', async () => {
renderLibraryUnitPage();
expect((await screen.findAllByText('Test Unit')).length).toBeGreaterThan(1);
const component = await screen.findByText('text block 0');
// Card is 3 levels up the component name div
userEvent.click(component.parentElement!.parentElement!.parentElement!);

View File

@@ -1,11 +1,9 @@
import { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Breadcrumb,
Button,
Container,
} from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import { useCallback, useContext, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
@@ -13,116 +11,18 @@ import Loading from '../../generic/Loading';
import NotFoundAlert from '../../generic/NotFoundAlert';
import SubHeader from '../../generic/sub-header/SubHeader';
import ErrorAlert from '../../generic/alert-error';
import { InplaceTextEditor } from '../../generic/inplace-text-editor';
import { ToastContext } from '../../generic/toast-context';
import Header from '../../header';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import {
COLLECTION_INFO_TABS, COMPONENT_INFO_TABS, SidebarBodyComponentId, UNIT_INFO_TABS, useSidebarContext,
COLLECTION_INFO_TABS, COMPONENT_INFO_TABS, UNIT_INFO_TABS, useSidebarContext,
} from '../common/context/SidebarContext';
import { useContainer, useUpdateContainer, useContentLibrary } from '../data/apiHooks';
import { useContainer, useContentLibrary } from '../data/apiHooks';
import { LibrarySidebar } from '../library-sidebar';
import { SubHeaderTitle } from '../LibraryAuthoringPage';
import { useLibraryRoutes } from '../routes';
import { LibraryUnitBlocks } from './LibraryUnitBlocks';
import messages from './messages';
interface EditableTitleProps {
unitId: string;
}
const EditableTitle = ({ unitId }: EditableTitleProps) => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
const { data: container } = useContainer(unitId);
const updateMutation = useUpdateContainer(unitId);
const { showToast } = useContext(ToastContext);
const handleSaveDisplayName = async (newDisplayName: string) => {
try {
await updateMutation.mutateAsync({
displayName: newDisplayName,
});
showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
} catch (err) {
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
throw err;
}
};
// istanbul ignore if: this should never happen
if (!container) {
return null;
}
return (
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={container.displayName}
readOnly={readOnly}
/>
);
};
const HeaderActions = () => {
const intl = useIntl();
const { componentPickerMode } = useComponentPickerContext();
const { unitId, readOnly } = useLibraryContext();
const {
openAddContentSidebar,
closeLibrarySidebar,
openUnitInfoSidebar,
sidebarComponentInfo,
} = useSidebarContext();
const { navigateTo } = useLibraryRoutes();
// istanbul ignore if: this should never happen
if (!unitId) {
throw new Error('it should not be possible to render HeaderActions without a unitId');
}
const infoSidebarIsOpen = sidebarComponentInfo?.type === SidebarBodyComponentId.UnitInfo
&& sidebarComponentInfo?.id === unitId;
const handleOnClickInfoSidebar = useCallback(() => {
if (infoSidebarIsOpen) {
closeLibrarySidebar();
} else {
openUnitInfoSidebar(unitId);
}
if (!componentPickerMode) {
navigateTo({ unitId });
}
}, [unitId, infoSidebarIsOpen]);
return (
<div className="header-actions">
<Button
className="normal-border"
iconBefore={InfoOutline}
variant="outline-primary rounded-0"
onClick={handleOnClickInfoSidebar}
>
{intl.formatMessage(messages.infoButtonText)}
</Button>
<Button
className="ml-2"
iconBefore={Add}
variant="primary rounded-0"
disabled={readOnly}
onClick={openAddContentSidebar}
>
{intl.formatMessage(messages.addContentButton)}
</Button>
</div>
);
};
import { ContainerEditableTitle, FooterActions, HeaderActions } from '../containers';
import { ContainerType } from '../../generic/key-utils';
export const LibraryUnitPage = () => {
const intl = useIntl();
@@ -160,11 +60,6 @@ export const LibraryUnitPage = () => {
};
}, [setDefaultTab, setHiddenTabs]);
if (!unitId || !libraryId) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Rendered without unitId or libraryId URL parameter');
}
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
const {
data: unitData,
@@ -173,6 +68,11 @@ export const LibraryUnitPage = () => {
error,
} = useContainer(unitId);
if (!unitId || !libraryId) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Rendered without unitId or libraryId URL parameter');
}
// Only show loading if unit or library data is not fetched from index yet
if (isLibLoading || isLoading) {
return <Loading />;
@@ -222,14 +122,25 @@ export const LibraryUnitPage = () => {
<Container className="px-0 mt-4 mb-5 library-authoring-page bg-white">
<div className="px-4 bg-light-200 border-bottom mb-2">
<SubHeader
title={<SubHeaderTitle title={<EditableTitle unitId={unitId} />} />}
headerActions={<HeaderActions />}
title={<SubHeaderTitle title={<ContainerEditableTitle containerId={unitId} />} />}
headerActions={(
<HeaderActions
containerKey={unitId}
containerType={ContainerType.Unit}
infoBtnText={intl.formatMessage(messages.infoButtonText)}
addContentBtnText={intl.formatMessage(messages.addContentButton)}
/>
)}
breadcrumbs={breadcrumbs}
hideBorder
/>
</div>
<Container className="px-4 py-4">
<LibraryUnitBlocks unitId={unitId} />
<FooterActions
addContentBtnText={intl.formatMessage(messages.newContentButton)}
addExistingContentBtnText={intl.formatMessage(messages.addExistingContentButton)}
/>
</Container>
</Container>
</div>

View File

@@ -4,7 +4,6 @@
padding: 0;
margin-bottom: 1rem;
border: solid 1px $light-500;
}
.pgn__card.clickable {

View File

@@ -41,16 +41,6 @@ const messages = defineMessages({
defaultMessage: 'There was an error updating the component.',
description: 'Message when there is an error when updating the component',
},
updateContainerSuccessMsg: {
id: 'course-authoring.library-authoring.update-container-success-msg',
defaultMessage: 'Container updated successfully.',
description: 'Message displayed when container is updated successfully',
},
updateContainerErrorMsg: {
id: 'course-authoring.library-authoring.update-container-error-msg',
defaultMessage: 'Failed to update container.',
description: 'Message displayed when container update fails',
},
orderUpdatedMsg: {
id: 'course-authoring.library-authoring.unit-component.order-updated-msg.text',
defaultMessage: 'Order updated',

View File

@@ -26,7 +26,10 @@ mockContentSearchConfig.applyMock = () => (
* For a given test suite, this mock will stay in effect until you call it with
* a different mock response, or you call `fetchMock.mockReset()`
*/
export function mockSearchResult(mockResponse: MultiSearchResponse) {
export function mockSearchResult(
mockResponse: MultiSearchResponse,
filterFn?: (requestData: any) => MultiSearchResponse,
) {
fetchMock.post(mockContentSearchConfig.multisearchEndpointUrl, (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[0]?.q ?? '';
@@ -38,7 +41,7 @@ export function mockSearchResult(mockResponse: MultiSearchResponse) {
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockResponse.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return newMockResponse;
return filterFn?.(requestData) || newMockResponse;
}, { overwriteRoutes: true });
}

View File

@@ -3,6 +3,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type {
Filter, MeiliSearch, MultiSearchQuery,
} from 'meilisearch';
import { ContainerType } from '../../generic/key-utils';
export const getContentSearchConfigUrl = () => new URL(
'api/content_search/v2/studio/',
@@ -181,12 +182,14 @@ interface ContainerHitContent {
}
export interface ContainerHit extends BaseContentHit {
type: 'library_container';
blockType: 'unit'; // This should be expanded to include other container types
blockType: ContainerType; // This should be expanded to include other container types
numChildren?: number;
published?: ContentPublishedData;
publishStatus: PublishStatus;
formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, };
content?: ContainerHitContent;
sections?: { displayName?: string[], key?: string[] };
subsections?: { displayName?: string[], key?: string[] };
}
export type HitType = ContentHit | CollectionHit | ContainerHit;