Conversation
| return ( | ||
| <PluginSlot | ||
| id="org.openedx.frontend.authoring.in_video_quiz_editor.v1" | ||
| idAliases={['in_video_quiz_editor_slot']} | ||
| pluginProps={{ | ||
| onClose, | ||
| returnFunction, | ||
| blockFinished, | ||
| blockId, | ||
| blockValue, | ||
| selectedVideo, | ||
| videos, | ||
| problems, | ||
| quizItems, | ||
| unitContentLoaded, | ||
| isDirty, | ||
| saveError, | ||
| setSelectedVideo, | ||
| addQuizItem, | ||
| removeQuizItem, | ||
| updateProblemId, | ||
| updateTime, | ||
| updateJumpBack, | ||
| onSave: handleSave, | ||
| getContent: () => hooks.getContent({ selectedVideo, quizItems }), | ||
| }} | ||
| > |
There was a problem hiding this comment.
Blocking - with FPF 1.7.0 (our locked version) this PluginSlot is not a no-op.
When no plugin is configured, PluginSlot renders the default child as cloneElement(children, mergeProps(children.props, pluginProps)), so every key here (L354–L373) is spread onto <EditorContainer> (L376). mergeProps chains same-named functions and overwrites same-named values (falsy → ''). Verified against the real @openedx/frontend-plugin-framework@1.7.0 mergeRenderWidgetPropsWithPluginContent with this PR's exact props:
| here | EditorContainer |
result |
|---|---|---|
isDirty (boolean, L364) |
isDirty={() => isDirty} (L380) |
becomes '' / true → isDirty() in EditorContainer/index.tsx L125 & L129 throws TypeError on Close/X or the unsaved-changes prompt |
onSave: handleSave (L372) |
onSave={handleSave} (L381) |
chained → handleSave runs twice per Save (2× saveInVideoQuizSettings, 2× navigateCallback) |
onClose (L354) |
onClose={onClose} (L378) |
chained → called twice |
getContent (L373) |
getContent={…} (L377) |
chained wrapper returns undefined |
| everything else | - | leaks onto EditorContainer as unknown props |
Namespacing under a single object prop avoids all of it (objects are passed by reference, never chained - same pattern as CourseUnitHeaderActionsSlot's headerNavigationsActions). EditorContainer ignores the one extra prop.
| return ( | |
| <PluginSlot | |
| id="org.openedx.frontend.authoring.in_video_quiz_editor.v1" | |
| idAliases={['in_video_quiz_editor_slot']} | |
| pluginProps={{ | |
| onClose, | |
| returnFunction, | |
| blockFinished, | |
| blockId, | |
| blockValue, | |
| selectedVideo, | |
| videos, | |
| problems, | |
| quizItems, | |
| unitContentLoaded, | |
| isDirty, | |
| saveError, | |
| setSelectedVideo, | |
| addQuizItem, | |
| removeQuizItem, | |
| updateProblemId, | |
| updateTime, | |
| updateJumpBack, | |
| onSave: handleSave, | |
| getContent: () => hooks.getContent({ selectedVideo, quizItems }), | |
| }} | |
| > | |
| // Everything a plugin needs is exposed under ONE object prop. FPF's PluginSlot | |
| // spreads pluginProps onto the default child via mergeProps(), which chains | |
| // same-named functions (double-invoking onSave/onClose) and overwrites | |
| // same-named values (a boolean isDirty replaced EditorContainer's isDirty()). | |
| // A single object prop cannot collide with any EditorContainer prop. | |
| const inVideoQuizEditor = { | |
| onClose, | |
| returnFunction, | |
| blockFinished, | |
| blockId, | |
| blockValue, | |
| selectedVideo, | |
| videos, | |
| problems, | |
| quizItems, | |
| unitContentLoaded, | |
| isDirty, | |
| saveError, | |
| setSelectedVideo, | |
| addQuizItem, | |
| removeQuizItem, | |
| updateProblemId, | |
| updateTime, | |
| updateJumpBack, | |
| onSave: handleSave, | |
| getContent: () => hooks.getContent({ selectedVideo, quizItems }), | |
| }; | |
| return ( | |
| <PluginSlot | |
| id="org.openedx.frontend.authoring.in_video_quiz_editor.v1" | |
| idAliases={['in_video_quiz_editor_slot']} | |
| pluginProps={{ inVideoQuizEditor }} | |
| > |
| jest.mock('@openedx/frontend-plugin-framework', () => ({ | ||
| PluginSlot: 'PluginSlot', | ||
| })); | ||
|
|
There was a problem hiding this comment.
This string mock is why the merge issue above isn't caught: the real PluginSlot never runs, so cloneElement/mergeProps never touches the mocked EditorContainer. initializeMocks() already calls initializeMockApp, so with no pluginSlots in config the real PluginSlot takes the default-content path and works in this file - please drop the mock so the test exercises the real thing.
| jest.mock('@openedx/frontend-plugin-framework', () => ({ | |
| PluginSlot: 'PluginSlot', | |
| })); |
| jest.mock('../EditorContainer', () => ({ | ||
| __esModule: true, | ||
| default: ({ children, onSave }) => ( | ||
| <div data-testid="editor-container"> | ||
| <button | ||
| type="button" | ||
| data-testid="save-button" | ||
| onClick={() => onSave && onSave()} | ||
| > | ||
| Save | ||
| </button> | ||
| {children} | ||
| </div> | ||
| ), | ||
| })); |
There was a problem hiding this comment.
Extend the stub so a test can prove isDirty is still callable and onClose fires once - both are broken today by the pluginProps merge.
| jest.mock('../EditorContainer', () => ({ | |
| __esModule: true, | |
| default: ({ | |
| children, onSave, onClose, isDirty, | |
| }) => ( | |
| <div data-testid="editor-container"> | |
| <button | |
| type="button" | |
| data-testid="save-button" | |
| onClick={() => onSave && onSave()} | |
| > | |
| Save | |
| </button> | |
| <button | |
| type="button" | |
| data-testid="close-button" | |
| onClick={() => { if (!isDirty()) { onClose(); } }} | |
| > | |
| Close | |
| </button> | |
| {children} | |
| </div> | |
| ), | |
| })); |
| expect(screen.queryByText('Each problem must have a unique timestamp. Please remove duplicate times.')).not.toBeInTheDocument(); | ||
| expect(thunkActions.inVideoQuiz.saveInVideoQuizSettings).toHaveBeenCalled(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
toHaveBeenCalled() passes even when the thunk fires twice (which it does today via the chained onSave). Assert the exact count, and add a test for the Close path - initializeMocks() calls jest.clearAllMocks() so per-test counts are safe.
| expect(thunkActions.inVideoQuiz.saveInVideoQuizSettings).toHaveBeenCalledTimes(1); | |
| }); | |
| it('passes props through the PluginSlot default content untouched (saves once, close does not throw)', () => { | |
| const onClose = jest.fn(); | |
| editorRender( | |
| <ConnectedInVideoQuizEditor onClose={onClose} />, | |
| { | |
| initialState: { | |
| ...baseState, | |
| inVideoQuiz: { | |
| ...baseState.inVideoQuiz, | |
| unitContentLoaded: true, | |
| selectedVideo: 'video-1', | |
| videos: [{ id: 'video-1', display_name: 'Video 1' }], | |
| problems: [{ id: 'problem-1', display_name: 'Problem 1' }], | |
| quizItems: [ | |
| { | |
| id: 'quiz-1', problemId: 'problem-1', time: '1:30', jumpBack: '', | |
| }, | |
| ], | |
| }, | |
| }, | |
| }, | |
| ); | |
| fireEvent.click(screen.getByTestId('save-button')); | |
| expect(thunkActions.inVideoQuiz.saveInVideoQuizSettings).toHaveBeenCalledTimes(1); | |
| // With the flat pluginProps, isDirty arrives as '' and this throws TypeError. | |
| expect(() => fireEvent.click(screen.getByTestId('close-button'))).not.toThrow(); | |
| expect(onClose).toHaveBeenCalledTimes(1); | |
| }); |
| ### Plugin Props: | ||
|
|
||
| * `onClose` - Function. Closes the editor. | ||
| * `returnFunction` - Function. Optional override for where to navigate after a successful save. | ||
| * `blockFinished` - Boolean. Whether the XBlock fetch request has completed. | ||
| * `blockId` - String. The XBlock usage id being edited. | ||
| * `blockValue` - Object. The raw XBlock field data. | ||
| * `selectedVideo` - String. Id of the currently selected video component in the unit. | ||
| * `videos` - Array. Video components available in the current unit. | ||
| * `problems` - Array. Problem components available in the current unit. | ||
| * `quizItems` - Array. The in-progress list of `{ problemId, time, jumpBack }` entries being edited. | ||
| * `unitContentLoaded` - Boolean. Whether the unit's video/problem components have finished loading. | ||
| * `isDirty` - Boolean. Whether there are unsaved changes. | ||
| * `saveError` - String or null. The current save-validation error message, if any. | ||
| * `setSelectedVideo` - Function. Redux action: sets the selected video by id. | ||
| * `addQuizItem` - Function. Redux action: appends a new empty `{ problemId, time, jumpBack }` row. | ||
| * `removeQuizItem` - Function. Redux action: removes a row by `{ index }`. | ||
| * `updateProblemId` - Function. Redux action: sets a row's `problemId` by `{ index, problemId }`. | ||
| * `updateTime` - Function. Redux action: sets a row's `time` by `{ index, time }`. | ||
| * `updateJumpBack` - Function. Redux action: sets a row's `jumpBack` by `{ index, jumpBack }`. | ||
| * `onSave` - Function. Validates `quizItems` and, if valid, saves via the `saveInVideoQuizSettings` thunk and navigates away. | ||
| * `getContent` - Function. Returns the `{ selectedVideo, quizItems }` payload to persist to the XBlock. |
There was a problem hiding this comment.
Update to match the namespaced shape - a plugin's RenderWidget receives one inVideoQuizEditor prop, not 20 flat ones.
| ### Plugin Props: | |
| * `onClose` - Function. Closes the editor. | |
| * `returnFunction` - Function. Optional override for where to navigate after a successful save. | |
| * `blockFinished` - Boolean. Whether the XBlock fetch request has completed. | |
| * `blockId` - String. The XBlock usage id being edited. | |
| * `blockValue` - Object. The raw XBlock field data. | |
| * `selectedVideo` - String. Id of the currently selected video component in the unit. | |
| * `videos` - Array. Video components available in the current unit. | |
| * `problems` - Array. Problem components available in the current unit. | |
| * `quizItems` - Array. The in-progress list of `{ problemId, time, jumpBack }` entries being edited. | |
| * `unitContentLoaded` - Boolean. Whether the unit's video/problem components have finished loading. | |
| * `isDirty` - Boolean. Whether there are unsaved changes. | |
| * `saveError` - String or null. The current save-validation error message, if any. | |
| * `setSelectedVideo` - Function. Redux action: sets the selected video by id. | |
| * `addQuizItem` - Function. Redux action: appends a new empty `{ problemId, time, jumpBack }` row. | |
| * `removeQuizItem` - Function. Redux action: removes a row by `{ index }`. | |
| * `updateProblemId` - Function. Redux action: sets a row's `problemId` by `{ index, problemId }`. | |
| * `updateTime` - Function. Redux action: sets a row's `time` by `{ index, time }`. | |
| * `updateJumpBack` - Function. Redux action: sets a row's `jumpBack` by `{ index, jumpBack }`. | |
| * `onSave` - Function. Validates `quizItems` and, if valid, saves via the `saveInVideoQuizSettings` thunk and navigates away. | |
| * `getContent` - Function. Returns the `{ selectedVideo, quizItems }` payload to persist to the XBlock. | |
| ### Plugin Props: | |
| All props are delivered under a single object prop, `inVideoQuizEditor` (e.g. `props.inVideoQuizEditor.onSave`). | |
| They are grouped this way because `PluginSlot` also spreads `pluginProps` onto the default content, and flat | |
| props whose names match `EditorContainer` props would be merged/chained with it. | |
| * `inVideoQuizEditor.onClose` - Function. Closes the editor. | |
| * `inVideoQuizEditor.returnFunction` - Function. Optional override for where to navigate after a successful save. | |
| * `inVideoQuizEditor.blockFinished` - Boolean. Whether the XBlock fetch request has completed. | |
| * `inVideoQuizEditor.blockId` - String. The XBlock usage id being edited. | |
| * `inVideoQuizEditor.blockValue` - Object. The raw XBlock field data. | |
| * `inVideoQuizEditor.selectedVideo` - String. Id of the currently selected video component in the unit. | |
| * `inVideoQuizEditor.videos` - Array. Video components available in the current unit. | |
| * `inVideoQuizEditor.problems` - Array. Problem components available in the current unit. | |
| * `inVideoQuizEditor.quizItems` - Array. The in-progress list of `{ problemId, time, jumpBack }` entries being edited. | |
| * `inVideoQuizEditor.unitContentLoaded` - Boolean. Whether the unit's video/problem components have finished loading. | |
| * `inVideoQuizEditor.isDirty` - Boolean. Whether there are unsaved changes. | |
| * `inVideoQuizEditor.saveError` - String or null. The current save-validation error message, if any. | |
| * `inVideoQuizEditor.setSelectedVideo` - Function. Redux action: sets the selected video by id. | |
| * `inVideoQuizEditor.addQuizItem` - Function. Redux action: appends a new empty `{ problemId, time, jumpBack }` row. | |
| * `inVideoQuizEditor.removeQuizItem` - Function. Redux action: removes a row by `{ index }`. | |
| * `inVideoQuizEditor.updateProblemId` - Function. Redux action: sets a row's `problemId` by `{ index, problemId }`. | |
| * `inVideoQuizEditor.updateTime` - Function. Redux action: sets a row's `time` by `{ index, time }`. | |
| * `inVideoQuizEditor.updateJumpBack` - Function. Redux action: sets a row's `jumpBack` by `{ index, jumpBack }`. | |
| * `inVideoQuizEditor.onSave` - Function. Validates `quizItems` and, if valid, saves via the `saveInVideoQuizSettings` thunk and navigates away. | |
| * `inVideoQuizEditor.getContent` - Function. Returns the `{ selectedVideo, quizItems }` payload to persist to the XBlock. |
| Moving this editor behind a slot allows it (like the Games editor) to be maintained entirely in an external | ||
| plugin package (e.g. `@edx/frontend-plugin-in-video-quiz`) instead of inside this MFE's fork, reducing merge | ||
| conflicts on upstream syncs. |
There was a problem hiding this comment.
There's no Games editor slot in this repo, and it's worth being explicit that only the UI moves behind the slot - the inVideoQuiz redux slice/thunks (src/editors/data/redux/inVideoQuiz/*, thunkActions/inVideoQuiz.js), cms/api.ts#saveInVideoQuizSettings, supportedEditors.ts and AddComponent.tsx stay fork-only.
| Moving this editor behind a slot allows it (like the Games editor) to be maintained entirely in an external | |
| plugin package (e.g. `@edx/frontend-plugin-in-video-quiz`) instead of inside this MFE's fork, reducing merge | |
| conflicts on upstream syncs. | |
| Moving this editor behind a slot allows the editor UI to be maintained in an external plugin package | |
| (e.g. `@edx/frontend-plugin-in-video-quiz`) instead of inside this MFE's fork, reducing merge conflicts on | |
| upstream syncs. The redux slice, thunks and CMS API client for in-video quiz remain in this repo and are | |
| exposed to the plugin via `inVideoQuizEditor`. |
| InVideoQuizEditorSlot.defaultProps = { | ||
| returnFunction: null, | ||
| blockId: null, | ||
| blockValue: null, | ||
| selectedVideo: null, | ||
| videos: [], | ||
| problems: [], | ||
| quizItems: [], | ||
| }; |
There was a problem hiding this comment.
Remove - defaults now live in the destructuring above. (The thin wrapper's InVideoQuizEditor.defaultProps at src/editors/containers/InVideoQuizEditor/index.jsx L53–L60 already resolves these before spreading into the slot, so this block was unreachable through supportedEditors.ts anyway.)
| InVideoQuizEditorSlot.defaultProps = { | |
| returnFunction: null, | |
| blockId: null, | |
| blockValue: null, | |
| selectedVideo: null, | |
| videos: [], | |
| problems: [], | |
| quizItems: [], | |
| }; |
| const InVideoQuizEditorSlot = ({ | ||
| onClose, | ||
| returnFunction, | ||
| blockFinished, | ||
| blockId, | ||
| blockValue, | ||
| selectedVideo, | ||
| videos, | ||
| problems, | ||
| quizItems, | ||
| unitContentLoaded, | ||
| setSelectedVideo, | ||
| addQuizItem, | ||
| removeQuizItem, | ||
| updateProblemId, | ||
| updateTime, | ||
| updateJumpBack, | ||
| loadInVideoQuizSettings, | ||
| saveInVideoQuizSettings, | ||
| isDirty, | ||
| }) => { |
There was a problem hiding this comment.
Nit (non-blocking): React 18.3 logs a deprecation warning for defaultProps on function components, and this file went the other way on returnFunction (it was a default parameter in the old InVideoQuizEditor/index.jsx L44, now defaultProps at L428). Since this is a new file, use default parameters and drop the defaultProps block at L427–L435 (second suggestion below). propTypes can stay.
| const InVideoQuizEditorSlot = ({ | |
| onClose, | |
| returnFunction, | |
| blockFinished, | |
| blockId, | |
| blockValue, | |
| selectedVideo, | |
| videos, | |
| problems, | |
| quizItems, | |
| unitContentLoaded, | |
| setSelectedVideo, | |
| addQuizItem, | |
| removeQuizItem, | |
| updateProblemId, | |
| updateTime, | |
| updateJumpBack, | |
| loadInVideoQuizSettings, | |
| saveInVideoQuizSettings, | |
| isDirty, | |
| }) => { | |
| const InVideoQuizEditorSlot = ({ | |
| onClose, | |
| returnFunction = null, | |
| blockFinished, | |
| blockId = null, | |
| blockValue = null, | |
| selectedVideo = null, | |
| videos = [], | |
| problems = [], | |
| quizItems = [], | |
| unitContentLoaded, | |
| setSelectedVideo, | |
| addQuizItem, | |
| removeQuizItem, | |
| updateProblemId, | |
| updateTime, | |
| updateJumpBack, | |
| loadInVideoQuizSettings, | |
| saveInVideoQuizSettings, | |
| isDirty, | |
| }) => { |
Summary
Moves the In-Video Quiz XBlock editor out of this MFE's own code and into a new
PluginSlot(org.openedx.frontend.authoring.in_video_quiz_editor.v1)What changed
src/plugin-slots/InVideoQuizEditorSlot/InVideoQuizEditoris now a thin wrapper that renders the slotWorking demo:
working-demo-in-devstack.mp4
jira-link - LP-912