|
| 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