Skip to content

feat: add plugin slot for xblock-invideo-quiz - #119

Closed
alenkadev wants to merge 2 commits into
release-ulmofrom
alenka/plugin-slot-xblock-invideoquiz
Closed

alenkadev wants to merge 2 commits into
release-ulmofrom
alenka/plugin-slot-xblock-invideoquiz

Conversation

@alenkadev

@alenkadev alenkadev commented Sep 13, 2026

Copy link
Copy Markdown

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

  • Extracted all the editor UI, validation, and save logic into src/plugin-slots/InVideoQuizEditorSlot/
  • InVideoQuizEditor is now a thin wrapper that renders the slot
  • Default behavior is unchanged — a site that doesn't configure a plugin sees exactly the same editor as before

Working demo:

working-demo-in-devstack.mp4

jira-link - LP-912

Comment on lines +349 to +375
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 }),
}}
>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 '' / trueisDirty() 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.

Suggested change
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 }}
>

Comment on lines +21 to +24
jest.mock('@openedx/frontend-plugin-framework', () => ({
PluginSlot: 'PluginSlot',
}));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
jest.mock('@openedx/frontend-plugin-framework', () => ({
PluginSlot: 'PluginSlot',
}));

Comment on lines 25 to 39
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>
),
}));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extend the stub so a test can prove isDirty is still callable and onClose fires once - both are broken today by the pluginProps merge.

Suggested change
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();
});
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
});

Comment on lines +8 to +29
### 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update to match the namespaced shape - a plugin's RenderWidget receives one inVideoQuizEditor prop, not 20 flat ones.

Suggested change
### 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.

Comment on lines +40 to +42
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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`.

Comment on lines +427 to +435
InVideoQuizEditorSlot.defaultProps = {
returnFunction: null,
blockId: null,
blockValue: null,
selectedVideo: null,
videos: [],
problems: [],
quizItems: [],
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
InVideoQuizEditorSlot.defaultProps = {
returnFunction: null,
blockId: null,
blockValue: null,
selectedVideo: null,
videos: [],
problems: [],
quizItems: [],
};

Comment on lines +40 to +60
const InVideoQuizEditorSlot = ({
onClose,
returnFunction,
blockFinished,
blockId,
blockValue,
selectedVideo,
videos,
problems,
quizItems,
unitContentLoaded,
setSelectedVideo,
addQuizItem,
removeQuizItem,
updateProblemId,
updateTime,
updateJumpBack,
loadInVideoQuizSettings,
saveInVideoQuizSettings,
isDirty,
}) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
}) => {

@alenkadev alenkadev closed this Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants