Skip to content

Commit 99e8946

Browse files
committed
feat: add existing components to unit
1 parent aa8a5bf commit 99e8946

7 files changed

Lines changed: 160 additions & 83 deletions

File tree

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 mockAddItemsToCollection = jest.fn();
32+
const mockAddComponentsToContainer = jest.fn();
33+
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
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 mockAddItemsToCollection = jest.fn();
53-
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
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(mockAddItemsToCollection).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(mockAddItemsToCollection).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 mockAddItemsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components'));
82-
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
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(mockAddItemsToCollection).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+
mockAddItemsToCollection.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(mockAddItemsToCollection).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: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,59 @@ 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 { useAddItemsToCollection } from '../data/apiHooks';
8+
import { useAddItemsToCollection, 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
);
2830

2931
interface PickLibraryContentModalProps {
3032
isOpen: boolean;
3133
onClose: () => void;
34+
extraFilter?: string[];
3235
}
3336

3437
export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ({
3538
isOpen,
3639
onClose,
40+
extraFilter,
3741
}) => {
3842
const intl = useIntl();
3943

4044
const {
4145
libraryId,
4246
collectionId,
47+
unitId,
4348
/** We need to get it as a reference instead of directly importing it to avoid the import cycle:
4449
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
4550
* Sidebar > AddContent > ComponentPicker */
4651
componentPicker: ComponentPicker,
4752
} = useLibraryContext();
4853

4954
// istanbul ignore if: this should never happen
50-
if (!collectionId || !ComponentPicker) {
51-
throw new Error('libraryId and componentPicker are required');
55+
if (!(collectionId || unitId) || !ComponentPicker) {
56+
throw new Error('collectionId/unitId and componentPicker are required');
5257
}
5358

54-
const updateComponentsMutation = useAddItemsToCollection(libraryId, collectionId);
59+
const updateCollectionItemsMutation = useAddItemsToCollection(libraryId, collectionId);
60+
const updateUnitComponentsMutation = useAddComponentsToContainer(unitId);
5561

5662
const { showToast } = useContext(ToastContext);
5763

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

7289
return (
@@ -76,12 +93,22 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
7693
size="xl"
7794
isOpen={isOpen}
7895
onClose={onClose}
79-
footerNode={<PickLibraryContentModalFooter onSubmit={onSubmit} selectedComponents={selectedComponents} />}
96+
footerNode={(
97+
<PickLibraryContentModalFooter
98+
onSubmit={onSubmit}
99+
selectedComponents={selectedComponents}
100+
buttonText={(collectionId
101+
? intl.formatMessage(messages.addToCollectionButton)
102+
: intl.formatMessage(messages.addToUnitButton)
103+
)}
104+
/>
105+
)}
80106
>
81107
<ComponentPicker
82108
libraryId={libraryId}
83109
componentPickerMode="multiple"
84110
onChangeComponentSelection={setSelectedComponents}
111+
extraFilter={extraFilter}
85112
/>
86113
</StandardModal>
87114
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default as AddContent } from './AddContent';
22
export { default as AddContentHeader } from './AddContentHeader';
3+
export { PickLibraryContentModal } from './PickLibraryContentModal';

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/units/LibraryUnitBlocks.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
1717
import Loading from '../../generic/Loading';
1818
import TagCount from '../../generic/tag-count';
1919
import { useLibraryContext } from '../common/context/LibraryContext';
20+
import { PickLibraryContentModal } from '../add-content';
2021
import ComponentMenu from '../components';
2122
import { LibraryBlockMetadata } from '../data/api';
2223
import { libraryAuthoringQueryKeys, useContainerChildren } from '../data/apiHooks';
@@ -38,6 +39,8 @@ export const LibraryUnitBlocks = () => {
3839
const intl = useIntl();
3940
const [orderedBlocks, setOrderedBlocks] = useState<LibraryBlockMetadata[]>([]);
4041
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
42+
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
43+
4144
const { navigateTo } = useLibraryRoutes();
4245

4346
const {
@@ -148,6 +151,7 @@ export const LibraryUnitBlocks = () => {
148151
</IframeProvider>
149152
));
150153

154+
151155
return (
152156
<div className="library-unit-page">
153157
<DraggableList itemList={orderedBlocks} setState={setOrderedBlocks} updateOrder={handleReorder}>
@@ -171,11 +175,17 @@ export const LibraryUnitBlocks = () => {
171175
className="ml-2"
172176
iconBefore={Add}
173177
variant="outline-primary rounded-0"
174-
disabled
178+
disabled={readOnly}
179+
onClick={showAddLibraryContentModal}
175180
block
176181
>
177182
{intl.formatMessage(messages.addExistingContentButton)}
178183
</Button>
184+
<PickLibraryContentModal
185+
isOpen={isAddLibraryContentModalOpen}
186+
onClose={closeAddLibraryContentModal}
187+
extraFilter={['NOT block_type = "unit"']}
188+
/>
179189
</div>
180190
</div>
181191
<ContentTagsDrawerSheet

src/library-authoring/units/LibraryUnitPage.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { useIntl } from '@edx/frontend-platform/i18n';
2-
import { Breadcrumb, Button, Container } from '@openedx/paragon';
2+
import {
3+
Breadcrumb,
4+
Button,
5+
Container,
6+
useToggle,
7+
} from '@openedx/paragon';
38
import { Add, InfoOutline } from '@openedx/paragon/icons';
49
import { useCallback, useEffect } from 'react';
510
import { Helmet } from 'react-helmet';

0 commit comments

Comments
 (0)