Skip to content

Commit ac7f900

Browse files
authored
feat: create subsections, units from within containers (#2104)
Functionality to create subsections, and units from within sections, and subsections respectively.
1 parent 19f81cc commit ac7f900

File tree

5 files changed

+204
-21
lines changed

5 files changed

+204
-21
lines changed

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ const render = (collectionId?: string) => {
4848
),
4949
});
5050
};
51-
const renderWithContainer = (containerId: string) => {
51+
const renderWithContainer = (containerId: string, containerType: 'unit' | 'section' | 'subsection' = 'unit') => {
5252
const params: { libraryId: string, containerId?: string } = { libraryId, containerId };
5353
return baseRender(<AddContent />, {
54-
path: '/library/:libraryId/unit/:containerId?',
54+
path: `/library/:libraryId/${containerType}/:containerId?`,
5555
params,
5656
extraWrapper: ({ children }) => (
5757
<LibraryProvider
@@ -63,6 +63,7 @@ const renderWithContainer = (containerId: string) => {
6363
),
6464
});
6565
};
66+
6667
let axiosMock: MockAdapter;
6768
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
6869

@@ -394,4 +395,30 @@ describe('<AddContent />', () => {
394395

395396
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this container.');
396397
});
398+
399+
it('should only show subsection button when inside a section', async () => {
400+
mockClipboardEmpty.applyMock();
401+
const sectionId = 'lct:orf1:lib1:section:test-1';
402+
renderWithContainer(sectionId, 'section');
403+
404+
expect(await screen.findByRole('button', { name: 'Subsection' })).toBeInTheDocument();
405+
406+
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
407+
expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument();
408+
expect(screen.queryByRole('button', { name: 'Section' })).not.toBeInTheDocument();
409+
expect(screen.queryByRole('button', { name: 'Text' })).not.toBeInTheDocument();
410+
});
411+
412+
it('should only show unit button when inside a subsection', async () => {
413+
mockClipboardEmpty.applyMock();
414+
const subsectionId = 'lct:orf1:lib1:subsection:test-1';
415+
renderWithContainer(subsectionId, 'subsection');
416+
417+
expect(await screen.findByRole('button', { name: 'Unit' })).toBeInTheDocument();
418+
419+
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
420+
expect(screen.queryByRole('button', { name: 'Subsection' })).not.toBeInTheDocument();
421+
expect(screen.queryByRole('button', { name: 'Section' })).not.toBeInTheDocument();
422+
expect(screen.queryByRole('button', { name: 'Text' })).not.toBeInTheDocument();
423+
});
397424
});

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

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,13 @@ const AddContentView = ({
141141
// Only show libraryContentButton
142142
return [libraryContentButtonData];
143143
}
144-
// istanbul ignore if
145144
if (insideSection) {
146145
// Should only allow adding subsections
147-
throw new Error('Not implemented');
148-
// return [subsectionButtonData];
146+
return [subsectionButtonData];
149147
}
150-
// istanbul ignore if
151148
if (insideSubsection) {
152149
// Should only allow adding units
153-
throw new Error('Not implemented');
154-
// return [unitButtonData];
150+
return [unitButtonData];
155151
}
156152
// except for libraryContentButton, show everthing.
157153
return [
@@ -178,15 +174,19 @@ const AddContentView = ({
178174
onClose={closeAddLibraryContentModal}
179175
/>
180176
)}
181-
<hr className="w-100 bg-gray-500" />
182-
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
183-
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
184-
<AddContentButton
185-
key={`add-content-${contentType.blockType}`}
186-
contentType={contentType}
187-
onCreateContent={onCreateContent}
188-
/>
189-
))}
177+
{(!insideSection && !insideSubsection) && (
178+
<>
179+
<hr className="w-100 bg-gray-500" />
180+
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
181+
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
182+
<AddContentButton
183+
key={`add-content-${contentType.blockType}`}
184+
contentType={contentType}
185+
onCreateContent={onCreateContent}
186+
/>
187+
))}
188+
</>
189+
)}
190190
</>
191191
);
192192
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import userEvent from '@testing-library/user-event';
2+
import {
3+
render, screen, waitFor, initializeMocks,
4+
} from '../../testUtils';
5+
import { LibraryProvider } from '../common/context/LibraryContext';
6+
import CreateContainerModal from './CreateContainerModal';
7+
import AddContent from '../add-content/AddContent';
8+
import { mockContentLibrary, mockBlockTypesMetadata } from '../data/api.mocks';
9+
import { getLibraryContainerApiUrl, getLibraryContainersApiUrl } from '../data/api';
10+
11+
const { libraryId } = mockContentLibrary;
12+
13+
const newSectionId = 'lct:org:lib:section:new-section';
14+
15+
describe('CreateContainerModal container linking', () => {
16+
let axiosMock;
17+
let mockShowToast;
18+
19+
beforeEach(() => {
20+
const mocks = initializeMocks();
21+
axiosMock = mocks.axiosMock;
22+
mockShowToast = mocks.mockShowToast;
23+
jest.clearAllMocks();
24+
mockContentLibrary.applyMock();
25+
mockBlockTypesMetadata.applyMock();
26+
axiosMock.onPost(getLibraryContainersApiUrl(libraryId)).reply(200, {
27+
id: newSectionId,
28+
containerType: 'section',
29+
displayName: 'Test Container',
30+
publishedDisplayName: 'Test Container',
31+
created: '2024-09-19T10:00:00Z',
32+
createdBy: 'test_author',
33+
lastPublished: null,
34+
publishedBy: null,
35+
lastDraftCreated: null,
36+
lastDraftCreatedBy: null,
37+
modified: '2024-09-20T11:00:00Z',
38+
hasUnpublishedChanges: true,
39+
collections: [],
40+
tagsCount: 0,
41+
});
42+
});
43+
44+
function renderWithProvider(content, options) {
45+
const { containerId, routeType = 'library' } = options || {};
46+
const paths = {
47+
library: '/library/:libraryId',
48+
section: '/library/:libraryId/section/:containerId',
49+
subsection: '/library/:libraryId/subsection/:containerId',
50+
collection: '/library/:libraryId/collection/:collectionId',
51+
};
52+
53+
const params = { libraryId, ...containerId && { containerId } };
54+
55+
return render(content, {
56+
path: paths[routeType],
57+
params,
58+
extraWrapper: ({ children: wrappedChildren }) => (
59+
<LibraryProvider libraryId={libraryId}>
60+
{wrappedChildren}
61+
</LibraryProvider>
62+
),
63+
});
64+
}
65+
66+
it('links container to collection when inside a collection', async () => {
67+
renderWithProvider(
68+
<>
69+
<AddContent />
70+
<CreateContainerModal />
71+
</>,
72+
{},
73+
);
74+
// Disambiguate: select the "Section" button by exact match
75+
const sectionButton = await screen.findByRole('button', { name: /^Section$/ });
76+
userEvent.click(sectionButton);
77+
const nameInput = await screen.findByLabelText(/name your section/i);
78+
userEvent.type(nameInput, 'Test Section');
79+
const createButton = await screen.findByRole('button', { name: /create/i });
80+
userEvent.click(createButton);
81+
await waitFor(() => {
82+
expect(axiosMock.history.post).toHaveLength(1);
83+
});
84+
expect(axiosMock.history.post[0].url).toMatch(/\/api\/libraries\/.*\/containers/);
85+
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
86+
can_stand_alone: true,
87+
container_type: 'section',
88+
display_name: 'Test Section',
89+
});
90+
});
91+
92+
it('links container to section when inside a section', async () => {
93+
axiosMock.onPost(getLibraryContainerApiUrl(newSectionId)).reply(200, {
94+
id: newSectionId,
95+
containerType: 'section',
96+
displayName: 'Test Container',
97+
publishedDisplayName: 'Test Container',
98+
created: '2024-09-19T10:00:00Z',
99+
});
100+
renderWithProvider(
101+
<>
102+
<AddContent />
103+
<CreateContainerModal />
104+
</>,
105+
{ containerId: newSectionId, routeType: 'section' },
106+
);
107+
108+
const subsectionButton = await screen.findByRole('button', { name: /^Subsection$/ });
109+
userEvent.click(subsectionButton);
110+
const nameInput = await screen.findByLabelText(/name your subsection/i);
111+
userEvent.type(nameInput, 'Test Subsection');
112+
const createButton = await screen.findByRole('button', { name: /create/i });
113+
userEvent.click(createButton);
114+
await waitFor(() => {
115+
expect(axiosMock.history.post[0].url).toMatch(/\/api\/libraries\/.*\/containers/);
116+
});
117+
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
118+
can_stand_alone: false,
119+
container_type: 'subsection',
120+
display_name: 'Test Subsection',
121+
});
122+
});
123+
124+
it('handles linking error gracefully', async () => {
125+
axiosMock.onPost(getLibraryContainersApiUrl(libraryId)).reply(500);
126+
renderWithProvider(
127+
<>
128+
<AddContent />
129+
<CreateContainerModal />
130+
</>,
131+
{},
132+
);
133+
// Disambiguate: select the "Section" button by exact match
134+
const sectionButton = await screen.findByRole('button', { name: /^Section$/ });
135+
userEvent.click(sectionButton);
136+
const nameInput = await screen.findByLabelText(/name your section/i);
137+
userEvent.type(nameInput, 'Test Section');
138+
const createButton = await screen.findByRole('button', { name: /create/i });
139+
userEvent.click(createButton);
140+
await waitFor(() => {
141+
expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/error/i));
142+
});
143+
});
144+
});

src/library-authoring/create-container/CreateContainerModal.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as Yup from 'yup';
1010
import FormikControl from '../../generic/FormikControl';
1111
import { useLibraryContext } from '../common/context/LibraryContext';
1212
import messages from './messages';
13-
import { useAddItemsToCollection, useCreateLibraryContainer } from '../data/apiHooks';
13+
import { useAddItemsToContainer, useAddItemsToCollection, useCreateLibraryContainer } from '../data/apiHooks';
1414
import { ToastContext } from '../../generic/toast-context';
1515
import LoadingButton from '../../generic/loading-button';
1616
import { ContainerType } from '../../generic/key-utils';
@@ -22,12 +22,19 @@ const CreateContainerModal = () => {
2222
const {
2323
collectionId,
2424
libraryId,
25+
containerId,
2526
createContainerModalType,
2627
setCreateContainerModalType,
2728
} = useLibraryContext();
28-
const { navigateTo, insideCollection } = useLibraryRoutes();
29+
const {
30+
navigateTo,
31+
insideCollection,
32+
insideSection,
33+
insideSubsection,
34+
} = useLibraryRoutes();
2935
const create = useCreateLibraryContainer(libraryId);
30-
const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId);
36+
const updateCollectionItemsMutation = useAddItemsToCollection(libraryId, collectionId);
37+
const updateContainerItemsMutation = useAddItemsToContainer(containerId);
3138
const { showToast } = React.useContext(ToastContext);
3239

3340
/** labels based on the type of modal open, i.e., section, subsection or unit */
@@ -78,13 +85,17 @@ const CreateContainerModal = () => {
7885

7986
const handleCreate = React.useCallback(async (values) => {
8087
try {
88+
const canStandAlone = !(insideCollection || insideSection || insideSubsection);
8189
const container = await create.mutateAsync({
90+
canStandAlone,
8291
containerType,
8392
...values,
8493
});
8594
// link container to parent
8695
if (collectionId && insideCollection) {
87-
await updateItemsMutation.mutateAsync([container.id]);
96+
await updateCollectionItemsMutation.mutateAsync([container.id]);
97+
} else if (containerId && (insideSection || insideSubsection)) {
98+
await updateContainerItemsMutation.mutateAsync([container.id]);
8899
}
89100
// Navigate to the new container
90101
navigateTo({ containerId: container.id });

src/library-authoring/data/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ export async function updateComponentCollections(usageKey: string, collectionKey
582582
export interface CreateLibraryContainerDataRequest {
583583
title: string;
584584
containerType: ContainerType;
585+
canStandAlone: boolean;
585586
}
586587

587588
/**

0 commit comments

Comments
 (0)