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:
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
148
src/library-authoring/__mocks__/subsection-single.json
Normal file
148
src/library-authoring/__mocks__/subsection-single.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -34,7 +34,6 @@ const CollectionInfoHeader = () => {
|
||||
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
|
||||
} catch (err) {
|
||||
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -36,7 +36,6 @@ const ComponentInfoHeader = () => {
|
||||
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
|
||||
} catch (err) {
|
||||
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
46
src/library-authoring/containers/ContainerEditableTitle.tsx
Normal file
46
src/library-authoring/containers/ContainerEditableTitle.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -33,7 +33,6 @@ const ContainerInfoHeader = () => {
|
||||
showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
|
||||
} catch (err) {
|
||||
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
51
src/library-authoring/containers/FooterActions.tsx
Normal file
51
src/library-authoring/containers/FooterActions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
70
src/library-authoring/containers/HeaderActions.tsx
Normal file
70
src/library-authoring/containers/HeaderActions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
)),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import "./generic";
|
||||
@import "./LibraryAuthoringPage";
|
||||
@import "./units";
|
||||
@import "./section-subsections";
|
||||
|
||||
.library-cards-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
126
src/library-authoring/section-subsections/LibrarySectionPage.tsx
Normal file
126
src/library-authoring/section-subsections/LibrarySectionPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
38
src/library-authoring/section-subsections/index.scss
Normal file
38
src/library-authoring/section-subsections/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
2
src/library-authoring/section-subsections/index.tsx
Normal file
2
src/library-authoring/section-subsections/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LibrarySectionPage } from './LibrarySectionPage';
|
||||
export { LibrarySubsectionPage } from './LibrarySubsectionPage';
|
||||
80
src/library-authoring/section-subsections/messages.ts
Normal file
80
src/library-authoring/section-subsections/messages.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
border: solid 1px $light-500;
|
||||
|
||||
}
|
||||
|
||||
.pgn__card.clickable {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user