Skip to content

Commit d9dcdfe

Browse files
authored
feat: add existing components to unit [FC-0083] (#1811)
allows adding existing components to units
1 parent 990073c commit d9dcdfe

File tree

8 files changed

+195
-120
lines changed

8 files changed

+195
-120
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 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/containers/UnitInfo.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useToggle,
1111
} from '@openedx/paragon';
1212
import { useEffect, useCallback } from 'react';
13+
import { Link } from 'react-router-dom';
1314
import { MoreVert } from '@openedx/paragon/icons';
1415

1516
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
@@ -70,7 +71,7 @@ const UnitMenu = ({ containerId, displayName }: ContainerMenuProps) => {
7071
const UnitInfo = () => {
7172
const intl = useIntl();
7273

73-
const { setUnitId } = useLibraryContext();
74+
const { libraryId } = useLibraryContext();
7475
const { componentPickerMode } = useComponentPickerContext();
7576
const {
7677
defaultTab,
@@ -81,7 +82,7 @@ const UnitInfo = () => {
8182
sidebarAction,
8283
} = useSidebarContext();
8384
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
84-
const { insideUnit, navigateTo } = useLibraryRoutes();
85+
const { insideUnit } = useLibraryRoutes();
8586

8687
const tab: UnitInfoTab = (
8788
sidebarTab && isUnitInfoTab(sidebarTab)
@@ -90,15 +91,7 @@ const UnitInfo = () => {
9091
const unitId = sidebarComponentInfo?.id;
9192
const { data: container } = useContainer(unitId);
9293

93-
const handleOpenUnit = useCallback(() => {
94-
if (componentPickerMode) {
95-
setUnitId(unitId);
96-
} else {
97-
navigateTo({ unitId });
98-
}
99-
}, [componentPickerMode, navigateTo, unitId]);
100-
101-
const showOpenUnitButton = !insideUnit || componentPickerMode;
94+
const showOpenUnitButton = !insideUnit && !componentPickerMode;
10295

10396
const renderTab = useCallback((infoTab: UnitInfoTab, component: React.ReactNode, title: string) => {
10497
if (hiddenTabs.includes(infoTab)) {
@@ -130,7 +123,8 @@ const UnitInfo = () => {
130123
<Button
131124
variant="outline-primary"
132125
className="m-1 text-nowrap flex-grow-1"
133-
onClick={handleOpenUnit}
126+
as={Link}
127+
to={`/library/${libraryId}/unit/${unitId}`}
134128
>
135129
{intl.formatMessage(messages.openUnitButton)}
136130
</Button>
@@ -147,7 +141,7 @@ const UnitInfo = () => {
147141
activeKey={tab}
148142
onSelect={setSidebarTab}
149143
>
150-
{renderTab(UNIT_INFO_TABS.Preview, <LibraryUnitBlocks />, intl.formatMessage(messages.previewTabTitle))}
144+
{renderTab(UNIT_INFO_TABS.Preview, <LibraryUnitBlocks preview />, intl.formatMessage(messages.previewTabTitle))}
151145
{renderTab(UNIT_INFO_TABS.Organize, <ContainerOrganize />, intl.formatMessage(messages.organizeTabTitle))}
152146
{renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))}
153147
</Tabs>

0 commit comments

Comments
 (0)