Skip to content

Commit aa8a5bf

Browse files
authored
feat: add collections support for containers [FC-0083] (#1797)
Adds support to add Units to Collections.
1 parent 87695ae commit aa8a5bf

35 files changed

Lines changed: 632 additions & 306 deletions

src/course-unit/add-component/AddComponent.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const AddComponent = ({
195195
>
196196
<ComponentPicker
197197
showOnlyPublished
198+
extraFilter={['NOT block_type = "unit"']}
198199
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
199200
onComponentSelected={handleLibraryV2Selection}
200201
onChangeComponentSelection={setSelectedComponents}

src/index.jsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ const App = () => {
6666
<Route path="/libraries-v1" element={<StudioHome />} />
6767
<Route path="/library/create" element={<CreateLibrary />} />
6868
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
69-
<Route path="/component-picker" element={<ComponentPicker />} />
70-
<Route path="/component-picker/multiple" element={<ComponentPicker componentPickerMode="multiple" />} />
69+
<Route
70+
path="/component-picker"
71+
element={<ComponentPicker extraFilter={['NOT block_type = "unit"']} />}
72+
/>
73+
<Route
74+
path="/component-picker/multiple"
75+
element={<ComponentPicker componentPickerMode="multiple" extraFilter={['NOT block_type = "unit"']} />}
76+
/>
7177
<Route path="/legacy/preview-changes/:usageKey" element={<PreviewChangesEmbed />} />
7278
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
7379
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ describe('<LibraryAuthoringPage />', () => {
392392
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
393393
});
394394

395-
it('should open component sidebar, showing manage tab on clicking add to collection menu item', async () => {
395+
it('should open component sidebar, showing manage tab on clicking add to collection menu item (component)', async () => {
396396
const mockResult0 = { ...mockResult }.results[0].hits[0];
397397
const displayName = 'Introduction to Testing';
398398
expect(mockResult0.display_name).toStrictEqual(displayName);
@@ -417,6 +417,29 @@ describe('<LibraryAuthoringPage />', () => {
417417
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
418418
});
419419

420+
it('should open component sidebar, showing manage tab on clicking add to collection menu item (unit)', async () => {
421+
const displayName = 'Test Unit';
422+
await renderLibraryPage();
423+
424+
waitFor(() => expect(screen.getAllByTestId('container-card-menu-toggle').length).toBeGreaterThan(0));
425+
426+
// Open menu
427+
fireEvent.click((await screen.findAllByTestId('container-card-menu-toggle'))[0]);
428+
// Click add to collection
429+
fireEvent.click(screen.getByRole('button', { name: 'Add to collection' }));
430+
431+
const sidebar = screen.getByTestId('library-sidebar');
432+
433+
const { getByRole, queryByText } = within(sidebar);
434+
435+
await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument());
436+
expect(getByRole('tab', { selected: true })).toHaveTextContent('Organize');
437+
const closeButton = getByRole('button', { name: /close/i });
438+
fireEvent.click(closeButton);
439+
440+
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
441+
});
442+
420443
it('should open and close the collection sidebar', async () => {
421444
await renderLibraryPage();
422445

@@ -732,7 +755,7 @@ describe('<LibraryAuthoringPage />', () => {
732755
fireEvent.click(cancelButton);
733756
expect(unitModalHeading).not.toBeInTheDocument();
734757

735-
// Open new unit modal again and create a collection
758+
// Open new unit modal again and create a unit
736759
fireEvent.click(newUnitButton);
737760
const createButton = screen.getByRole('button', { name: /create/i });
738761
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
@@ -800,7 +823,7 @@ describe('<LibraryAuthoringPage />', () => {
800823
fireEvent.click(newButton);
801824
expect(screen.getByText(/add content/i)).toBeInTheDocument();
802825

803-
// Open New collection Modal
826+
// Open New Unit Modal
804827
const sidebar = screen.getByTestId('library-sidebar');
805828
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
806829
fireEvent.click(newUnitButton);

src/library-authoring/LibraryAuthoringPage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
141141
libraryData,
142142
isLoadingLibraryData,
143143
showOnlyPublished,
144+
extraFilter: contextExtraFilter,
144145
componentId,
145146
collectionId,
146147
unitId,
@@ -223,6 +224,10 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
223224
extraFilter.push('last_published IS NOT NULL');
224225
}
225226

227+
if (contextExtraFilter) {
228+
extraFilter.push(...contextExtraFilter);
229+
}
230+
226231
const activeTypeFilters = {
227232
components: 'type = "library_block"',
228233
collections: 'type = "collection"',

src/library-authoring/__mocks__/collection-search.json

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,45 @@
218218
"org": "OpenedX",
219219
"access_id": 16,
220220
"num_children": 1
221+
},
222+
{
223+
"display_name": "Test Unit",
224+
"block_id": "test-unit-9284e2",
225+
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
226+
"type": "library_container",
227+
"breadcrumbs": [
228+
{
229+
"display_name": "Test Library"
230+
}
231+
],
232+
"created": 1742221203.895054,
233+
"modified": 1742221203.895054,
234+
"usage_key": "lct:Axim:TEST:unit:test-unit-9284e2",
235+
"block_type": "unit",
236+
"context_key": "lib:Axim:TEST",
237+
"org": "Axim",
238+
"access_id": 15,
239+
"num_children": 0,
240+
"_formatted": {
241+
"display_name": "Test Unit",
242+
"block_id": "test-unit-9284e2",
243+
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
244+
"type": "library_container",
245+
"breadcrumbs": [
246+
{
247+
"display_name": "Test Library"
248+
}
249+
],
250+
"created": "1742221203.895054",
251+
"modified": "1742221203.895054",
252+
"usage_key": "lct:Axim:TEST:unit:test-unit-9284e2",
253+
"block_type": "unit",
254+
"context_key": "lib:Axim:TEST",
255+
"org": "Axim",
256+
"access_id": "15",
257+
"num_children": "0"
258+
}
221259
}
222-
223260
],
224261
"query": "",
225262
"processingTimeMs": 1,

src/library-authoring/add-content/AddContent.test.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import {
1212
mockXBlockFields,
1313
} from '../data/api.mocks';
1414
import {
15-
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
16-
getXBlockFieldsApiUrl, getLibraryContainerChildrenApiUrl,
15+
getContentLibraryApiUrl,
16+
getCreateLibraryBlockUrl,
17+
getLibraryCollectionItemsApiUrl,
18+
getLibraryContainerChildrenApiUrl,
19+
getLibraryPasteClipboardUrl,
20+
getXBlockFieldsApiUrl,
1721
} from '../data/api';
1822
import { mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
1923
import { LibraryProvider } from '../common/context/LibraryContext';
@@ -151,7 +155,7 @@ describe('<AddContent />', () => {
151155
const url = getCreateLibraryBlockUrl(libraryId);
152156
const usageKey = mockXBlockFields.usageKeyNewHtml;
153157
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
154-
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
158+
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
155159
libraryId,
156160
collectionId,
157161
);
@@ -209,7 +213,7 @@ describe('<AddContent />', () => {
209213

210214
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
211215
const collectionId = 'some-collection-id';
212-
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
216+
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
213217
libraryId,
214218
collectionId,
215219
);
@@ -234,7 +238,7 @@ describe('<AddContent />', () => {
234238

235239
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
236240
const collectionId = 'some-collection-id';
237-
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
241+
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
238242
libraryId,
239243
collectionId,
240244
);

src/library-authoring/add-content/AddContent.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import { getCanEdit } from '../../course-unit/data/selectors';
2121
import {
2222
useCreateLibraryBlock,
2323
useLibraryPasteClipboard,
24-
useAddComponentsToCollection,
2524
useBlockTypesMetadata,
25+
useAddItemsToCollection,
2626
useAddComponentsToContainer,
2727
} from '../data/apiHooks';
2828
import { useLibraryContext } from '../common/context/LibraryContext';
@@ -207,8 +207,8 @@ const AddContent = () => {
207207
openComponentEditor,
208208
unitId,
209209
} = useLibraryContext();
210-
const addComponentsToCollectionMutation = useAddComponentsToCollection(libraryId, collectionId);
211-
const addComponentsToContainerMutation = useAddComponentsToContainer(libraryId, unitId);
210+
const addComponentsToCollectionMutation = useAddItemsToCollection(libraryId, collectionId);
211+
const addComponentsToContainerMutation = useAddComponentsToContainer(unitId);
212212
const createBlockMutation = useCreateLibraryBlock();
213213
const pasteClipboardMutation = useLibraryPasteClipboard();
214214
const { showToast } = useContext(ToastContext);
@@ -286,14 +286,14 @@ const AddContent = () => {
286286
contentTypes.push(pasteButton);
287287
}
288288

289-
const linkComponent = (usageKey: string) => {
289+
const linkComponent = (opaqueKey: string) => {
290290
if (collectionId) {
291-
addComponentsToCollectionMutation.mutateAsync([usageKey]).catch(() => {
291+
addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => {
292292
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
293293
});
294294
}
295295
if (unitId) {
296-
addComponentsToContainerMutation.mutateAsync([usageKey]).catch(() => {
296+
addComponentsToContainerMutation.mutateAsync([opaqueKey]).catch(() => {
297297
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
298298
});
299299
}

src/library-authoring/add-content/PickLibraryContentModal.test.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ describe('<PickLibraryContentModal />', () => {
4949
});
5050

5151
it('can pick components from the modal', async () => {
52-
const mockAddComponentsToCollection = jest.fn();
53-
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
52+
const mockAddItemsToCollection = jest.fn();
53+
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
5454

5555
render();
5656

@@ -67,7 +67,7 @@ describe('<PickLibraryContentModal />', () => {
6767
fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
6868

6969
await waitFor(() => {
70-
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
70+
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
7171
libraryId,
7272
'collectionId',
7373
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
@@ -78,8 +78,8 @@ describe('<PickLibraryContentModal />', () => {
7878
});
7979

8080
it('show error when api call fails', async () => {
81-
const mockAddComponentsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components'));
82-
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
81+
const mockAddItemsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components'));
82+
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
8383
render();
8484

8585
// Wait for the content library to load
@@ -95,7 +95,7 @@ describe('<PickLibraryContentModal />', () => {
9595
fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
9696

9797
await waitFor(() => {
98-
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
98+
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
9999
libraryId,
100100
'collectionId',
101101
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],

src/library-authoring/add-content/PickLibraryContentModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ActionRow, Button, StandardModal } from '@openedx/paragon';
55
import { ToastContext } from '../../generic/toast-context';
66
import { useLibraryContext } from '../common/context/LibraryContext';
77
import type { SelectedComponent } from '../common/context/ComponentPickerContext';
8-
import { useAddComponentsToCollection } from '../data/apiHooks';
8+
import { useAddItemsToCollection } from '../data/apiHooks';
99
import messages from './messages';
1010

1111
interface PickLibraryContentModalFooterProps {
@@ -51,7 +51,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
5151
throw new Error('libraryId and componentPicker are required');
5252
}
5353

54-
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
54+
const updateComponentsMutation = useAddItemsToCollection(libraryId, collectionId);
5555

5656
const { showToast } = useContext(ToastContext);
5757

src/library-authoring/collections/LibraryCollectionPage.test.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ import {
1515
mockContentLibrary,
1616
mockXBlockFields,
1717
mockGetCollectionMetadata,
18+
mockGetContainerMetadata,
1819
} from '../data/api.mocks';
1920
import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
2021
import { mockClipboardEmpty } from '../../generic/data/api.mock';
2122
import { LibraryLayout } from '..';
2223
import { ContentTagsDrawer } from '../../content-tags-drawer';
23-
import { getLibraryCollectionComponentApiUrl } from '../data/api';
24+
import { getLibraryCollectionItemsApiUrl } from '../data/api';
2425

2526
let axiosMock: MockAdapter;
2627
let mockShowToast;
@@ -31,6 +32,7 @@ mockContentSearchConfig.applyMock();
3132
mockGetBlockTypes.applyMock();
3233
mockContentLibrary.applyMock();
3334
mockXBlockFields.applyMock();
35+
mockGetContainerMetadata.applyMock();
3436

3537
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
3638
const path = '/library/:libraryId/*';
@@ -350,7 +352,7 @@ describe('<LibraryCollectionPage />', () => {
350352
});
351353

352354
it('should remove component from collection and hides sidebar', async () => {
353-
const url = getLibraryCollectionComponentApiUrl(
355+
const url = getLibraryCollectionItemsApiUrl(
354356
mockContentLibrary.libraryId,
355357
mockCollection.collectionId,
356358
);
@@ -369,8 +371,38 @@ describe('<LibraryCollectionPage />', () => {
369371
fireEvent.click(await screen.findByText('Remove from collection'));
370372
await waitFor(() => {
371373
expect(axiosMock.history.delete.length).toEqual(1);
372-
expect(mockShowToast).toHaveBeenCalledWith('Component successfully removed');
373374
});
375+
expect(mockShowToast).toHaveBeenCalledWith('Item successfully removed');
376+
// Should close sidebar as component was removed
377+
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
378+
});
379+
380+
it('should remove unit from collection and hides sidebar', async () => {
381+
const url = getLibraryCollectionItemsApiUrl(
382+
mockContentLibrary.libraryId,
383+
mockCollection.collectionId,
384+
);
385+
axiosMock.onDelete(url).reply(204);
386+
const displayName = 'Test Unit';
387+
await renderLibraryCollectionPage();
388+
389+
// Wait for the unit cards to load
390+
waitFor(() => expect(screen.getAllByTestId('container-card-menu-toggle').length).toBeGreaterThan(0));
391+
392+
// open sidebar
393+
fireEvent.click(await screen.findByText(displayName));
394+
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).toBeInTheDocument());
395+
396+
// Open menu
397+
fireEvent.click((await screen.findAllByTestId('container-card-menu-toggle'))[0]);
398+
399+
// Click remove to collection
400+
fireEvent.click(screen.getByRole('button', { name: 'Remove from collection' }));
401+
402+
await waitFor(() => {
403+
expect(axiosMock.history.delete.length).toEqual(1);
404+
});
405+
expect(mockShowToast).toHaveBeenCalledWith('Item successfully removed');
374406
// Should close sidebar as component was removed
375407
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
376408
});

0 commit comments

Comments
 (0)