Skip to content

Commit a522c48

Browse files
authored
feat: Add component to Unit [FC-0083] (#1784)
Creation workflow in unit page.
1 parent f46e4ce commit a522c48

19 files changed

Lines changed: 288 additions & 41 deletions

File tree

src/editors/containers/EditorContainer/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { useEditorContext } from '../../EditorContext';
1818
import TitleHeader from './components/TitleHeader';
1919
import * as hooks from './hooks';
2020
import messages from './messages';
21-
import { parseErrorMsg } from '../../../library-authoring/add-content/AddContentContainer';
21+
import { parseErrorMsg } from '../../../library-authoring/add-content/AddContent';
2222
import libraryMessages from '../../../library-authoring/add-content/messages';
2323

2424
import './index.scss';

src/generic/key-utils.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
getLibraryId,
55
isLibraryKey,
66
isLibraryV1Key,
7+
getContainerTypeFromId,
8+
ContainerType,
79
} from './key-utils';
810

911
describe('component utils', () => {
@@ -97,4 +99,16 @@ describe('component utils', () => {
9799
});
98100
}
99101
});
102+
103+
describe('getContainerTypeFromId', () => {
104+
for (const [input, expected] of [
105+
['lct:org:lib:unit:my-unit-9284e2', ContainerType.Unit],
106+
['lct:OpenCraftX:ALPHA:my-unit-a3223f', undefined],
107+
['', undefined],
108+
]) {
109+
it(`returns '${expected}' for container key '${input}'`, () => {
110+
expect(getContainerTypeFromId(input!)).toStrictEqual(expected);
111+
});
112+
}
113+
});
100114
});

src/generic/key-utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,26 @@ export const buildCollectionUsageKey = (learningContextKey: string, collectionId
4949
const orgLib = learningContextKey.replace('lib:', '');
5050
return `lib-collection:${orgLib}:${collectionId}`;
5151
};
52+
53+
export enum ContainerType {
54+
Unit = 'unit',
55+
}
56+
57+
/**
58+
* Given a container key like `ltc:org:lib:unit:id`
59+
* get the container type
60+
*/
61+
export function getContainerTypeFromId(containerId: string): ContainerType | undefined {
62+
const parts = containerId.split(':');
63+
if (parts.length < 2) {
64+
return undefined;
65+
}
66+
67+
const maybeType = parts[parts.length - 2];
68+
69+
if (Object.values(ContainerType).includes(maybeType as ContainerType)) {
70+
return maybeType as ContainerType;
71+
}
72+
73+
return undefined;
74+
}

src/library-authoring/LibraryLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const LibraryLayout = () => {
4343
/** The component picker modal to use. We need to pass it as a reference instead of
4444
* directly importing it to avoid the import cycle:
4545
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
46-
* Sidebar > AddContentContainer > ComponentPicker */
46+
* Sidebar > AddContent > ComponentPicker */
4747
componentPicker={ComponentPicker}
4848
>
4949
<SidebarProvider>

src/library-authoring/add-content/AddContentContainer.test.tsx renamed to src/library-authoring/add-content/AddContent.test.tsx

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ import {
1313
} from '../data/api.mocks';
1414
import {
1515
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
16-
getXBlockFieldsApiUrl,
16+
getXBlockFieldsApiUrl, getLibraryContainerChildrenApiUrl,
1717
} from '../data/api';
1818
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
1919
import { LibraryProvider } from '../common/context/LibraryContext';
20-
import AddContentContainer from './AddContentContainer';
20+
import AddContent from './AddContent';
2121
import { ComponentEditorModal } from '../components/ComponentEditorModal';
2222
import editorCmsApi from '../../editors/data/services/cms/api';
2323
import { ToastActionData } from '../../generic/toast-context';
@@ -32,7 +32,7 @@ jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCs
3232
const { libraryId } = mockContentLibrary;
3333
const render = (collectionId?: string) => {
3434
const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId };
35-
return baseRender(<AddContentContainer />, {
35+
return baseRender(<AddContent />, {
3636
path: '/library/:libraryId/:collectionId?',
3737
params,
3838
extraWrapper: ({ children }) => (
@@ -45,10 +45,25 @@ const render = (collectionId?: string) => {
4545
),
4646
});
4747
};
48+
const renderWithUnit = (unitId: string) => {
49+
const params: { libraryId: string, unitId?: string } = { libraryId, unitId };
50+
return baseRender(<AddContent />, {
51+
path: '/library/:libraryId/:unitId?',
52+
params,
53+
extraWrapper: ({ children }) => (
54+
<LibraryProvider
55+
libraryId={libraryId}
56+
>
57+
{ children }
58+
<ComponentEditorModal />
59+
</LibraryProvider>
60+
),
61+
});
62+
};
4863
let axiosMock: MockAdapter;
4964
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
5065

51-
describe('<AddContentContainer />', () => {
66+
describe('<AddContent />', () => {
5267
beforeEach(() => {
5368
const mocks = initializeMocks();
5469
axiosMock = mocks.axiosMock;
@@ -290,4 +305,71 @@ describe('<AddContentContainer />', () => {
290305
expect(mockShowToast).toHaveBeenCalledWith(expectedError);
291306
});
292307
});
308+
309+
it('should not show collection/unit buttons when create component in container', async () => {
310+
const unitId = 'lct:orf1:lib1:unit:test-1';
311+
renderWithUnit(unitId);
312+
313+
expect(await screen.findByRole('button', { name: 'Text' })).toBeInTheDocument();
314+
315+
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
316+
expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument();
317+
});
318+
319+
it('should create a component in unit', async () => {
320+
const unitId = 'lct:orf1:lib1:unit:test-1';
321+
const usageKey = mockXBlockFields.usageKeyNewHtml;
322+
const createUrl = getCreateLibraryBlockUrl(libraryId);
323+
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
324+
const linkUrl = getLibraryContainerChildrenApiUrl(unitId);
325+
326+
axiosMock.onPost(createUrl).reply(200, {
327+
id: usageKey,
328+
});
329+
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
330+
axiosMock.onPost(linkUrl).reply(200);
331+
332+
renderWithUnit(unitId);
333+
334+
const textButton = screen.getByRole('button', { name: /text/i });
335+
fireEvent.click(textButton);
336+
337+
// Component should be linked to Unit on saving the changes in the editor.
338+
const saveButton = screen.getByLabelText('Save changes and return to learning context');
339+
fireEvent.click(saveButton);
340+
341+
await waitFor(() => expect(axiosMock.history.post.length).toEqual(3));
342+
expect(axiosMock.history.post[0].url).toEqual(createUrl);
343+
expect(axiosMock.history.post[1].url).toEqual(updateBlockUrl);
344+
expect(axiosMock.history.post[2].url).toEqual(linkUrl);
345+
});
346+
347+
it('should show error on create a component in unit', async () => {
348+
const unitId = 'lct:orf1:lib1:unit:test-1';
349+
const usageKey = mockXBlockFields.usageKeyNewHtml;
350+
const createUrl = getCreateLibraryBlockUrl(libraryId);
351+
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
352+
const linkUrl = getLibraryContainerChildrenApiUrl(unitId);
353+
354+
axiosMock.onPost(createUrl).reply(200, {
355+
id: usageKey,
356+
});
357+
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
358+
axiosMock.onPost(linkUrl).reply(400);
359+
360+
renderWithUnit(unitId);
361+
362+
const textButton = screen.getByRole('button', { name: /text/i });
363+
fireEvent.click(textButton);
364+
365+
const saveButton = screen.getByLabelText('Save changes and return to learning context');
366+
fireEvent.click(saveButton);
367+
368+
await waitFor(() => expect(axiosMock.history.post.length).toEqual(3));
369+
expect(axiosMock.history.post[0].url).toEqual(createUrl);
370+
expect(axiosMock.history.post[1].url).toEqual(updateBlockUrl);
371+
expect(axiosMock.history.post[2].url).toEqual(linkUrl);
372+
373+
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this container.');
374+
});
293375
});

src/library-authoring/add-content/AddContentContainer.tsx renamed to src/library-authoring/add-content/AddContent.tsx

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ import {
2323
useLibraryPasteClipboard,
2424
useAddComponentsToCollection,
2525
useBlockTypesMetadata,
26+
useAddComponentsToContainer,
2627
} from '../data/apiHooks';
2728
import { useLibraryContext } from '../common/context/LibraryContext';
2829
import { PickLibraryContentModal } from './PickLibraryContentModal';
2930
import { blockTypes } from '../../editors/data/constants/app';
3031

3132
import messages from './messages';
3233
import type { BlockTypeMetadata } from '../data/api';
34+
import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils';
3335

3436
type ContentType = {
3537
name: string,
@@ -87,7 +89,12 @@ const AddContentView = ({
8789
const {
8890
collectionId,
8991
componentPicker,
92+
unitId,
9093
} = useLibraryContext();
94+
let upstreamContainerType: ContainerType | undefined;
95+
if (unitId) {
96+
upstreamContainerType = getContainerTypeFromId(unitId);
97+
}
9198

9299
const collectionButtonData = {
93100
name: intl.formatMessage(messages.collectionButton),
@@ -109,21 +116,25 @@ const AddContentView = ({
109116

110117
return (
111118
<>
112-
{collectionId ? (
113-
componentPicker && (
114-
<>
115-
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
116-
<PickLibraryContentModal
117-
isOpen={isAddLibraryContentModalOpen}
118-
onClose={closeAddLibraryContentModal}
119-
/>
120-
</>
121-
)
122-
) : (
123-
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
119+
{upstreamContainerType !== ContainerType.Unit && (
120+
<>
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" />
136+
</>
124137
)}
125-
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
126-
<hr className="w-100 bg-gray-500" />
127138
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
128139
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
129140
<AddContentButton
@@ -186,16 +197,18 @@ export const parseErrorMsg = (
186197
return intl.formatMessage(defaultMessage);
187198
};
188199

189-
const AddContentContainer = () => {
200+
const AddContent = () => {
190201
const intl = useIntl();
191202
const {
192203
libraryId,
193204
collectionId,
194205
openCreateCollectionModal,
195206
openCreateUnitModal,
196207
openComponentEditor,
208+
unitId,
197209
} = useLibraryContext();
198-
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
210+
const addComponentsToCollectionMutation = useAddComponentsToCollection(libraryId, collectionId);
211+
const addComponentsToContainerMutation = useAddComponentsToContainer(libraryId, unitId);
199212
const createBlockMutation = useCreateLibraryBlock();
200213
const pasteClipboardMutation = useLibraryPasteClipboard();
201214
const { showToast } = useContext(ToastContext);
@@ -274,9 +287,16 @@ const AddContentContainer = () => {
274287
}
275288

276289
const linkComponent = (usageKey: string) => {
277-
updateComponentsMutation.mutateAsync([usageKey]).catch(() => {
278-
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
279-
});
290+
if (collectionId) {
291+
addComponentsToCollectionMutation.mutateAsync([usageKey]).catch(() => {
292+
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
293+
});
294+
}
295+
if (unitId) {
296+
addComponentsToContainerMutation.mutateAsync([usageKey]).catch(() => {
297+
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
298+
});
299+
}
280300
};
281301

282302
const onPaste = () => {
@@ -374,4 +394,4 @@ const AddContentContainer = () => {
374394
);
375395
};
376396

377-
export default AddContentContainer;
397+
export default AddContent;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
4242
collectionId,
4343
/** We need to get it as a reference instead of directly importing it to avoid the import cycle:
4444
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
45-
* Sidebar > AddContentContainer > ComponentPicker */
45+
* Sidebar > AddContent > ComponentPicker */
4646
componentPicker: ComponentPicker,
4747
} = useLibraryContext();
4848

@@ -65,7 +65,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
6565
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
6666
})
6767
.catch(() => {
68-
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
68+
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
6969
});
7070
}, [selectedComponents]);
7171

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { default as AddContentContainer } from './AddContentContainer';
1+
export { default as AddContent } from './AddContent';
22
export { default as AddContentHeader } from './AddContentHeader';

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,16 @@ const messages = defineMessages({
8484
defaultMessage: 'Content linked successfully.',
8585
description: 'Message when linking of content to a collection in library is success',
8686
},
87-
errorAssociateComponentMessage: {
87+
errorAssociateComponentToCollectionMessage: {
8888
id: 'course-authoring.library-authoring.associate-collection-content.error.text',
8989
defaultMessage: 'There was an error linking the content to this collection.',
9090
description: 'Message when linking of content to a collection in library fails',
9191
},
92+
errorAssociateComponentToContainerMessage: {
93+
id: 'course-authoring.library-authoring.associate-container-content.error.text',
94+
defaultMessage: 'There was an error linking the content to this container.',
95+
description: 'Message when linking of content to a container in library fails',
96+
},
9297
addContentTitle: {
9398
id: 'course-authoring.library-authoring.sidebar.title.add-content',
9499
defaultMessage: 'Add Content',

src/library-authoring/common/context/LibraryContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ type LibraryProviderProps = {
7272
/** The component picker modal to use. We need to pass it as a reference instead of
7373
* directly importing it to avoid the import cycle:
7474
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
75-
* Sidebar > AddContentContainer > ComponentPicker */
75+
* Sidebar > AddContent > ComponentPicker */
7676
componentPicker?: typeof ComponentPicker;
7777
};
7878

0 commit comments

Comments
 (0)