Skip to content

Commit 632adbb

Browse files
committed
feat: add existing components to unit
1 parent c3dc8a1 commit 632adbb

File tree

5 files changed

+140
-82
lines changed

5 files changed

+140
-82
lines changed

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

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,25 +116,25 @@ const AddContentView = ({
116116

117117
return (
118118
<>
119-
{upstreamContainerType !== ContainerType.Unit && (
119+
{(collectionId || unitId) && componentPicker && (
120+
/// Show the "Add Library Content" button for units and collections
120121
<>
121-
{collectionId ? (
122-
componentPicker && (
123-
<>
124-
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
125-
<PickLibraryContentModal
126-
isOpen={isAddLibraryContentModalOpen}
127-
onClose={closeAddLibraryContentModal}
128-
/>
129-
</>
130-
)
131-
) : (
132-
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
133-
)}
134-
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
135-
<hr className="w-100 bg-gray-500" />
122+
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
123+
<PickLibraryContentModal
124+
isOpen={isAddLibraryContentModalOpen}
125+
onClose={closeAddLibraryContentModal}
126+
/>
136127
</>
137128
)}
129+
{!collectionId && !unitId && (
130+
// Doesn't show the "Collection" button if we are in a unit or collection
131+
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
132+
)}
133+
{upstreamContainerType !== ContainerType.Unit && (
134+
// Doesn't show the "Unit" button if we are in a unit
135+
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
136+
)}
137+
<hr className="w-100 bg-gray-500" />
138138
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
139139
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
140140
<AddContentButton

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

Lines changed: 81 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,20 @@ const { libraryId } = mockContentLibrary;
2828
const onClose = jest.fn();
2929
let mockShowToast: (message: string) => void;
3030

31-
const render = () => baseRender(<PickLibraryContentModal isOpen onClose={onClose} />, {
32-
path: '/library/:libraryId/collection/:collectionId/*',
33-
params: { libraryId, collectionId: 'collectionId' },
31+
const mockAddComponentsToCollection = jest.fn();
32+
const mockAddComponentsToContainer = jest.fn();
33+
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
34+
jest.spyOn(api, 'addComponentsToContainer').mockImplementation(mockAddComponentsToContainer);
35+
36+
const render = (context: 'collection' | 'unit') => baseRender(<PickLibraryContentModal isOpen onClose={onClose} />, {
37+
path: context === 'collection'
38+
? '/library/:libraryId/collection/:collectionId/*'
39+
: '/library/:libraryId/container/:unitId/*',
40+
params: {
41+
libraryId,
42+
...(context === 'collection' && { collectionId: 'collectionId' }),
43+
...(context === 'unit' && { unitId: 'unitId' }),
44+
},
3445
extraWrapper: ({ children }) => (
3546
<LibraryProvider
3647
libraryId={libraryId}
@@ -46,62 +57,80 @@ describe('<PickLibraryContentModal />', () => {
4657
const mocks = initializeMocks();
4758
mockShowToast = mocks.mockShowToast;
4859
mocks.axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
60+
jest.clearAllMocks();
4961
});
5062

51-
it('can pick components from the modal', async () => {
52-
const mockAddComponentsToCollection = jest.fn();
53-
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
54-
55-
render();
56-
57-
// Wait for the content library to load
58-
await waitFor(() => {
59-
expect(screen.getByText('Test Library')).toBeInTheDocument();
60-
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
61-
});
62-
63-
// Select the first component
64-
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
65-
expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
66-
67-
fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
68-
69-
await waitFor(() => {
70-
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
71-
libraryId,
72-
'collectionId',
73-
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
74-
);
63+
['collection' as const, 'unit' as const].forEach((context) => {
64+
it(`can pick components from the modal (${context})`, async () => {
65+
render(context);
66+
67+
// Wait for the content library to load
68+
await waitFor(() => {
69+
expect(screen.getByText('Test Library')).toBeInTheDocument();
70+
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
71+
});
72+
73+
// Select the first component
74+
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
75+
expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
76+
77+
fireEvent.click(screen.getByRole('button', { name: /add to .*/i }));
78+
79+
await waitFor(() => {
80+
if (context === 'collection') {
81+
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
82+
libraryId,
83+
'collectionId',
84+
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
85+
);
86+
} else {
87+
expect(mockAddComponentsToContainer).toHaveBeenCalledWith(
88+
'unitId',
89+
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
90+
);
91+
}
92+
});
7593
expect(onClose).toHaveBeenCalled();
7694
expect(mockShowToast).toHaveBeenCalledWith('Content linked successfully.');
7795
});
78-
});
79-
80-
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);
83-
render();
84-
85-
// Wait for the content library to load
86-
await waitFor(() => {
87-
expect(screen.getByText('Test Library')).toBeInTheDocument();
88-
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
89-
});
90-
91-
// Select the first component
92-
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
93-
expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
94-
95-
fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
9696

97-
await waitFor(() => {
98-
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
99-
libraryId,
100-
'collectionId',
101-
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
102-
);
97+
it(`show error when api call fails (${context})`, async () => {
98+
if (context === 'collection') {
99+
mockAddComponentsToCollection.mockRejectedValueOnce(new Error('Error'));
100+
} else {
101+
mockAddComponentsToContainer.mockRejectedValueOnce(new Error('Error'));
102+
}
103+
render(context);
104+
105+
// Wait for the content library to load
106+
await waitFor(() => {
107+
expect(screen.getByText('Test Library')).toBeInTheDocument();
108+
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
109+
});
110+
111+
// Select the first component
112+
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
113+
expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
114+
115+
fireEvent.click(screen.getByRole('button', { name: /add to .*/i }));
116+
117+
await waitFor(() => {
118+
if (context === 'collection') {
119+
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
120+
libraryId,
121+
'collectionId',
122+
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
123+
);
124+
} else {
125+
expect(mockAddComponentsToContainer).toHaveBeenCalledWith(
126+
'unitId',
127+
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
128+
);
129+
}
130+
});
103131
expect(onClose).toHaveBeenCalled();
104-
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.');
132+
const name = context === 'collection' ? 'collection' : 'container';
133+
expect(mockShowToast).toHaveBeenCalledWith(`There was an error linking the content to this ${name}.`);
105134
});
106135
});
107136
});

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

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,25 @@ 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 { useAddComponentsToCollection, useAddComponentsToContainer } from '../data/apiHooks';
99
import messages from './messages';
1010

1111
interface PickLibraryContentModalFooterProps {
1212
onSubmit: () => void;
1313
selectedComponents: SelectedComponent[];
14+
buttonText: React.ReactNode;
1415
}
1516

1617
const PickLibraryContentModalFooter: React.FC<PickLibraryContentModalFooterProps> = ({
1718
onSubmit,
1819
selectedComponents,
20+
buttonText,
1921
}) => (
2022
<ActionRow>
2123
<FormattedMessage {...messages.selectedComponents} values={{ count: selectedComponents.length }} />
2224
<ActionRow.Spacer />
2325
<Button variant="primary" onClick={onSubmit}>
24-
<FormattedMessage {...messages.addToCollectionButton} />
26+
{buttonText}
2527
</Button>
2628
</ActionRow>
2729
);
@@ -40,18 +42,20 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
4042
const {
4143
libraryId,
4244
collectionId,
45+
unitId,
4346
/** We need to get it as a reference instead of directly importing it to avoid the import cycle:
4447
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
4548
* Sidebar > AddContent > ComponentPicker */
4649
componentPicker: ComponentPicker,
4750
} = useLibraryContext();
4851

4952
// istanbul ignore if: this should never happen
50-
if (!collectionId || !ComponentPicker) {
51-
throw new Error('libraryId and componentPicker are required');
53+
if (!(collectionId || unitId) || !ComponentPicker) {
54+
throw new Error('collectionId/unitId and componentPicker are required');
5255
}
5356

54-
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
57+
const updateCollectionItemsMutation = useAddComponentsToCollection(libraryId, collectionId);
58+
const updateUnitComponentsMutation = useAddComponentsToContainer(libraryId, unitId);
5559

5660
const { showToast } = useContext(ToastContext);
5761

@@ -60,13 +64,23 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
6064
const onSubmit = useCallback(() => {
6165
const usageKeys = selectedComponents.map(({ usageKey }) => usageKey);
6266
onClose();
63-
updateComponentsMutation.mutateAsync(usageKeys)
64-
.then(() => {
65-
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
66-
})
67-
.catch(() => {
68-
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
69-
});
67+
if (collectionId) {
68+
updateCollectionItemsMutation.mutateAsync(usageKeys)
69+
.then(() => {
70+
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
71+
})
72+
.catch(() => {
73+
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
74+
});
75+
} else if (unitId) {
76+
updateUnitComponentsMutation.mutateAsync(usageKeys)
77+
.then(() => {
78+
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
79+
})
80+
.catch(() => {
81+
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
82+
});
83+
}
7084
}, [selectedComponents]);
7185

7286
return (
@@ -76,7 +90,16 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
7690
size="xl"
7791
isOpen={isOpen}
7892
onClose={onClose}
79-
footerNode={<PickLibraryContentModalFooter onSubmit={onSubmit} selectedComponents={selectedComponents} />}
93+
footerNode={(
94+
<PickLibraryContentModalFooter
95+
onSubmit={onSubmit}
96+
selectedComponents={selectedComponents}
97+
buttonText={(collectionId
98+
? intl.formatMessage(messages.addToCollectionButton)
99+
: intl.formatMessage(messages.addToUnitButton)
100+
)}
101+
/>
102+
)}
80103
>
81104
<ComponentPicker
82105
libraryId={libraryId}

src/library-authoring/add-content/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const messages = defineMessages({
2121
defaultMessage: 'Add to Collection',
2222
description: 'Button to add library content to a collection.',
2323
},
24+
addToUnitButton: {
25+
id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-unit',
26+
defaultMessage: 'Add to Unit',
27+
description: 'Button to add library content to a unit.',
28+
},
2429
selectedComponents: {
2530
id: 'course-authoring.library-authoring.add-content.selected-components',
2631
defaultMessage: '{count, plural, one {# Selected Component} other {# Selected Components}}',

src/library-authoring/data/apiHooks.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,14 @@ describe('library api hooks', () => {
261261
});
262262

263263
it('should add components to container', async () => {
264+
const libraryId = 'lib:org:1';
264265
const componentId = 'lb:org:lib:html:1';
265266
const containerId = 'ltc:org:lib:unit:1';
266267

267268
const url = getLibraryContainerChildrenApiUrl(containerId);
268269

269270
axiosMock.onPost(url).reply(200);
270-
const { result } = renderHook(() => useAddComponentsToContainer(containerId), { wrapper });
271+
const { result } = renderHook(() => useAddComponentsToContainer(libraryId, containerId), { wrapper });
271272
await result.current.mutateAsync([componentId]);
272273

273274
expect(axiosMock.history.post[0].url).toEqual(url);

0 commit comments

Comments
 (0)