Skip to content

Commit c46bdea

Browse files
authored
fix(outline): preserve outline context when adding components (#94)
* fix(outline): preserve outline context when adding components * fix: update test cases
1 parent e7d90fd commit c46bdea

4 files changed

Lines changed: 125 additions & 59 deletions

File tree

src/course-outline/unit-card/AddComponentWidget.test.tsx

Lines changed: 90 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ const problemTemplate: ComponentTemplate = {
3939
supportLegend: {},
4040
};
4141

42+
const openAssessmentTemplate: ComponentTemplate = {
43+
type: 'openassessment',
44+
displayName: 'Open Response',
45+
templates: [
46+
{ displayName: 'Peer Assessment Only', category: 'openassessment', boilerplateName: 'peer-assessment' },
47+
{ displayName: 'Self Assessment Only', category: 'openassessment', boilerplateName: 'self-assessment' },
48+
{ displayName: 'Staff Assessment Only', category: 'openassessment', boilerplateName: 'staff-assessment' },
49+
],
50+
supportLegend: {},
51+
};
52+
4253
const advancedTemplate: ComponentTemplate = {
4354
type: 'advanced',
4455
displayName: 'Advanced',
@@ -55,7 +66,7 @@ const unitId = 'block-v1:edX+Demo+2025+type@vertical+block@unit1';
5566
const renderWidget = (props?: Partial<React.ComponentProps<typeof AddComponentWidget>>) => render(
5667
<AddComponentWidget
5768
unitId={unitId}
58-
componentTemplates={[htmlTemplate, problemTemplate, advancedTemplate]}
69+
componentTemplates={[htmlTemplate, problemTemplate, openAssessmentTemplate, advancedTemplate]}
5970
{...props}
6071
/>,
6172
);
@@ -147,62 +158,87 @@ describe('<AddComponentWidget />', () => {
147158
});
148159
});
149160

150-
it('opens template selection modal for multi-template types', async () => {
151-
renderWidget();
152-
// Open dropdown and click Problem (multiple templates)
161+
it('creates problem component directly without modal (skips template selection)', async () => {
162+
const onCreated = jest.fn();
163+
renderWidget({ onComponentCreated: onCreated });
164+
165+
// Open dropdown and click Problem – should create directly, no modal
153166
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
154167
await act(async () => fireEvent.click(toggle));
155168
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-problem')));
156169

157-
// Modal should appear with radio options
158-
expect(screen.getByText('Add problem component')).toBeInTheDocument();
159-
expect(screen.getByText('Blank Problem')).toBeInTheDocument();
160-
expect(screen.getByText('Multiple Choice')).toBeInTheDocument();
161-
expect(screen.getByText('Checkboxes')).toBeInTheDocument();
170+
// Should call createXBlock with the first/default template
171+
await waitFor(() => {
172+
expect(mockCreateXBlock).toHaveBeenCalledWith({
173+
parentLocator: unitId,
174+
type: 'problem',
175+
category: 'problem',
176+
boilerplate: undefined,
177+
});
178+
});
179+
180+
// Modal should NOT appear
181+
expect(screen.queryByText('Add problem component')).not.toBeInTheDocument();
162182
});
163183

164-
it('creates component from modal after selecting a template', async () => {
184+
it('creates open response component directly with Peer Assessment Only default (skips modal)', async () => {
165185
const onCreated = jest.fn();
166186
renderWidget({ onComponentCreated: onCreated });
167187

168-
// Open dropdown click Problem → modal opens
188+
// Open dropdown and click Open Response – should create directly, no modal
169189
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
170190
await act(async () => fireEvent.click(toggle));
171-
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-problem')));
172-
173-
// Select "Multiple Choice" radio button
174-
const multipleChoiceRadio = screen.getByLabelText('Multiple Choice');
175-
await act(async () => fireEvent.click(multipleChoiceRadio));
191+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-openassessment')));
176192

177-
// Click "Select" button to submit
178-
const selectButton = screen.getByText(messages.templateModalSelect.defaultMessage);
179-
await act(async () => fireEvent.click(selectButton));
180-
181-
// Should create with the selected boilerplate
193+
// Should call createXBlock with 'peer-assessment' boilerplate
182194
await waitFor(() => {
183195
expect(mockCreateXBlock).toHaveBeenCalledWith({
184196
parentLocator: unitId,
185-
type: 'problem',
186-
category: 'problem',
187-
boilerplate: 'multiple_choice',
197+
type: 'openassessment',
198+
category: 'openassessment',
199+
boilerplate: 'peer-assessment',
188200
});
189201
});
202+
203+
// Modal should NOT appear
204+
expect(screen.queryByText('Add open response component')).not.toBeInTheDocument();
190205
});
191206

192-
it('closes modal without creating when cancel is clicked', async () => {
193-
renderWidget();
194-
// Open dropdown → click Problem → modal opens
207+
it('creates html/text component directly with default Text template (skips modal)', async () => {
208+
const onCreated = jest.fn();
209+
// Use multi-template html to prove modal is skipped even with multiple templates
210+
const htmlMultiTemplate: ComponentTemplate = {
211+
type: 'html',
212+
displayName: 'Text',
213+
templates: [
214+
{ displayName: 'Text', category: 'html', boilerplateName: undefined },
215+
{ displayName: 'Raw HTML', category: 'html', boilerplateName: 'raw.yaml' },
216+
{ displayName: 'Zooming Image Tool', category: 'html', boilerplateName: 'zooming_image.yaml' },
217+
],
218+
supportLegend: {},
219+
};
220+
renderWidget({
221+
componentTemplates: [htmlMultiTemplate, problemTemplate, openAssessmentTemplate, advancedTemplate],
222+
onComponentCreated: onCreated,
223+
});
224+
225+
// Open dropdown and click Text – should create directly, no modal
195226
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
196227
await act(async () => fireEvent.click(toggle));
197-
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-problem')));
228+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-html')));
198229

199-
// Click "Cancel" button
200-
const cancelButton = screen.getByText(messages.templateModalCancel.defaultMessage);
201-
await act(async () => fireEvent.click(cancelButton));
230+
// Should call createXBlock with default Text (no boilerplate)
231+
await waitFor(() => {
232+
expect(mockCreateXBlock).toHaveBeenCalledWith({
233+
parentLocator: unitId,
234+
type: 'html',
235+
category: 'html',
236+
boilerplate: undefined,
237+
});
238+
});
202239

203-
// Modal should close, createXBlock should not be called
204-
expect(screen.queryByText('Add problem component')).not.toBeInTheDocument();
205-
expect(mockCreateXBlock).not.toHaveBeenCalled();
240+
// Modal should NOT appear
241+
expect(screen.queryByText('Add text component')).not.toBeInTheDocument();
206242
});
207243

208244
it('creates advanced component directly on click', async () => {
@@ -269,15 +305,28 @@ describe('<AddComponentWidget />', () => {
269305
expect(screen.queryByTestId('add-component-item-paste')).not.toBeInTheDocument();
270306
});
271307

272-
it('disables the Select button in modal when no template is selected', async () => {
273-
renderWidget();
274-
// Open dropdown → click Problem → modal opens
308+
it('still opens modal for types not in directCreateDefaults (e.g. custom multi-template)', async () => {
309+
// Create a custom type with multiple templates that is NOT in directCreateDefaults
310+
const customTemplate: ComponentTemplate = {
311+
type: 'custom_type',
312+
displayName: 'Custom',
313+
templates: [
314+
{ displayName: 'Option A', category: 'custom_type', boilerplateName: 'option_a' },
315+
{ displayName: 'Option B', category: 'custom_type', boilerplateName: 'option_b' },
316+
],
317+
supportLegend: {},
318+
};
319+
renderWidget({
320+
componentTemplates: [customTemplate],
321+
});
322+
275323
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
276324
await act(async () => fireEvent.click(toggle));
277-
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-problem')));
325+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-custom_type')));
278326

279-
// Select button should be disabled initially (no radio selected)
280-
const selectButton = screen.getByText(messages.templateModalSelect.defaultMessage);
281-
expect(selectButton).toBeDisabled();
327+
// Modal should appear
328+
expect(screen.getByText('Add custom component')).toBeInTheDocument();
329+
expect(screen.getByText('Option A')).toBeInTheDocument();
330+
expect(screen.getByText('Option B')).toBeInTheDocument();
282331
});
283332
});

src/course-outline/unit-card/AddComponentWidget.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,27 @@ const AddComponentWidget = ({
8282
}
8383
};
8484

85-
/** Handle clicking a dropdown item – open modal if multiple templates, otherwise create directly */
85+
/**
86+
* Map of component types that should skip the template selection modal
87+
* and create directly with a specific default template.
88+
* Key: component type, Value: function to find the default template.
89+
*/
90+
const directCreateDefaults: Record<string, (tpls: ComponentTemplate['templates']) => ComponentTemplate['templates'][0] | undefined> = {
91+
html: (tpls) => tpls.find((t) => !t.boilerplateName) || tpls[0],
92+
openassessment: (tpls) => tpls.find((t) => t.boilerplateName === 'peer-assessment') || tpls[0],
93+
problem: (tpls) => tpls[0],
94+
};
95+
8696
const handleDropdownItemClick = async (template: ComponentTemplate) => {
87-
if (template.templates.length > 1) {
97+
const directDefault = directCreateDefaults[template.type];
98+
if (directDefault) {
99+
const defaultTpl = directDefault(template.templates);
100+
await handleAddComponent(
101+
template.type,
102+
defaultTpl?.category || template.type,
103+
defaultTpl?.boilerplateName,
104+
);
105+
} else if (template.templates.length > 1) {
88106
// Multiple sub-types available – show selection modal
89107
setModalTemplate(template);
90108
setSelectedTemplateValue('');

src/course-outline/unit-card/UnitCard.test.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,7 @@ describe('<UnitCard />', () => {
568568
expect(screen.queryByTestId('add-component-item-paste')).not.toBeInTheDocument();
569569
});
570570

571-
it('opens MFE editor after creating an MFE-supported component (e.g. html)', async () => {
571+
it('does not open editor after creating an MFE-supported component (e.g. html)', async () => {
572572
// Simulate createXBlock returning a locator for an html component
573573
mockCreateXBlock.mockResolvedValueOnce({
574574
locator: 'block-v1:test+type@html+block@new1',
@@ -604,13 +604,17 @@ describe('<UnitCard />', () => {
604604
await act(async () => fireEvent.click(toggle));
605605
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-html')));
606606

607-
// The MFE editor modal should appear (mocked EditorPage inside .editor-page div)
607+
// createXBlock should have been called
608608
await waitFor(() => {
609-
expect(screen.getByTestId('mock-editor-page')).toBeInTheDocument();
609+
expect(mockCreateXBlock).toHaveBeenCalled();
610610
});
611+
612+
// No editor should open — component is created without launching an editor
613+
expect(screen.queryByTestId('mock-editor-page')).not.toBeInTheDocument();
614+
expect(screen.queryByTestId('mock-modal-iframe')).not.toBeInTheDocument();
611615
});
612616

613-
it('opens legacy editor after creating a non-MFE component (e.g. openassessment)', async () => {
617+
it('does not open editor after creating a non-MFE component (e.g. openassessment)', async () => {
614618
// Simulate createXBlock returning a locator for an ORA component
615619
mockCreateXBlock.mockResolvedValueOnce({
616620
locator: 'block-v1:test+type@openassessment+block@new2',
@@ -650,10 +654,14 @@ describe('<UnitCard />', () => {
650654
await act(async () => fireEvent.click(toggle));
651655
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-openassessment')));
652656

653-
// Legacy editor modal (iframe) should appear — MockModalIframe renders with a title
657+
// createXBlock should have been called
654658
await waitFor(() => {
655-
expect(screen.getByTestId('mock-modal-iframe')).toBeInTheDocument();
659+
expect(mockCreateXBlock).toHaveBeenCalled();
656660
});
661+
662+
// No editor should open — component is created without launching an editor
663+
expect(screen.queryByTestId('mock-editor-page')).not.toBeInTheDocument();
664+
expect(screen.queryByTestId('mock-modal-iframe')).not.toBeInTheDocument();
657665
});
658666
});
659667

src/course-outline/unit-card/UnitCard.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ import DraggableList, { SortableItem as GenericSortableItem } from '@src/generic
4444
import { ToastContext } from '@src/generic/toast-context';
4545
import { useUnitHandler, useComponentTemplates } from './data/hooks';
4646
import AddComponentWidget from './AddComponentWidget';
47-
import type { CreatedXBlockInfo } from './AddComponentWidget';
4847
import messages from './messages';
4948

5049
interface UnitCardProps {
@@ -608,17 +607,9 @@ const UnitCard = ({
608607
componentTemplates={componentTemplates}
609608
showPasteXBlock={!!showPasteXBlock}
610609
onPasteComponent={handlePasteComponent}
611-
onComponentCreated={(info: CreatedXBlockInfo) => {
610+
onComponentCreated={() => {
612611
dispatch(fetchCourseSectionQuery([section.id]));
613612
refetchUnitData();
614-
const editorBlockType = info.category || info.type;
615-
if (supportsMFEEditor(editorBlockType)) {
616-
// MFE editor available (html, video, problem, games, etc.)
617-
handleShowMFEEditor(editorBlockType, info.locator);
618-
} else {
619-
// No MFE editor — open the legacy Studio editor in an iframe
620-
handleShowLegacyEditModal(info.locator);
621-
}
622613
}}
623614
/>
624615
</div>

0 commit comments

Comments
 (0)