Skip to content

Commit 13c91a4

Browse files
committed
feat: add components from Outline page
1 parent 15d55ac commit 13c91a4

12 files changed

Lines changed: 1184 additions & 6 deletions

File tree

src/course-outline/card-header/CardHeader.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ interface CardHeaderProps {
4747
onClickDuplicate: () => void;
4848
onClickMoveUp: () => void;
4949
onClickMoveDown: () => void;
50+
onClickPreview?: () => void;
5051
onClickCopy?: () => void;
5152
titleComponent: ReactNode;
5253
namePrefix: string;
@@ -89,6 +90,7 @@ const CardHeader = ({
8990
onClickDuplicate,
9091
onClickMoveUp,
9192
onClickMoveDown,
93+
onClickPreview,
9294
onClickCopy,
9395
titleComponent,
9496
namePrefix,
@@ -220,6 +222,14 @@ const CardHeader = ({
220222
iconAs={Icon}
221223
/>
222224
<Dropdown.Menu>
225+
{isVertical && onClickPreview && (
226+
<Dropdown.Item
227+
data-testid={`${namePrefix}-card-header__menu-preview-button`}
228+
onClick={onClickPreview}
229+
>
230+
{intl.formatMessage(messages.menuPreview)}
231+
</Dropdown.Item>
232+
)}
223233
{isSequential && proctoringExamConfigurationLink && (
224234
<Dropdown.Item
225235
as={Hyperlink}

src/course-outline/card-header/messages.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ const messages = defineMessages({
3737
id: 'course-authoring.course-outline.card.button.edit.alt',
3838
defaultMessage: 'Rename',
3939
},
40+
menuPreview: {
41+
id: 'course-authoring.course-outline.card.menu.preview',
42+
defaultMessage: 'Preview',
43+
},
4044
menuPublish: {
4145
id: 'course-authoring.course-outline.card.menu.publish',
4246
defaultMessage: 'Publish',
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import {
2+
act, fireEvent, initializeMocks, render, screen, waitFor,
3+
} from '@src/testUtils';
4+
5+
import AddComponentWidget from './AddComponentWidget';
6+
import type { ComponentTemplate } from './data/api';
7+
import messages from './messages';
8+
9+
// Mock the useCreateXBlockInUnit hook
10+
const mockCreateXBlock = jest.fn();
11+
jest.mock('./data/hooks', () => ({
12+
useUnitHandler: jest.fn(() => ({
13+
data: undefined, isLoading: false, isError: false, error: null,
14+
})),
15+
useCreateXBlockInUnit: () => ({
16+
mutateAsync: mockCreateXBlock,
17+
isPending: false,
18+
}),
19+
}));
20+
21+
// Sample component templates matching the API shape
22+
const htmlTemplate: ComponentTemplate = {
23+
type: 'html',
24+
displayName: 'Text',
25+
templates: [
26+
{ displayName: 'Text', category: 'html', boilerplateName: undefined },
27+
],
28+
supportLegend: {},
29+
};
30+
31+
const problemTemplate: ComponentTemplate = {
32+
type: 'problem',
33+
displayName: 'Problem',
34+
templates: [
35+
{ displayName: 'Blank Problem', category: 'problem', boilerplateName: undefined },
36+
{ displayName: 'Multiple Choice', category: 'problem', boilerplateName: 'multiple_choice' },
37+
{ displayName: 'Checkboxes', category: 'problem', boilerplateName: 'checkboxes' },
38+
],
39+
supportLegend: {},
40+
};
41+
42+
const advancedTemplate: ComponentTemplate = {
43+
type: 'advanced',
44+
displayName: 'Advanced',
45+
templates: [
46+
{ displayName: 'LTI Consumer', category: 'lti_consumer', supportLevel: 'fs' },
47+
{ displayName: 'Poll', category: 'poll', supportLevel: 'ps' },
48+
{ displayName: 'Custom XBlock', category: 'custom_xblock', supportLevel: 'us' },
49+
],
50+
supportLegend: {},
51+
};
52+
53+
const unitId = 'block-v1:edX+Demo+2025+type@vertical+block@unit1';
54+
55+
const renderWidget = (props?: Partial<React.ComponentProps<typeof AddComponentWidget>>) => render(
56+
<AddComponentWidget
57+
unitId={unitId}
58+
componentTemplates={[htmlTemplate, problemTemplate, advancedTemplate]}
59+
{...props}
60+
/>,
61+
);
62+
63+
describe('<AddComponentWidget />', () => {
64+
let mockShowToast: ReturnType<typeof initializeMocks>['mockShowToast'];
65+
66+
beforeEach(() => {
67+
const mocks = initializeMocks();
68+
mockShowToast = mocks.mockShowToast;
69+
mockCreateXBlock.mockReset();
70+
// Default: creation succeeds
71+
mockCreateXBlock.mockResolvedValue({ locator: 'block-v1:new', courseKey: 'course-v1:test' });
72+
});
73+
74+
it('renders the Add Component dropdown button', () => {
75+
renderWidget();
76+
// The toggle button should be visible with the correct label
77+
expect(screen.getByText(messages.addComponentButton.defaultMessage)).toBeInTheDocument();
78+
});
79+
80+
it('returns null when there are no templates and no paste option', () => {
81+
renderWidget({ componentTemplates: [], showPasteXBlock: false });
82+
// Widget should render nothing – no dropdown visible
83+
expect(screen.queryByTestId('add-component-dropdown')).not.toBeInTheDocument();
84+
});
85+
86+
it('renders normal and advanced template items in the dropdown', async () => {
87+
renderWidget();
88+
// Open the dropdown menu
89+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
90+
await act(async () => fireEvent.click(toggle));
91+
92+
// Normal templates
93+
expect(screen.getByTestId('add-component-item-html')).toBeInTheDocument();
94+
expect(screen.getByTestId('add-component-item-problem')).toBeInTheDocument();
95+
96+
// Advanced section header + items
97+
expect(screen.getByText('Advanced')).toBeInTheDocument();
98+
expect(screen.getByTestId('add-component-item-advanced-lti_consumer')).toBeInTheDocument();
99+
expect(screen.getByTestId('add-component-item-advanced-poll')).toBeInTheDocument();
100+
expect(screen.getByTestId('add-component-item-advanced-custom_xblock')).toBeInTheDocument();
101+
});
102+
103+
it('shows support level labels for partially and not supported advanced xblocks', async () => {
104+
renderWidget();
105+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
106+
await act(async () => fireEvent.click(toggle));
107+
108+
// LTI Consumer (fs) - no support label
109+
const ltiItem = screen.getByTestId('add-component-item-advanced-lti_consumer');
110+
expect(ltiItem).not.toHaveTextContent(messages.supportPartiallySuppported.defaultMessage);
111+
expect(ltiItem).not.toHaveTextContent(messages.supportNotSupported.defaultMessage);
112+
113+
// Poll (ps) - partially supported label
114+
const pollItem = screen.getByTestId('add-component-item-advanced-poll');
115+
expect(pollItem).toHaveTextContent(messages.supportPartiallySuppported.defaultMessage);
116+
117+
// Custom XBlock (us) - not supported label
118+
const customItem = screen.getByTestId('add-component-item-advanced-custom_xblock');
119+
expect(customItem).toHaveTextContent(messages.supportNotSupported.defaultMessage);
120+
});
121+
122+
it('creates a single-template component directly on click', async () => {
123+
const onCreated = jest.fn();
124+
renderWidget({ onComponentCreated: onCreated });
125+
126+
// Open dropdown and click HTML (single template)
127+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
128+
await act(async () => fireEvent.click(toggle));
129+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-html')));
130+
131+
// Should call createXBlock with correct params
132+
await waitFor(() => {
133+
expect(mockCreateXBlock).toHaveBeenCalledWith({
134+
parentLocator: unitId,
135+
type: 'html',
136+
category: 'html',
137+
boilerplate: undefined,
138+
});
139+
});
140+
141+
// Should call onComponentCreated with the result
142+
expect(onCreated).toHaveBeenCalledWith({
143+
locator: 'block-v1:new',
144+
courseKey: 'course-v1:test',
145+
type: 'html',
146+
category: 'html',
147+
});
148+
});
149+
150+
it('opens template selection modal for multi-template types', async () => {
151+
renderWidget();
152+
// Open dropdown and click Problem (multiple templates)
153+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
154+
await act(async () => fireEvent.click(toggle));
155+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-problem')));
156+
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();
162+
});
163+
164+
it('creates component from modal after selecting a template', async () => {
165+
const onCreated = jest.fn();
166+
renderWidget({ onComponentCreated: onCreated });
167+
168+
// Open dropdown → click Problem → modal opens
169+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
170+
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));
176+
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
182+
await waitFor(() => {
183+
expect(mockCreateXBlock).toHaveBeenCalledWith({
184+
parentLocator: unitId,
185+
type: 'problem',
186+
category: 'problem',
187+
boilerplate: 'multiple_choice',
188+
});
189+
});
190+
});
191+
192+
it('closes modal without creating when cancel is clicked', async () => {
193+
renderWidget();
194+
// Open dropdown → click Problem → modal opens
195+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
196+
await act(async () => fireEvent.click(toggle));
197+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-problem')));
198+
199+
// Click "Cancel" button
200+
const cancelButton = screen.getByText(messages.templateModalCancel.defaultMessage);
201+
await act(async () => fireEvent.click(cancelButton));
202+
203+
// Modal should close, createXBlock should not be called
204+
expect(screen.queryByText('Add problem component')).not.toBeInTheDocument();
205+
expect(mockCreateXBlock).not.toHaveBeenCalled();
206+
});
207+
208+
it('creates advanced component directly on click', async () => {
209+
const onCreated = jest.fn();
210+
renderWidget({ onComponentCreated: onCreated });
211+
212+
// Open dropdown and click an advanced item
213+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
214+
await act(async () => fireEvent.click(toggle));
215+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-advanced-poll')));
216+
217+
// Should create with type 'advanced' and the correct category
218+
await waitFor(() => {
219+
expect(mockCreateXBlock).toHaveBeenCalledWith({
220+
parentLocator: unitId,
221+
type: 'advanced',
222+
category: 'poll',
223+
boilerplate: undefined,
224+
});
225+
});
226+
});
227+
228+
it('shows error toast when creation fails', async () => {
229+
// Make createXBlock reject
230+
mockCreateXBlock.mockRejectedValueOnce(new Error('Network error'));
231+
232+
renderWidget();
233+
234+
// Open dropdown and click HTML
235+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
236+
await act(async () => fireEvent.click(toggle));
237+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-html')));
238+
239+
// Should show error toast
240+
await waitFor(() => {
241+
expect(mockShowToast).toHaveBeenCalledWith(messages.addComponentError.defaultMessage);
242+
});
243+
});
244+
245+
it('renders paste component option when showPasteXBlock is true', async () => {
246+
const onPaste = jest.fn();
247+
renderWidget({ showPasteXBlock: true, onPasteComponent: onPaste });
248+
249+
// Open the dropdown
250+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
251+
await act(async () => fireEvent.click(toggle));
252+
253+
// Paste item should be visible
254+
const pasteItem = screen.getByTestId('add-component-item-paste');
255+
expect(pasteItem).toBeInTheDocument();
256+
expect(pasteItem).toHaveTextContent(messages.pasteComponent.defaultMessage);
257+
258+
// Click paste
259+
await act(async () => fireEvent.click(pasteItem));
260+
expect(onPaste).toHaveBeenCalled();
261+
});
262+
263+
it('does not render paste option when showPasteXBlock is false', async () => {
264+
renderWidget({ showPasteXBlock: false });
265+
266+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
267+
await act(async () => fireEvent.click(toggle));
268+
269+
expect(screen.queryByTestId('add-component-item-paste')).not.toBeInTheDocument();
270+
});
271+
272+
it('disables the Select button in modal when no template is selected', async () => {
273+
renderWidget();
274+
// Open dropdown → click Problem → modal opens
275+
const toggle = screen.getByText(messages.addComponentButton.defaultMessage);
276+
await act(async () => fireEvent.click(toggle));
277+
await act(async () => fireEvent.click(screen.getByTestId('add-component-item-problem')));
278+
279+
// Select button should be disabled initially (no radio selected)
280+
const selectButton = screen.getByText(messages.templateModalSelect.defaultMessage);
281+
expect(selectButton).toBeDisabled();
282+
});
283+
});

0 commit comments

Comments
 (0)