feat: add collections support for containers [FC-0083] (#1797)

Adds support to add Units to Collections.
This commit is contained in:
Rômulo Penido
2025-04-15 15:13:12 -03:00
committed by GitHub
parent 87695ae636
commit aa8a5bfba4
35 changed files with 636 additions and 310 deletions

View File

@@ -195,6 +195,7 @@ const AddComponent = ({
>
<ComponentPicker
showOnlyPublished
extraFilter={['NOT block_type = "unit"']}
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
onComponentSelected={handleLibraryV2Selection}
onChangeComponentSelection={setSelectedComponents}

View File

@@ -66,8 +66,14 @@ const App = () => {
<Route path="/libraries-v1" element={<StudioHome />} />
<Route path="/library/create" element={<CreateLibrary />} />
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
<Route path="/component-picker" element={<ComponentPicker />} />
<Route path="/component-picker/multiple" element={<ComponentPicker componentPickerMode="multiple" />} />
<Route
path="/component-picker"
element={<ComponentPicker extraFilter={['NOT block_type = "unit"']} />}
/>
<Route
path="/component-picker/multiple"
element={<ComponentPicker componentPickerMode="multiple" extraFilter={['NOT block_type = "unit"']} />}
/>
<Route path="/legacy/preview-changes/:usageKey" element={<PreviewChangesEmbed />} />
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />

View File

@@ -392,7 +392,7 @@ describe('<LibraryAuthoringPage />', () => {
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
it('should open component sidebar, showing manage tab on clicking add to collection menu item', async () => {
it('should open component sidebar, showing manage tab on clicking add to collection menu item (component)', async () => {
const mockResult0 = { ...mockResult }.results[0].hits[0];
const displayName = 'Introduction to Testing';
expect(mockResult0.display_name).toStrictEqual(displayName);
@@ -417,6 +417,29 @@ describe('<LibraryAuthoringPage />', () => {
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
it('should open component sidebar, showing manage tab on clicking add to collection menu item (unit)', async () => {
const displayName = 'Test Unit';
await renderLibraryPage();
waitFor(() => expect(screen.getAllByTestId('container-card-menu-toggle').length).toBeGreaterThan(0));
// Open menu
fireEvent.click((await screen.findAllByTestId('container-card-menu-toggle'))[0]);
// Click add to collection
fireEvent.click(screen.getByRole('button', { name: 'Add to collection' }));
const sidebar = screen.getByTestId('library-sidebar');
const { getByRole, queryByText } = within(sidebar);
await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument());
expect(getByRole('tab', { selected: true })).toHaveTextContent('Organize');
const closeButton = getByRole('button', { name: /close/i });
fireEvent.click(closeButton);
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
it('should open and close the collection sidebar', async () => {
await renderLibraryPage();
@@ -732,7 +755,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(cancelButton);
expect(unitModalHeading).not.toBeInTheDocument();
// Open new unit modal again and create a collection
// Open new unit modal again and create a unit
fireEvent.click(newUnitButton);
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
@@ -800,7 +823,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection Modal
// Open New Unit Modal
const sidebar = screen.getByTestId('library-sidebar');
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
fireEvent.click(newUnitButton);

View File

@@ -141,6 +141,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
libraryData,
isLoadingLibraryData,
showOnlyPublished,
extraFilter: contextExtraFilter,
componentId,
collectionId,
unitId,
@@ -223,6 +224,10 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
extraFilter.push('last_published IS NOT NULL');
}
if (contextExtraFilter) {
extraFilter.push(...contextExtraFilter);
}
const activeTypeFilters = {
components: 'type = "library_block"',
collections: 'type = "collection"',

View File

@@ -218,8 +218,45 @@
"org": "OpenedX",
"access_id": 16,
"num_children": 1
},
{
"display_name": "Test Unit",
"block_id": "test-unit-9284e2",
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
"type": "library_container",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1742221203.895054,
"modified": 1742221203.895054,
"usage_key": "lct:Axim:TEST:unit:test-unit-9284e2",
"block_type": "unit",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15,
"num_children": 0,
"_formatted": {
"display_name": "Test Unit",
"block_id": "test-unit-9284e2",
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
"type": "library_container",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": "1742221203.895054",
"modified": "1742221203.895054",
"usage_key": "lct:Axim:TEST:unit:test-unit-9284e2",
"block_type": "unit",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": "15",
"num_children": "0"
}
}
],
"query": "",
"processingTimeMs": 1,

View File

@@ -12,8 +12,12 @@ import {
mockXBlockFields,
} from '../data/api.mocks';
import {
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
getXBlockFieldsApiUrl, getLibraryContainerChildrenApiUrl,
getContentLibraryApiUrl,
getCreateLibraryBlockUrl,
getLibraryCollectionItemsApiUrl,
getLibraryContainerChildrenApiUrl,
getLibraryPasteClipboardUrl,
getXBlockFieldsApiUrl,
} from '../data/api';
import { mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
@@ -151,7 +155,7 @@ describe('<AddContent />', () => {
const url = getCreateLibraryBlockUrl(libraryId);
const usageKey = mockXBlockFields.usageKeyNewHtml;
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);
@@ -209,7 +213,7 @@ describe('<AddContent />', () => {
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
const collectionId = 'some-collection-id';
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);
@@ -234,7 +238,7 @@ describe('<AddContent />', () => {
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
const collectionId = 'some-collection-id';
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);

View File

@@ -21,8 +21,8 @@ import { getCanEdit } from '../../course-unit/data/selectors';
import {
useCreateLibraryBlock,
useLibraryPasteClipboard,
useAddComponentsToCollection,
useBlockTypesMetadata,
useAddItemsToCollection,
useAddComponentsToContainer,
} from '../data/apiHooks';
import { useLibraryContext } from '../common/context/LibraryContext';
@@ -207,8 +207,8 @@ const AddContent = () => {
openComponentEditor,
unitId,
} = useLibraryContext();
const addComponentsToCollectionMutation = useAddComponentsToCollection(libraryId, collectionId);
const addComponentsToContainerMutation = useAddComponentsToContainer(libraryId, unitId);
const addComponentsToCollectionMutation = useAddItemsToCollection(libraryId, collectionId);
const addComponentsToContainerMutation = useAddComponentsToContainer(unitId);
const createBlockMutation = useCreateLibraryBlock();
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);
@@ -286,14 +286,14 @@ const AddContent = () => {
contentTypes.push(pasteButton);
}
const linkComponent = (usageKey: string) => {
const linkComponent = (opaqueKey: string) => {
if (collectionId) {
addComponentsToCollectionMutation.mutateAsync([usageKey]).catch(() => {
addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
});
}
if (unitId) {
addComponentsToContainerMutation.mutateAsync([usageKey]).catch(() => {
addComponentsToContainerMutation.mutateAsync([opaqueKey]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
});
}

View File

@@ -49,8 +49,8 @@ describe('<PickLibraryContentModal />', () => {
});
it('can pick components from the modal', async () => {
const mockAddComponentsToCollection = jest.fn();
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
const mockAddItemsToCollection = jest.fn();
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
render();
@@ -67,7 +67,7 @@ describe('<PickLibraryContentModal />', () => {
fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
await waitFor(() => {
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
@@ -78,8 +78,8 @@ describe('<PickLibraryContentModal />', () => {
});
it('show error when api call fails', async () => {
const mockAddComponentsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components'));
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
const mockAddItemsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components'));
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
render();
// Wait for the content library to load
@@ -95,7 +95,7 @@ describe('<PickLibraryContentModal />', () => {
fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
await waitFor(() => {
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],

View File

@@ -5,7 +5,7 @@ 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 { useAddComponentsToCollection } from '../data/apiHooks';
import { useAddItemsToCollection } from '../data/apiHooks';
import messages from './messages';
interface PickLibraryContentModalFooterProps {
@@ -51,7 +51,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
throw new Error('libraryId and componentPicker are required');
}
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
const updateComponentsMutation = useAddItemsToCollection(libraryId, collectionId);
const { showToast } = useContext(ToastContext);

View File

@@ -15,12 +15,13 @@ import {
mockContentLibrary,
mockXBlockFields,
mockGetCollectionMetadata,
mockGetContainerMetadata,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
import { mockClipboardEmpty } from '../../generic/data/api.mock';
import { LibraryLayout } from '..';
import { ContentTagsDrawer } from '../../content-tags-drawer';
import { getLibraryCollectionComponentApiUrl } from '../data/api';
import { getLibraryCollectionItemsApiUrl } from '../data/api';
let axiosMock: MockAdapter;
let mockShowToast;
@@ -31,6 +32,7 @@ mockContentSearchConfig.applyMock();
mockGetBlockTypes.applyMock();
mockContentLibrary.applyMock();
mockXBlockFields.applyMock();
mockGetContainerMetadata.applyMock();
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
const path = '/library/:libraryId/*';
@@ -350,7 +352,7 @@ describe('<LibraryCollectionPage />', () => {
});
it('should remove component from collection and hides sidebar', async () => {
const url = getLibraryCollectionComponentApiUrl(
const url = getLibraryCollectionItemsApiUrl(
mockContentLibrary.libraryId,
mockCollection.collectionId,
);
@@ -369,8 +371,38 @@ describe('<LibraryCollectionPage />', () => {
fireEvent.click(await screen.findByText('Remove from collection'));
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
expect(mockShowToast).toHaveBeenCalledWith('Component successfully removed');
});
expect(mockShowToast).toHaveBeenCalledWith('Item successfully removed');
// Should close sidebar as component was removed
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
it('should remove unit from collection and hides sidebar', async () => {
const url = getLibraryCollectionItemsApiUrl(
mockContentLibrary.libraryId,
mockCollection.collectionId,
);
axiosMock.onDelete(url).reply(204);
const displayName = 'Test Unit';
await renderLibraryCollectionPage();
// Wait for the unit cards to load
waitFor(() => expect(screen.getAllByTestId('container-card-menu-toggle').length).toBeGreaterThan(0));
// open sidebar
fireEvent.click(await screen.findByText(displayName));
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).toBeInTheDocument());
// Open menu
fireEvent.click((await screen.findAllByTestId('container-card-menu-toggle'))[0]);
// Click remove to collection
fireEvent.click(screen.getByRole('button', { name: 'Remove from collection' }));
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Item successfully removed');
// Should close sidebar as component was removed
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});

View File

@@ -109,7 +109,9 @@ const LibraryCollectionPage = () => {
}
const { componentPickerMode } = useComponentPickerContext();
const { showOnlyPublished, setCollectionId, componentId } = useLibraryContext();
const {
showOnlyPublished, extraFilter: contextExtraFilter, setCollectionId, componentId,
} = useLibraryContext();
const { sidebarComponentInfo, openInfoSidebar } = useSidebarContext();
const {
@@ -182,6 +184,10 @@ const LibraryCollectionPage = () => {
extraFilter.push('last_published IS NOT NULL');
}
if (contextExtraFilter) {
extraFilter.push(...contextExtraFilter);
}
return (
<div className="d-flex">
<div className="flex-grow-1">

View File

@@ -34,6 +34,8 @@ export type LibraryContextData = {
setUnitId: (unitId?: string) => void;
// Only show published components
showOnlyPublished: boolean;
// Additional filtering
extraFilter?: string[];
// "Create New Collection" modal
isCreateCollectionModalOpen: boolean;
openCreateCollectionModal: () => void;
@@ -66,6 +68,7 @@ type LibraryProviderProps = {
children?: React.ReactNode;
libraryId: string;
showOnlyPublished?: boolean;
extraFilter?: string[]
// If set, will initialize the current collection and/or component from the current URL
skipUrlUpdate?: boolean;
@@ -83,6 +86,7 @@ export const LibraryProvider = ({
children,
libraryId,
showOnlyPublished = false,
extraFilter = [],
skipUrlUpdate = false,
componentPicker,
}: LibraryProviderProps) => {
@@ -139,6 +143,7 @@ export const LibraryProvider = ({
readOnly,
isLoadingLibraryData,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
@@ -164,6 +169,7 @@ export const LibraryProvider = ({
readOnly,
isLoadingLibraryData,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,

View File

@@ -8,11 +8,11 @@ import {
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryBlockMetadata } from '../data/apiHooks';
import StatusWidget from '../generic/status-widget';
import messages from './messages';
import { ContentTagsDrawer, useContentTaxonomyTagsData } from '../../content-tags-drawer';
import ManageCollections from './ManageCollections';
import { useLibraryBlockMetadata, useUpdateComponentCollections } from '../data/apiHooks';
import StatusWidget from '../generic/status-widget';
import { ManageCollections } from '../generic/manage-collections';
import messages from './messages';
const ComponentManagement = () => {
const intl = useIntl();
@@ -130,7 +130,11 @@ const ComponentManagement = () => {
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body">
<ManageCollections usageKey={usageKey} collections={componentMetadata.collections} />
<ManageCollections
opaqueKey={usageKey}
collections={componentMetadata.collections}
useUpdateCollectionsHook={useUpdateComponentCollections}
/>
</Collapsible.Body>
</Collapsible.Advanced>
</Stack>

View File

@@ -136,51 +136,6 @@ const messages = defineMessages({
defaultMessage: 'Component Preview',
description: 'Title for preview modal',
},
manageCollectionsText: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.text',
defaultMessage: 'Manage Collections',
description: 'Header and button text for collection section in manage tab',
},
manageCollectionsAddBtnText: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.btn-text',
defaultMessage: 'Add to Collection',
description: 'Button text for collection section in manage tab',
},
manageCollectionsSearchPlaceholder: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.search-placeholder',
defaultMessage: 'Search',
description: 'Placeholder text for collection search in manage tab',
},
manageCollectionsSelectionLabel: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.selection-aria-label',
defaultMessage: 'Collection selection',
description: 'Aria label text for collection selection box',
},
manageCollectionsToComponentSuccess: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.add-success',
defaultMessage: 'Component collections updated',
description: 'Message to display on updating component collections',
},
manageCollectionsToComponentFailed: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.add-failed',
defaultMessage: 'Failed to update Component collections',
description: 'Message to display on failure of updating component collections',
},
manageCollectionsToComponentConfirmBtn: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.add-confirm-btn',
defaultMessage: 'Confirm',
description: 'Button text to confirm collections for a component',
},
manageCollectionsToComponentCancelBtn: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.add-cancel-btn',
defaultMessage: 'Cancel',
description: 'Button text to cancel collections selection for a component',
},
componentNotOrganizedIntoCollection: {
id: 'course-authoring.library-authoring.component.manage-tab.collections.no-collections',
defaultMessage: 'This component is not organized into any collection.',
description: 'Message to display in manage collections section when component is not part of any collection.',
},
componentPickerSingleSelect: {
id: 'course-authoring.library-authoring.component-picker.single-select',
defaultMessage: 'Add to Course', // TODO: Change this message to a generic one?

View File

@@ -38,7 +38,7 @@ const defaultSelectionChangedCallback: ComponentSelectionChangedEvent = (selecti
window.parent.postMessage({ type: 'pickerSelectionChanged', selections }, '*');
};
type ComponentPickerProps = { libraryId?: string, showOnlyPublished?: boolean } & (
type ComponentPickerProps = { libraryId?: string, showOnlyPublished?: boolean, extraFilter?: string[] } & (
{
componentPickerMode?: 'single',
onComponentSelected?: ComponentSelectedEvent,
@@ -54,6 +54,7 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
/** Restrict the component picker to a specific library */
libraryId,
showOnlyPublished,
extraFilter,
componentPickerMode = 'single',
/** This default callback is used to send the selected component back to the parent window,
* when the component picker is used in an iframe.
@@ -105,6 +106,7 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
<LibraryProvider
libraryId={selectedLibrary}
showOnlyPublished={calcShowOnlyPublished}
extraFilter={extraFilter}
skipUrlUpdate
>
<SidebarProvider>

View File

@@ -0,0 +1,82 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import {
AddCircleOutline,
CheckBoxIcon,
CheckBoxOutlineBlank,
} from '@openedx/paragon/icons';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import messages from './messages';
interface AddComponentWidgetProps {
usageKey: string;
blockType: string;
}
const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => {
const intl = useIntl();
const {
componentPickerMode,
onComponentSelected,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
} = useComponentPickerContext();
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}
// istanbul ignore if: this should never happen
if (!componentPickerMode) {
return null;
}
if (componentPickerMode === 'single') {
return (
<Button
variant="outline-primary"
iconBefore={AddCircleOutline}
onClick={() => {
onComponentSelected({ usageKey, blockType });
}}
>
<FormattedMessage {...messages.componentPickerSingleSelectTitle} />
</Button>
);
}
if (componentPickerMode === 'multiple') {
const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
const handleChange = () => {
const selectedComponent = {
usageKey,
blockType,
};
if (!isChecked) {
addComponentToSelectedComponents(selectedComponent);
} else {
removeComponentFromSelectedComponents(selectedComponent);
}
};
return (
<Button
variant="outline-primary"
iconBefore={isChecked ? CheckBoxIcon : CheckBoxOutlineBlank}
onClick={handleChange}
>
{intl.formatMessage(messages.componentPickerMultipleSelectTitle)}
</Button>
);
}
// istanbul ignore next: this should never happen
return null;
};
export default AddComponentWidget;

View File

@@ -2,18 +2,12 @@ import { useCallback, useContext } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
Dropdown,
Icon,
IconButton,
useToggle,
} from '@openedx/paragon';
import {
AddCircleOutline,
CheckBoxIcon,
CheckBoxOutlineBlank,
MoreVert,
} from '@openedx/paragon/icons';
import { MoreVert } from '@openedx/paragon/icons';
import { useClipboard } from '../../generic/clipboard';
import { ToastContext } from '../../generic/toast-context';
@@ -21,13 +15,14 @@ import { type ContentHit, PublishStatus } from '../../search-manager';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useRemoveComponentsFromCollection } from '../data/apiHooks';
import { useRemoveItemsFromCollection } from '../data/apiHooks';
import { useLibraryRoutes } from '../routes';
import AddComponentWidget from './AddComponentWidget';
import BaseCard from './BaseCard';
import { canEditComponent } from './ComponentEditorModal';
import messages from './messages';
import ComponentDeleter from './ComponentDeleter';
import messages from './messages';
type ComponentCardProps = {
hit: ContentHit,
@@ -50,7 +45,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const canEdit = usageKey && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId);
const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
const { copyToClipboard } = useClipboard();
@@ -110,76 +105,6 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
);
};
interface AddComponentWidgetProps {
usageKey: string;
blockType: string;
}
const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => {
const intl = useIntl();
const {
componentPickerMode,
onComponentSelected,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
} = useComponentPickerContext();
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}
// istanbul ignore if: this should never happen
if (!componentPickerMode) {
return null;
}
if (componentPickerMode === 'single') {
return (
<Button
variant="outline-primary"
iconBefore={AddCircleOutline}
onClick={() => {
onComponentSelected({ usageKey, blockType });
}}
>
<FormattedMessage {...messages.componentPickerSingleSelectTitle} />
</Button>
);
}
if (componentPickerMode === 'multiple') {
const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
const handleChange = () => {
const selectedComponent = {
usageKey,
blockType,
};
if (!isChecked) {
addComponentToSelectedComponents(selectedComponent);
} else {
removeComponentFromSelectedComponents(selectedComponent);
}
};
return (
<Button
variant="outline-primary"
iconBefore={isChecked ? CheckBoxIcon : CheckBoxOutlineBlank}
onClick={handleChange}
>
{intl.formatMessage(messages.componentPickerMultipleSelectTitle)}
</Button>
);
}
// istanbul ignore next: this should never happen
return null;
};
const ComponentCard = ({ hit }: ComponentCardProps) => {
const { showOnlyPublished } = useLibraryContext();
const { openComponentInfoSidebar } = useSidebarContext();

View File

@@ -1,4 +1,4 @@
import { useCallback, ReactNode } from 'react';
import { ReactNode, useCallback, useContext } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
@@ -12,14 +12,16 @@ import { MoreVert } from '@openedx/paragon/icons';
import { Link } from 'react-router-dom';
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-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 { useSidebarContext } from '../common/context/SidebarContext';
import BaseCard from './BaseCard';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useContainerChildren, useRemoveItemsFromCollection } from '../data/apiHooks';
import { useLibraryRoutes } from '../routes';
import AddComponentWidget from './AddComponentWidget';
import BaseCard from './BaseCard';
import messages from './messages';
import { useContainerChildren } from '../data/apiHooks';
import ContainerDeleter from './ContainerDeleter';
type ContainerMenuProps = {
@@ -30,13 +32,38 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
const intl = useIntl();
const {
contextKey,
blockId,
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 removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
const removeFromCollection = () => {
removeComponentsMutation.mutateAsync([containerId]).then(() => {
if (sidebarComponentInfo?.id === containerId) {
// Close sidebar if current component is open
closeLibrarySidebar();
}
showToast(intl.formatMessage(messages.removeComponentSucess));
}).catch(() => {
showToast(intl.formatMessage(messages.removeComponentFailure));
});
};
const showManageCollections = useCallback(() => {
setSidebarAction(SidebarActions.JumpToAddCollections);
openUnitInfoSidebar(containerId);
}, [setSidebarAction, openUnitInfoSidebar, containerId]);
return (
<>
<Dropdown id="container-card-dropdown">
@@ -46,20 +73,27 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
alt={intl.formatMessage(messages.containerCardMenuAlt)}
data-testid="container-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item
as={Link}
to={`/library/${contextKey}/container/${blockId}`}
disabled
to={`/library/${contextKey}/unit/${containerId}`}
>
<FormattedMessage {...messages.menuOpen} />
</Dropdown.Item>
<Dropdown.Item onClick={confirmDelete}>
<FormattedMessage {...messages.menuDeleteContainer} />
</Dropdown.Item>
{collectionId && (
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...messages.menuRemoveFromCollection} />
</Dropdown.Item>
)}
<Dropdown.Item onClick={showManageCollections}>
<FormattedMessage {...messages.menuAddToCollection} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<ContainerDeleter
@@ -78,8 +112,7 @@ type ContainerCardPreviewProps = {
};
const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCardPreviewProps) => {
const { libraryId } = useLibraryContext();
const { data, isLoading, isError } = useContainerChildren(libraryId, containerId);
const { data, isLoading, isError } = useContainerChildren(containerId);
if (isLoading || isError) {
return null;
}
@@ -170,9 +203,13 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
preview={<ContainerCardPreview containerId={unitId} />}
tags={tags}
numChildren={numChildrenCount}
actions={!componentPickerMode && (
actions={(
<ActionRow>
<ContainerMenu hit={hit} />
{componentPickerMode ? (
<AddComponentWidget usageKey={unitId} blockType={itemType} />
) : (
<ContainerMenu hit={hit} />
)}
</ActionRow>
)}
hasUnpublishedChanges={publishStatus !== PublishStatus.Published}

View File

@@ -44,17 +44,17 @@ const messages = defineMessages({
menuRemoveFromCollection: {
id: 'course-authoring.library-authoring.component.menu.remove',
defaultMessage: 'Remove from collection',
description: 'Menu item for remove a component from collection.',
description: 'Menu item for remove an item from collection.',
},
removeComponentSucess: {
id: 'course-authoring.library-authoring.component.remove-from-collection-success',
defaultMessage: 'Component successfully removed',
description: 'Message for successful removal of component from collection.',
defaultMessage: 'Item successfully removed',
description: 'Message for successful removal of an item from collection.',
},
removeComponentFailure: {
id: 'course-authoring.library-authoring.component.remove-from-collection-failure',
defaultMessage: 'Failed to remove Component',
description: 'Message for failure of removal of component from collection.',
defaultMessage: 'Failed to remove item',
description: 'Message for failure of removal of an item from collection.',
},
deleteComponentWarningTitle: {
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
@@ -137,7 +137,7 @@ const messages = defineMessages({
description: 'Message to display on failure to undo delete collection',
},
componentPickerSingleSelectTitle: {
id: 'course-authoring.library-authoring.component-picker.single..title',
id: 'course-authoring.library-authoring.component-picker.single.title',
defaultMessage: 'Add',
description: 'Button title for picking a component',
},

View File

@@ -18,7 +18,7 @@ const ContainerInfoHeader = () => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
const { libraryId, readOnly } = useLibraryContext();
const { readOnly } = useLibraryContext();
const { sidebarComponentInfo } = useSidebarContext();
const containerId = sidebarComponentInfo?.id;
@@ -27,7 +27,7 @@ const ContainerInfoHeader = () => {
throw new Error('containerId is required');
}
const { data: container } = useContainer(libraryId, containerId);
const { data: container } = useContainer(containerId);
const updateMutation = useUpdateContainer(containerId);
const { showToast } = useContext(ToastContext);

View File

@@ -23,14 +23,18 @@ mockGetContainerMetadata.applyMock();
mockContentLibrary.applyMock();
mockContentTaxonomyTagsData.applyMock();
const { containerIdForTags } = mockGetContainerMetadata;
const render = (libraryId?: string) => baseRender(<ContainerOrganize />, {
const render = ({
libraryId = mockContentLibrary.libraryId,
containerId = mockGetContainerMetadata.containerId,
}: {
libraryId?: string;
containerId?: string;
}) => baseRender(<ContainerOrganize />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId || mockContentLibrary.libraryId}>
<LibraryProvider libraryId={libraryId}>
<SidebarProvider
initialSidebarComponentInfo={{
id: containerIdForTags,
id: containerId,
type: SidebarBodyComponentId.ComponentInfo,
}}
>
@@ -61,7 +65,7 @@ describe('<ContainerOrganize />', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
render(libraryId);
render({ libraryId });
await waitFor(() => {
expect(screen.getByText(`Mocked ${expected} ContentTagsDrawer`)).toBeInTheDocument();
});
@@ -73,7 +77,12 @@ describe('<ContainerOrganize />', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
render();
render({ containerId: mockGetContainerMetadata.containerIdForTags });
expect(await screen.findByText('Tags (6)')).toBeInTheDocument();
});
it('should render collection count in collection info section', async () => {
render({ containerId: mockGetContainerMetadata.containerIdWithCollections });
expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import {
@@ -8,20 +8,27 @@ import {
useToggle,
} from '@openedx/paragon';
import {
ExpandLess, ExpandMore, Tag,
BookOpen,
ExpandLess,
ExpandMore,
Tag,
} from '@openedx/paragon/icons';
import { ContentTagsDrawer, useContentTaxonomyTagsData } from '../../content-tags-drawer';
import { ManageCollections } from '../generic/manage-collections';
import { useContainer, useUpdateContainerCollections } from '../data/apiHooks';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import messages from './messages';
const ContainerOrganize = () => {
const intl = useIntl();
const [tagsCollapseIsOpen, , , toggleTags] = useToggle(true);
const [tagsCollapseIsOpen, ,setTagsCollapseClose, toggleTags] = useToggle(true);
const [collectionsCollapseIsOpen, setCollectionsCollapseOpen, , toggleCollections] = useToggle(true);
const { readOnly } = useLibraryContext();
const { sidebarComponentInfo } = useSidebarContext();
const { sidebarComponentInfo, sidebarAction } = useSidebarContext();
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
const containerId = sidebarComponentInfo?.id;
// istanbul ignore if: this should never happen
@@ -29,8 +36,17 @@ const ContainerOrganize = () => {
throw new Error('containerId is required');
}
const { data: containerMetadata } = useContainer(containerId);
const { data: componentTags } = useContentTaxonomyTagsData(containerId);
useEffect(() => {
if (jumpToCollections) {
setTagsCollapseClose();
setCollectionsCollapseOpen();
}
}, [jumpToCollections, tagsCollapseIsOpen, collectionsCollapseIsOpen]);
const collectionsCount = useMemo(() => containerMetadata?.collections?.length || 0, [containerMetadata]);
const tagsCount = useMemo(() => {
if (!componentTags) {
return 0;
@@ -50,6 +66,11 @@ const ContainerOrganize = () => {
return result;
}, [componentTags]);
// istanbul ignore if: this should never happen
if (!containerMetadata) {
return null;
}
return (
<Stack gap={3}>
{[true, 'true'].includes(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES)
@@ -82,6 +103,33 @@ const ContainerOrganize = () => {
</Collapsible.Body>
</Collapsible.Advanced>
)}
<Collapsible.Advanced
open={collectionsCollapseIsOpen}
className="collapsible-card border-0"
>
<Collapsible.Trigger
onClick={toggleCollections}
className="collapsible-trigger d-flex justify-content-between p-2"
>
<Stack gap={1} direction="horizontal">
<Icon src={BookOpen} />
{intl.formatMessage(messages.organizeTabCollectionsTitle, { count: collectionsCount })}
</Stack>
<Collapsible.Visible whenClosed>
<Icon src={ExpandMore} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={ExpandLess} />
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body">
<ManageCollections
opaqueKey={containerId}
collections={containerMetadata.collections}
useUpdateCollectionsHook={useUpdateContainerCollections}
/>
</Collapsible.Body>
</Collapsible.Advanced>
</Stack>
);
};

View File

@@ -9,13 +9,14 @@ import {
IconButton,
useToggle,
} from '@openedx/paragon';
import { useEffect, useCallback } from 'react';
import { MoreVert } from '@openedx/paragon/icons';
import { useCallback } from 'react';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import {
type UnitInfoTab,
SidebarActions,
UNIT_INFO_TABS,
isUnitInfoTab,
useSidebarContext,
@@ -69,11 +70,17 @@ const UnitMenu = ({ containerId, displayName }: ContainerMenuProps) => {
const UnitInfo = () => {
const intl = useIntl();
const { libraryId, setUnitId } = useLibraryContext();
const { setUnitId } = useLibraryContext();
const { componentPickerMode } = useComponentPickerContext();
const {
defaultTab, hiddenTabs, sidebarComponentInfo, sidebarTab, setSidebarTab,
defaultTab,
hiddenTabs,
sidebarTab,
setSidebarTab,
sidebarComponentInfo,
sidebarAction,
} = useSidebarContext();
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
const { insideUnit, navigateTo } = useLibraryRoutes();
const tab: UnitInfoTab = (
@@ -81,7 +88,7 @@ const UnitInfo = () => {
) ? sidebarTab : defaultTab.unit;
const unitId = sidebarComponentInfo?.id;
const { data: container } = useContainer(libraryId, unitId);
const { data: container } = useContainer(unitId);
const handleOpenUnit = useCallback(() => {
if (componentPickerMode) {
@@ -105,12 +112,14 @@ const UnitInfo = () => {
);
}, [hiddenTabs, defaultTab.unit, unitId]);
// istanbul ignore if: this should never happen
if (!unitId) {
throw new Error('unitId is required');
}
useEffect(() => {
// Show Organize tab if JumpToAddCollections action is set in sidebarComponentInfo
if (jumpToCollections) {
setSidebarTab(UNIT_INFO_TABS.Organize);
}
}, [jumpToCollections, setSidebarTab]);
if (!container) {
if (!container || !unitId) {
return null;
}

View File

@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'Tags ({count})',
description: 'Title for tags section in organize tab',
},
organizeTabCollectionsTitle: {
id: 'course-authoring.library-authoring.container-sidebar.organize-tab.collections.title',
defaultMessage: 'Collections ({count})',
description: 'Title for collections section in organize tab',
},
settingsTabTitle: {
id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title',
defaultMessage: 'Settings',

View File

@@ -6,33 +6,40 @@ 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';
import messages from './messages';
import { useCreateLibraryContainer } from '../data/apiHooks';
import { useAddItemsToCollection, useCreateLibraryContainer } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import { ContainerType } from '../../generic/key-utils';
const CreateUnitModal = () => {
const intl = useIntl();
const navigate = useNavigate();
const {
collectionId,
libraryId,
isCreateUnitModalOpen,
closeCreateUnitModal,
} = useLibraryContext();
const create = useCreateLibraryContainer(libraryId);
const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId);
const { showToast } = React.useContext(ToastContext);
const handleCreate = React.useCallback(async (values) => {
try {
await create.mutateAsync({
const container = await create.mutateAsync({
containerType: ContainerType.Unit,
...values,
});
// TODO: Navigate to the new unit
// navigate(`/library/${libraryId}/units/${data.key}`);
if (collectionId) {
await updateItemsMutation.mutateAsync([container.containerKey]);
}
// Navigate to the new unit
navigate(`/library/${libraryId}/unit/${container.containerKey}`);
showToast(intl.formatMessage(messages.createUnitSuccess));
} catch (error) {
showToast(intl.formatMessage(messages.createUnitError));

View File

@@ -480,6 +480,8 @@ export async function mockGetContainerMetadata(containerId: string): Promise<api
});
case mockGetContainerMetadata.containerIdLoading:
return new Promise(() => { });
case mockGetContainerMetadata.containerIdWithCollections:
return Promise.resolve(mockGetContainerMetadata.containerDataWithCollections);
default:
return Promise.resolve(mockGetContainerMetadata.containerData);
}
@@ -488,6 +490,7 @@ mockGetContainerMetadata.containerId = 'lct:org:lib:unit:test-unit-9a207';
mockGetContainerMetadata.containerIdError = 'lct:org:lib:unit:container_error';
mockGetContainerMetadata.containerIdLoading = 'lct:org:lib:unit:container_loading';
mockGetContainerMetadata.containerIdForTags = mockContentTaxonomyTagsData.largeTagsId;
mockGetContainerMetadata.containerIdWithCollections = 'lct:org:lib:unit:container_collections';
mockGetContainerMetadata.containerData = {
containerKey: 'lct:org:lib:unit:test-unit-9a2072',
containerType: 'unit',
@@ -502,6 +505,11 @@ mockGetContainerMetadata.containerData = {
hasUnpublishedChanges: true,
collections: [],
} satisfies api.Container;
mockGetContainerMetadata.containerDataWithCollections = {
...mockGetContainerMetadata.containerData,
containerKey: mockGetContainerMetadata.containerIdWithCollections,
collections: [{ title: 'My first collection', key: 'my-first-collection' }],
} satisfies api.Container;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockGetContainerMetadata.applyMock = () => {
jest.spyOn(api, 'getContainerMetadata').mockImplementation(mockGetContainerMetadata);

View File

@@ -41,7 +41,7 @@ export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl
export const getLibraryBlockRestoreUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}restore/`;
/**
* Get the URL for library block metadata.
* Get the URL for library block collections.
*/
export const getLibraryBlockCollectionsUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}collections/`;
@@ -89,9 +89,9 @@ export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseU
*/
export const getLibraryCollectionApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionsApiUrl(libraryId)}${collectionId}/`;
/**
* Get the URL for the collection components API.
* Get the URL for the collection items API.
*/
export const getLibraryCollectionComponentApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}components/`;
export const getLibraryCollectionItemsApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}items/`;
/**
* Get the API URL for restoring deleted collection.
*/
@@ -120,6 +120,10 @@ export const getLibraryContainerRestoreApiUrl = (containerId: string) => `${getL
* Get the URL for a single container children api.
*/
export const getLibraryContainerChildrenApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}children/`;
/**
* Get the URL for library container collections.
*/
export const getLibraryContainerCollectionsUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}collections/`;
export interface ContentLibrary {
id: string;
@@ -533,19 +537,19 @@ export async function updateCollectionMetadata(
}
/**
* Add components to collection.
* Add items to collection.
*/
export async function addComponentsToCollection(libraryId: string, collectionId: string, usageKeys: string[]) {
await getAuthenticatedHttpClient().patch(getLibraryCollectionComponentApiUrl(libraryId, collectionId), {
export async function addItemsToCollection(libraryId: string, collectionId: string, usageKeys: string[]) {
await getAuthenticatedHttpClient().patch(getLibraryCollectionItemsApiUrl(libraryId, collectionId), {
usage_keys: usageKeys,
});
}
/**
* Remove components from collection.
* Remove items from collection.
*/
export async function removeComponentsFromCollection(libraryId: string, collectionId: string, usageKeys: string[]) {
await getAuthenticatedHttpClient().delete(getLibraryCollectionComponentApiUrl(libraryId, collectionId), {
export async function removeItemsFromCollection(libraryId: string, collectionId: string, usageKeys: string[]) {
await getAuthenticatedHttpClient().delete(getLibraryCollectionItemsApiUrl(libraryId, collectionId), {
data: { usage_keys: usageKeys },
});
}
@@ -583,9 +587,13 @@ export interface CreateLibraryContainerDataRequest {
/**
* Create a library container
*/
export async function createLibraryContainer(libraryId: string, containerData: CreateLibraryContainerDataRequest) {
export async function createLibraryContainer(
libraryId: string,
containerData: CreateLibraryContainerDataRequest,
): Promise<Container> {
const client = getAuthenticatedHttpClient();
await client.post(getLibraryContainersApiUrl(libraryId), snakeCaseObject(containerData));
const { data } = await client.post(getLibraryContainersApiUrl(libraryId), snakeCaseObject(containerData));
return camelCaseObject(data);
}
export interface Container {
@@ -661,3 +669,12 @@ export async function addComponentsToContainer(containerId: string, componentIds
snakeCaseObject({ usageKeys: componentIds }),
);
}
/**
* Update container collections.
*/
export async function updateContainerCollections(containerId: string, collectionKeys: string[]) {
await getAuthenticatedHttpClient().patch(getLibraryContainerCollectionsUrl(containerId), {
collection_keys: collectionKeys,
});
}

View File

@@ -8,7 +8,7 @@ import MockAdapter from 'axios-mock-adapter';
import {
getCommitLibraryChangesUrl,
getCreateLibraryBlockUrl,
getLibraryCollectionComponentApiUrl,
getLibraryCollectionItemsApiUrl,
getLibraryCollectionsApiUrl,
getLibraryCollectionApiUrl,
getBlockTypesMetaDataUrl,
@@ -21,7 +21,7 @@ import {
useCreateLibraryBlock,
useCreateLibraryCollection,
useRevertLibraryChanges,
useAddComponentsToCollection,
useAddItemsToCollection,
useCollection,
useBlockTypesMetadata,
useContainer,
@@ -111,9 +111,9 @@ describe('library api hooks', () => {
it('should add components to collection', async () => {
const libraryId = 'lib:org:1';
const collectionId = 'my-first-collection';
const url = getLibraryCollectionComponentApiUrl(libraryId, collectionId);
const url = getLibraryCollectionItemsApiUrl(libraryId, collectionId);
axiosMock.onPatch(url).reply(200);
const { result } = renderHook(() => useAddComponentsToCollection(libraryId, collectionId), { wrapper });
const { result } = renderHook(() => useAddItemsToCollection(libraryId, collectionId), { wrapper });
await result.current.mutateAsync(['some-usage-key']);
expect(axiosMock.history.patch[0].url).toEqual(url);
@@ -147,12 +147,11 @@ describe('library api hooks', () => {
});
it('should get container metadata', async () => {
const libraryId = 'lib:org:1';
const containerId = 'lct:lib:org:unit:unit1';
const url = getLibraryContainerApiUrl(containerId);
axiosMock.onGet(url).reply(200, { 'test-data': 'test-value' });
const { result } = renderHook(() => useContainer(libraryId, containerId), { wrapper });
const { result } = renderHook(() => useContainer(containerId), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
@@ -185,7 +184,6 @@ describe('library api hooks', () => {
});
it('should get container children', async () => {
const libraryId = 'lib:org:1';
const containerId = 'lct:lib:org:unit:unit1';
const url = getLibraryContainerChildrenApiUrl(containerId);
@@ -221,7 +219,7 @@ describe('library api hooks', () => {
collections: ['col2'],
},
]);
const { result } = renderHook(() => useContainerChildren(libraryId, containerId), { wrapper });
const { result } = renderHook(() => useContainerChildren(containerId), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
@@ -261,14 +259,13 @@ describe('library api hooks', () => {
});
it('should add components to container', async () => {
const libraryId = 'lib:org:1';
const componentId = 'lb:org:lib:html:1';
const containerId = 'ltc:org:lib:unit:1';
const url = getLibraryContainerChildrenApiUrl(containerId);
axiosMock.onPost(url).reply(200);
const { result } = renderHook(() => useAddComponentsToContainer(libraryId, containerId), { wrapper });
const { result } = renderHook(() => useAddComponentsToContainer(containerId), { wrapper });
await result.current.mutateAsync([componentId]);
expect(axiosMock.history.post[0].url).toEqual(url);

View File

@@ -33,7 +33,7 @@ import {
getXBlockOLX,
updateCollectionMetadata,
type UpdateCollectionComponentsRequest,
addComponentsToCollection,
addItemsToCollection,
type CreateLibraryCollectionDataRequest,
getCollectionMetadata,
deleteCollection,
@@ -41,7 +41,7 @@ import {
setXBlockOLX,
getXBlockAssets,
updateComponentCollections,
removeComponentsFromCollection,
removeItemsFromCollection,
publishXBlock,
deleteXBlockAsset,
restoreLibraryBlock,
@@ -55,6 +55,7 @@ import {
type UpdateContainerDataRequest,
restoreContainer,
getLibraryContainerChildren,
updateContainerCollections,
} from './api';
import { VersionSpec } from '../LibraryBlock';
@@ -94,22 +95,22 @@ export const libraryAuthoringQueryKeys = {
libraryId,
collectionId,
],
container: (libraryId?: string, containerId?: string) => [
...libraryAuthoringQueryKeys.all,
libraryId,
containerId,
],
containerChildren: (libraryId?: string, containerId?: string) => [
...libraryAuthoringQueryKeys.all,
libraryId,
containerId,
'children',
],
blockTypes: (libraryId?: string) => [
...libraryAuthoringQueryKeys.all,
'blockTypes',
libraryId,
],
container: (containerId?: string) => [
...libraryAuthoringQueryKeys.all,
'container',
containerId,
],
containerChildren: (containerId?: string) => [
...libraryAuthoringQueryKeys.all,
'container',
containerId,
'children',
],
};
export const xblockQueryKeys = {
@@ -128,15 +129,6 @@ export const xblockQueryKeys = {
componentDownstreamLinks: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'downstreamLinks'],
};
export const containerQueryKeys = {
all: ['container', 'children'],
/**
* Base key for data specific to a container
*/
container: (usageKey?: string) => [...containerQueryKeys.all, usageKey],
children: (usageKey?: string) => [...containerQueryKeys.all, usageKey, 'children'],
};
/**
* Tell react-query to refresh its cache of any data related to the given
* component (XBlock).
@@ -274,7 +266,7 @@ export const useRevertLibraryChanges = () => {
/**
* Hook to fetch a content library's team members
*/
export const useLibraryTeam = (libraryId: string | undefined) => (
export const useLibraryTeam = (libraryId?: string) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.libraryTeam(libraryId),
queryFn: () => getLibraryTeam(libraryId!),
@@ -285,7 +277,7 @@ export const useLibraryTeam = (libraryId: string | undefined) => (
/**
* Hook to fetch the list of XBlock types that can be added to this library.
*/
export const useBlockTypesMetadata = (libraryId: string | undefined) => (
export const useBlockTypesMetadata = (libraryId?: string) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.blockTypes(libraryId),
queryFn: () => getBlockTypes(libraryId!),
@@ -509,14 +501,14 @@ export const useUpdateCollection = (libraryId: string, collectionId: string) =>
};
/**
* Use this mutation to add components to a collection in a library
* Use this mutation to add items to a collection in a library
*/
export const useAddComponentsToCollection = (libraryId?: string, collectionId?: string) => {
export const useAddItemsToCollection = (libraryId?: string, collectionId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (usageKeys: string[]) => {
if (libraryId !== undefined && collectionId !== undefined) {
return addComponentsToCollection(libraryId, collectionId, usageKeys);
return addItemsToCollection(libraryId, collectionId, usageKeys);
}
return undefined;
},
@@ -529,14 +521,14 @@ export const useAddComponentsToCollection = (libraryId?: string, collectionId?:
};
/**
* Use this mutation to remove components from a collection in a library
* Use this mutation to remove items from a collection in a library
*/
export const useRemoveComponentsFromCollection = (libraryId?: string, collectionId?: string) => {
export const useRemoveItemsFromCollection = (libraryId?: string, collectionId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (usageKeys: string[]) => {
if (libraryId !== undefined && collectionId !== undefined) {
return removeComponentsFromCollection(libraryId, collectionId, usageKeys);
return removeItemsFromCollection(libraryId, collectionId, usageKeys);
}
return undefined;
},
@@ -579,8 +571,9 @@ export const useRestoreCollection = (libraryId: string, collectionId: string) =>
/**
* Use this mutation to update collections related a component in a library
*/
export const useUpdateComponentCollections = (libraryId: string, usageKey: string) => {
export const useUpdateComponentCollections = (usageKey: string) => {
const queryClient = useQueryClient();
const libraryId = getLibraryId(usageKey);
return useMutation({
mutationFn: async (collectionKeys: string[]) => updateComponentCollections(usageKey, collectionKeys),
onSettled: () => {
@@ -606,10 +599,10 @@ export const useCreateLibraryContainer = (libraryId: string) => {
/**
* Get the metadata for a container in a library
*/
export const useContainer = (libraryId?: string, containerId?: string) => (
export const useContainer = (containerId?: string) => (
useQuery({
enabled: !!libraryId && !!containerId,
queryKey: libraryAuthoringQueryKeys.container(libraryId, containerId),
enabled: !!containerId,
queryKey: libraryAuthoringQueryKeys.container(containerId!),
queryFn: () => getContainerMetadata(containerId!),
})
);
@@ -626,7 +619,7 @@ export const useUpdateContainer = (containerId: string) => {
// NOTE: We invalidate the library query here because we need to update the library's
// container list.
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(libraryId, containerId) });
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
},
});
};
@@ -663,10 +656,10 @@ export const useRestoreContainer = (containerId: string) => {
/**
* Get the metadata and children for a container in a library
*/
export const useContainerChildren = (libraryId?: string, containerId?: string) => (
export const useContainerChildren = (containerId?: string) => (
useQuery({
enabled: !!libraryId && !!containerId,
queryKey: libraryAuthoringQueryKeys.containerChildren(libraryId, containerId),
enabled: !!containerId,
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId),
queryFn: () => getLibraryContainerChildren(containerId!),
})
);
@@ -674,7 +667,7 @@ export const useContainerChildren = (libraryId?: string, containerId?: string) =
/**
* Use this mutation to add components to a container
*/
export const useAddComponentsToContainer = (libraryId?: string, containerId?: string) => {
export const useAddComponentsToContainer = (containerId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (componentIds: string[]) => {
@@ -684,7 +677,22 @@ export const useAddComponentsToContainer = (libraryId?: string, containerId?: st
return undefined;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(libraryId, containerId) });
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!) });
},
});
};
/**
* Use this mutation to update collections related a container in a library
*/
export const useUpdateContainerCollections = (containerId: string) => {
const queryClient = useQueryClient();
const libraryId = getLibraryId(containerId);
return useMutation({
mutationFn: async (collectionKeys: string[]) => updateContainerCollections(containerId, collectionKeys),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
},
});
};

View File

@@ -2,25 +2,27 @@ import fetchMock from 'fetch-mock-jest';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import { mockContentSearchConfig } from '../../../search-manager/data/api.mock';
import {
initializeMocks,
render as baseRender,
screen,
waitFor,
} from '../../testUtils';
import mockCollectionsResults from '../__mocks__/collection-search.json';
import { mockContentSearchConfig } from '../../search-manager/data/api.mock';
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
} from '../../../testUtils';
import mockCollectionsResults from '../../__mocks__/collection-search.json';
import { LibraryProvider } from '../../common/context/LibraryContext';
import { SidebarProvider } from '../../common/context/SidebarContext';
import { getLibraryBlockCollectionsUrl, getLibraryContainerCollectionsUrl } from '../../data/api';
import { useUpdateComponentCollections, useUpdateContainerCollections } from '../../data/apiHooks';
import { mockContentLibrary, mockLibraryBlockMetadata, mockGetContainerMetadata } from '../../data/api.mocks';
import ManageCollections from './ManageCollections';
import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarProvider } from '../common/context/SidebarContext';
import { getLibraryBlockCollectionsUrl } from '../data/api';
let axiosMock: MockAdapter;
let mockShowToast;
mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockGetContainerMetadata.applyMock();
mockContentSearchConfig.applyMock();
const render = (ui: React.ReactElement) => baseRender(ui, {
@@ -56,12 +58,13 @@ describe('<ManageCollections />', () => {
});
});
it('should show all collections in library and allow users to select for the current component ', async () => {
it('should show all collections in library and allow users to select for the current component', async () => {
const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
axiosMock.onPatch(url).reply(200);
render(<ManageCollections
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
opaqueKey={mockLibraryBlockMetadata.usageKeyWithCollections}
collections={[{ title: 'My first collection', key: 'my-first-collection' }]}
useUpdateCollectionsHook={useUpdateComponentCollections}
/>);
const manageBtn = await screen.findByRole('button', { name: 'Manage Collections' });
userEvent.click(manageBtn);
@@ -73,10 +76,36 @@ describe('<ManageCollections />', () => {
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(1);
expect(mockShowToast).toHaveBeenCalledWith('Component collections updated');
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
collection_keys: ['my-first-collection', 'my-second-collection'],
});
});
expect(mockShowToast).toHaveBeenCalledWith('Item collections updated');
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
collection_keys: ['my-first-collection', 'my-second-collection'],
});
expect(screen.queryByRole('search')).not.toBeInTheDocument();
});
it('should show all collections in library and allow users to select for the current container', async () => {
const url = getLibraryContainerCollectionsUrl(mockGetContainerMetadata.containerIdWithCollections);
axiosMock.onPatch(url).reply(200);
render(<ManageCollections
opaqueKey={mockGetContainerMetadata.containerIdWithCollections}
collections={[{ title: 'My first collection', key: 'my-first-collection' }]}
useUpdateCollectionsHook={useUpdateContainerCollections}
/>);
const manageBtn = await screen.findByRole('button', { name: 'Manage Collections' });
userEvent.click(manageBtn);
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
expect(screen.queryByRole('search')).toBeInTheDocument();
const secondCollection = await screen.findByRole('button', { name: 'My second collection' });
userEvent.click(secondCollection);
const confirmBtn = await screen.findByRole('button', { name: 'Confirm' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Item collections updated');
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
collection_keys: ['my-first-collection', 'my-second-collection'],
});
expect(screen.queryByRole('search')).not.toBeInTheDocument();
});
@@ -85,8 +114,9 @@ describe('<ManageCollections />', () => {
const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
axiosMock.onPatch(url).reply(400);
render(<ManageCollections
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
opaqueKey={mockLibraryBlockMetadata.usageKeyWithCollections}
collections={[]}
useUpdateCollectionsHook={useUpdateComponentCollections}
/>);
screen.logTestingPlaygroundURL();
const manageBtn = await screen.findByRole('button', { name: 'Add to Collection' });
@@ -99,11 +129,11 @@ describe('<ManageCollections />', () => {
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
collection_keys: ['my-second-collection'],
});
expect(mockShowToast).toHaveBeenCalledWith('Failed to update Component collections');
});
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
collection_keys: ['my-second-collection'],
});
expect(mockShowToast).toHaveBeenCalledWith('Failed to update item collections');
expect(screen.queryByRole('search')).not.toBeInTheDocument();
});
@@ -111,8 +141,9 @@ describe('<ManageCollections />', () => {
const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
axiosMock.onPatch(url).reply(400);
render(<ManageCollections
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
opaqueKey={mockLibraryBlockMetadata.usageKeyWithCollections}
collections={[]}
useUpdateCollectionsHook={useUpdateComponentCollections}
/>);
const manageBtn = await screen.findByRole('button', { name: 'Add to Collection' });
userEvent.click(manageBtn);
@@ -124,8 +155,8 @@ describe('<ManageCollections />', () => {
userEvent.click(cancelBtn);
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(0);
expect(mockShowToast).not.toHaveBeenCalled();
});
expect(mockShowToast).not.toHaveBeenCalled();
expect(screen.queryByRole('search')).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import { useContext, useState } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import type { UseMutationResult } from '@tanstack/react-query';
import {
Button, Icon, Scrollable, SelectableBox, Stack, StatefulButton, useCheckboxSetValues,
} from '@openedx/paragon';
@@ -10,24 +11,29 @@ import {
SearchKeywordsField,
SearchSortWidget,
useSearchContext,
} from '../../search-manager';
} from '../../../search-manager';
import { ToastContext } from '../../../generic/toast-context';
import { CollectionMetadata } from '../../data/api';
import { useLibraryContext } from '../../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../../common/context/SidebarContext';
import messages from './messages';
import { useUpdateComponentCollections } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
import { CollectionMetadata } from '../data/api';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
interface ManageCollectionsProps {
usageKey: string;
opaqueKey: string;
collections: CollectionMetadata[],
useUpdateCollectionsHook: (opaqueKey: string) => UseMutationResult<void, unknown, string[], unknown>;
}
interface CollectionsDrawerProps extends ManageCollectionsProps {
onClose: () => void;
}
const CollectionsSelectableBox = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
const CollectionsSelectableBox = ({
opaqueKey,
collections,
useUpdateCollectionsHook,
onClose,
}: CollectionsDrawerProps) => {
const type = 'checkbox';
const intl = useIntl();
const { hits } = useSearchContext();
@@ -39,9 +45,7 @@ const CollectionsSelectableBox = ({ usageKey, collections, onClose }: Collection
}] = useCheckboxSetValues(collectionKeys);
const [btnState, setBtnState] = useState('default');
const { libraryId } = useLibraryContext();
const updateCollectionsMutation = useUpdateComponentCollections(libraryId, usageKey);
const updateCollectionsMutation = useUpdateCollectionsHook(opaqueKey);
const handleConfirmation = () => {
setBtnState('pending');
@@ -107,7 +111,12 @@ const CollectionsSelectableBox = ({ usageKey, collections, onClose }: Collection
);
};
const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
const AddToCollectionsDrawer = ({
opaqueKey,
collections,
useUpdateCollectionsHook,
onClose,
}: CollectionsDrawerProps) => {
const intl = useIntl();
const { libraryId } = useLibraryContext();
@@ -128,19 +137,20 @@ const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsD
/>
<SearchSortWidget iconOnly />
</Stack>
{/* Set key to update selection when component usageKey changes */}
{/* Set key to update selection when entity opaqueKey changes */}
<CollectionsSelectableBox
usageKey={usageKey}
opaqueKey={opaqueKey}
collections={collections}
useUpdateCollectionsHook={useUpdateCollectionsHook}
onClose={onClose}
key={usageKey}
key={opaqueKey}
/>
</Stack>
</SearchContextProvider>
);
};
const ComponentCollections = ({ collections, onManageClick }: {
const EntityCollections = ({ collections, onManageClick }: {
collections?: string[];
onManageClick: () => void;
}) => {
@@ -190,7 +200,7 @@ const ComponentCollections = ({ collections, onManageClick }: {
);
};
const ManageCollections = ({ usageKey, collections }: ManageCollectionsProps) => {
const ManageCollections = ({ opaqueKey, collections, useUpdateCollectionsHook }: ManageCollectionsProps) => {
const { sidebarAction, resetSidebarAction, setSidebarAction } = useSidebarContext();
const collectionNames = collections.map((collection) => collection.title);
@@ -198,12 +208,13 @@ const ManageCollections = ({ usageKey, collections }: ManageCollectionsProps) =>
sidebarAction === SidebarActions.JumpToAddCollections
? (
<AddToCollectionsDrawer
usageKey={usageKey}
opaqueKey={opaqueKey}
collections={collections}
useUpdateCollectionsHook={useUpdateCollectionsHook}
onClose={() => resetSidebarAction()}
/>
) : (
<ComponentCollections
<EntityCollections
collections={collectionNames}
onManageClick={() => setSidebarAction(SidebarActions.JumpToAddCollections)}
/>

View File

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

View File

@@ -0,0 +1,51 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
manageCollectionsText: {
id: 'course-authoring.library-authoring.manage-collections.title',
defaultMessage: 'Manage Collections',
description: 'Header and button text for the manage collection widget',
},
manageCollectionsAddBtnText: {
id: 'course-authoring.library-authoring.manage-collections.btn-text',
defaultMessage: 'Add to Collection',
description: 'Button text for collection section in the manage collections widget',
},
manageCollectionsSearchPlaceholder: {
id: 'course-authoring.library-authoring.manage-collections.search-placeholder',
defaultMessage: 'Search',
description: 'Placeholder text for collection search in the manage collections widget',
},
manageCollectionsSelectionLabel: {
id: 'course-authoring.library-authoring.manage-collections.selection-aria-label',
defaultMessage: 'Collection selection',
description: 'Aria label text for collection selection box',
},
manageCollectionsToComponentSuccess: {
id: 'course-authoring.library-authoring.manage-collections.add-success',
defaultMessage: 'Item collections updated',
description: 'Message to display on updating item collections',
},
manageCollectionsToComponentFailed: {
id: 'course-authoring.library-authoring.manage-collections.add-failed',
defaultMessage: 'Failed to update item collections',
description: 'Message to display on failure of updating item collections',
},
manageCollectionsToComponentConfirmBtn: {
id: 'course-authoring.library-authoring.manage-collections.add-confirm-btn',
defaultMessage: 'Confirm',
description: 'Button text to confirm adding collections for an item',
},
manageCollectionsToComponentCancelBtn: {
id: 'course-authoring.library-authoring.manage-collections.add-cancel-btn',
defaultMessage: 'Cancel',
description: 'Button text to cancel collections selection for am item',
},
componentNotOrganizedIntoCollection: {
id: 'course-authoring.library-authoring.manage-collections.no-collections',
defaultMessage: 'This item is not organized into any collection.',
description: 'Message to display in the manage collections widget when an item is not part of any collection.',
},
});
export default messages;

View File

@@ -41,7 +41,6 @@ export const LibraryUnitBlocks = () => {
const { navigateTo } = useLibraryRoutes();
const {
libraryId,
unitId,
showOnlyPublished,
componentId,
@@ -59,7 +58,7 @@ export const LibraryUnitBlocks = () => {
isLoading,
isError,
error,
} = useContainerChildren(libraryId, unitId);
} = useContainerChildren(unitId);
useEffect(() => setOrderedBlocks(blocks || []), [blocks]);
@@ -80,7 +79,7 @@ export const LibraryUnitBlocks = () => {
};
const onTagSidebarClose = () => {
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(libraryId, unitId));
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId));
closeManageTagsDrawer();
};

View File

@@ -121,7 +121,7 @@ export const LibraryUnitPage = () => {
isLoading,
isError,
error,
} = useContainer(libraryId, unitId);
} = useContainer(unitId);
// Only show loading if unit or library data is not fetched from index yet
if (isLibLoading || isLoading) {