feat: add collections support for containers [FC-0083] (#1797)
Adds support to add Units to Collections.
This commit is contained in:
@@ -195,6 +195,7 @@ const AddComponent = ({
|
||||
>
|
||||
<ComponentPicker
|
||||
showOnlyPublished
|
||||
extraFilter={['NOT block_type = "unit"']}
|
||||
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
|
||||
onComponentSelected={handleLibraryV2Selection}
|
||||
onChangeComponentSelection={setSelectedComponents}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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>
|
||||
|
||||
82
src/library-authoring/components/AddComponentWidget.tsx
Normal file
82
src/library-authoring/components/AddComponentWidget.tsx
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
/>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ManageCollections } from './ManageCollections';
|
||||
51
src/library-authoring/generic/manage-collections/messages.ts
Normal file
51
src/library-authoring/generic/manage-collections/messages.ts
Normal 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;
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user