diff --git a/.changeset/flat-nails-tell.md b/.changeset/flat-nails-tell.md new file mode 100644 index 0000000000..d650a71347 --- /dev/null +++ b/.changeset/flat-nails-tell.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-editor": minor +--- + +Add a shuffle choices toggle for radio widgets in the content editor diff --git a/packages/perseus-editor/src/editor-page.test.tsx b/packages/perseus-editor/src/editor-page.test.tsx index 358f037df5..2ecb895442 100644 --- a/packages/perseus-editor/src/editor-page.test.tsx +++ b/packages/perseus-editor/src/editor-page.test.tsx @@ -1,5 +1,7 @@ import {Dependencies} from "@khanacademy/perseus"; import { + generateRadioWidget, + generateRadioOptions, type PerseusOrdererWidgetOptions, type PerseusRenderer, } from "@khanacademy/perseus-core"; @@ -278,4 +280,97 @@ describe("EditorPage", () => { screen.getByDisplayValue(/Updated content from parent/), ).toBeInTheDocument(); }); + + describe("radio shuffle preview stripping", () => { + function radioQuestion( + optionOverrides: Record = {}, + ): PerseusRenderer { + return { + content: "[[☃ radio 1]]", + images: {}, + widgets: { + "radio 1": generateRadioWidget({ + options: generateRadioOptions({ + choices: [ + {id: "id-1", content: "A", correct: true}, + {id: "id-2", content: "B"}, + ], + randomize: true, + ...optionOverrides, + }), + }), + }, + }; + } + + it("strips _showShuffledPreview from serialize() output", () => { + const editorRef = React.createRef(); + + render( + {}} + onPreviewDeviceChange={() => {}} + previewDevice="desktop" + previewURL="" + itemId="itemId" + developerMode={false} + jsonMode={false} + />, + ); + + const serialized = editorRef.current?.serialize(); + const options = serialized?.question?.widgets?.["radio 1"]?.options; + + expect(options?._showShuffledPreview).toBeUndefined(); + expect(options?.randomize).toBe(true); + }); + + it("strips _showShuffledPreview from non-radio widgets without error", () => { + const editorRef = React.createRef(); + + const question: PerseusRenderer = { + content: "[[☃ categorizer 1]]", + images: {}, + widgets: { + "categorizer 1": { + type: "categorizer", + static: false, + options: { + static: false, + items: ["A"], + categories: ["B"], + values: [0], + randomizeItems: false, + }, + }, + }, + }; + + render( + {}} + onPreviewDeviceChange={() => {}} + previewDevice="desktop" + previewURL="" + itemId="itemId" + developerMode={false} + jsonMode={false} + />, + ); + + // Should not throw + const serialized = editorRef.current?.serialize(); + expect( + serialized?.question?.widgets?.["categorizer 1"], + ).toBeDefined(); + }); + }); }); diff --git a/packages/perseus-editor/src/editor-page.tsx b/packages/perseus-editor/src/editor-page.tsx index 79ef6523b3..e7a1e25c8f 100644 --- a/packages/perseus-editor/src/editor-page.tsx +++ b/packages/perseus-editor/src/editor-page.tsx @@ -87,6 +87,21 @@ type State = { widgetsAreOpen: boolean; }; +// Strips editor-only marker fields (e.g. _showShuffledPreview) from +// radio widget options so they are never persisted to content data. +function stripEditorOnlyRadioFields(item: any) { + const widgets = item?.question?.widgets; + if (widgets) { + for (const widgetId of Object.keys(widgets)) { + const widget = widgets[widgetId]; + if (widget?.type === "radio" && widget.options) { + widget.options = {...widget.options}; + delete widget.options._showShuffledPreview; + } + } + } +} + class EditorPage extends React.Component { _isMounted: boolean; @@ -124,7 +139,7 @@ class EditorPage extends React.Component { getSnapshotBeforeUpdate(prevProps: Props, prevState: State) { if (!prevProps.jsonMode && this.props.jsonMode) { - return { + const snapshot = { ...(this.itemEditor.current?.serialize({ keepDeletedWidgets: true, }) ?? {}), @@ -132,6 +147,8 @@ class EditorPage extends React.Component { keepDeletedWidgets: true, }), }; + stripEditorOnlyRadioFields(snapshot); + return snapshot; } return null; } @@ -222,7 +239,7 @@ class EditorPage extends React.Component { this.itemEditor.current?.triggerPreviewUpdate({ type: "question", data: _({ - item: this.serialize(), + item: this.serializeForPreview(), apiOptions: deviceBasedApiOptions, initialHintsVisible: 0, device: this.props.previewDevice, @@ -255,9 +272,42 @@ class EditorPage extends React.Component { if (this.props.jsonMode) { return this.state.json; } - return _.extend(this.itemEditor.current?.serialize(options), { + const result = _.extend(this.itemEditor.current?.serialize(options), { hints: this.hintsEditor.current?.serialize(options), }); + stripEditorOnlyRadioFields(result); + + return result; + } + + // Serializes item data for the Edit tab iframe preview. + // Unlike serialize() (the save path), this preserves the + // _showShuffledPreview marker to control preview shuffling, + // then applies the preview-specific shuffle override. + serializeForPreview(): PerseusItem { + const item = _.extend(this.itemEditor.current?.serialize(), { + hints: this.hintsEditor.current?.serialize(), + }); + + // For the Edit tab preview, default radio widgets to unshuffled + // unless the editor's "Shuffle preview" toggle is on. + const widgets = item?.question?.widgets; + if (widgets) { + for (const widgetId of Object.keys(widgets)) { + const widget = widgets[widgetId]; + if (widget?.type === "radio" && widget.options) { + // Shallow copy to avoid mutating the source object + widget.options = {...widget.options}; + if (widget.options._showShuffledPreview) { + delete widget.options._showShuffledPreview; + } else { + widget.options.randomize = false; + } + } + } + } + + return item; } // eslint-disable-next-line import/no-deprecated diff --git a/packages/perseus-editor/src/widgets/__tests__/radio-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/radio-editor.test.tsx index dd114ad5a5..2a6f73f8b8 100644 --- a/packages/perseus-editor/src/widgets/__tests__/radio-editor.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/radio-editor.test.tsx @@ -1363,6 +1363,145 @@ describe("radio-editor", () => { }); }); + describe("Shuffle preview toggle", () => { + it("renders the shuffle preview toggle", () => { + renderRadioEditor(); + + expect( + screen.getByRole("switch", {name: "Shuffle preview"}), + ).toBeInTheDocument(); + }); + + it("is disabled when randomize is off", () => { + renderRadioEditor(() => {}, {randomize: false}); + + const toggle = screen.getByRole("switch", { + name: "Shuffle preview", + }); + expect(toggle).toHaveAttribute("aria-disabled", "true"); + }); + + it("is enabled when randomize is on", () => { + renderRadioEditor(() => {}, {randomize: true}); + + const toggle = screen.getByRole("switch", { + name: "Shuffle preview", + }); + expect(toggle).not.toHaveAttribute("aria-disabled", "true"); + }); + + it("does not include _showShuffledPreview in serialize when toggle is off", () => { + const editorRef = React.createRef(); + + render( + {}} + apiOptions={ApiOptions.defaults} + static={false} + choices={fourChoices} + randomize={true} + />, + {wrapper: RenderStateRoot}, + ); + + const options = editorRef.current?.serialize(); + expect(options?._showShuffledPreview).toBeUndefined(); + }); + + it("includes _showShuffledPreview in serialize when prop is true and randomize is on", () => { + const editorRef = React.createRef(); + + render( + {}} + apiOptions={ApiOptions.defaults} + static={false} + choices={fourChoices} + randomize={true} + _showShuffledPreview={true} + />, + {wrapper: RenderStateRoot}, + ); + + const options = editorRef.current?.serialize(); + expect(options?._showShuffledPreview).toBe(true); + }); + + it("does not include _showShuffledPreview when randomize is off even if prop is true", () => { + const editorRef = React.createRef(); + + render( + {}} + apiOptions={ApiOptions.defaults} + static={false} + choices={fourChoices} + randomize={false} + _showShuffledPreview={true} + />, + {wrapper: RenderStateRoot}, + ); + + const options = editorRef.current?.serialize(); + expect(options?._showShuffledPreview).toBeUndefined(); + expect(options?.randomize).toBe(false); + }); + + it("calls onChange with _showShuffledPreview when toggled", async () => { + const onChangeMock = jest.fn(); + + renderRadioEditor(onChangeMock, {randomize: true}); + + await userEvent.click( + screen.getByRole("switch", {name: "Shuffle preview"}), + ); + + expect(onChangeMock).toHaveBeenCalledWith({ + _showShuffledPreview: true, + }); + }); + + it("resets _showShuffledPreview when randomize is turned off", async () => { + const onChangeMock = jest.fn(); + + renderRadioEditor(onChangeMock, { + randomize: true, + _showShuffledPreview: true, + }); + + await userEvent.click( + screen.getByRole("switch", {name: "Randomize order"}), + ); + + expect(onChangeMock).toHaveBeenCalledWith({ + randomize: false, + _showShuffledPreview: false, + }); + }); + + it("preserves the real randomize value in serialize", () => { + const editorRef = React.createRef(); + + render( + {}} + apiOptions={ApiOptions.defaults} + static={false} + choices={fourChoices} + randomize={true} + />, + {wrapper: RenderStateRoot}, + ); + + const options = editorRef.current?.serialize(); + expect(options?.randomize).toBe(true); + }); + }); + describe("ensureValidIds", () => { it("should generate new ID for empty string", () => { // Reset mock and set specific return value diff --git a/packages/perseus-editor/src/widgets/radio/editor.tsx b/packages/perseus-editor/src/widgets/radio/editor.tsx index 6d8e234abc..ec44c2cc18 100644 --- a/packages/perseus-editor/src/widgets/radio/editor.tsx +++ b/packages/perseus-editor/src/widgets/radio/editor.tsx @@ -1,8 +1,9 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ +import {components} from "@khanacademy/perseus"; import {radioLogic, deriveNumCorrect} from "@khanacademy/perseus-core"; import Button from "@khanacademy/wonder-blocks-button"; import Link from "@khanacademy/wonder-blocks-link"; -import {sizing} from "@khanacademy/wonder-blocks-tokens"; +import {semanticColor, sizing} from "@khanacademy/wonder-blocks-tokens"; import {Footnote} from "@khanacademy/wonder-blocks-typography"; import plusIcon from "@phosphor-icons/core/bold/plus-bold.svg"; import * as React from "react"; @@ -22,6 +23,12 @@ import type { RadioDefaultWidgetOptions, } from "@khanacademy/perseus-core"; +const {InfoTip} = components; + +type RadioSerializedOptions = PerseusRadioWidgetOptions & { + _showShuffledPreview?: boolean; +}; + // Exported for testing export interface RadioEditorProps { apiOptions: APIOptions; @@ -32,9 +39,10 @@ export interface RadioEditorProps { multipleSelect: boolean; deselectEnabled: boolean; static: boolean; + _showShuffledPreview?: boolean; onChange: ( - values: Partial, + values: Partial, callback?: (() => void) | null, ) => void; } @@ -298,7 +306,7 @@ class RadioEditor extends React.Component { return []; }; - serialize(): PerseusRadioWidgetOptions { + serialize(): RadioSerializedOptions { const { choices, randomize, @@ -306,9 +314,10 @@ class RadioEditor extends React.Component { countChoices, hasNoneOfTheAbove, deselectEnabled, + _showShuffledPreview, } = this.props; - return { + const options: RadioSerializedOptions = { choices, randomize, multipleSelect, @@ -317,6 +326,12 @@ class RadioEditor extends React.Component { deselectEnabled, numCorrect: deriveNumCorrect(choices), }; + + if (randomize && _showShuffledPreview) { + options._showShuffledPreview = true; + } + + return options; } render(): React.ReactNode { @@ -332,14 +347,50 @@ class RadioEditor extends React.Component { Multiple choice best practices
+
+ { + this.props.onChange({ + randomize: value, + ...(!value && { + _showShuffledPreview: false, + }), + }); + }} + /> + + The editor preview shows choices unshuffled by + default. Use "Shuffle preview" to see the + randomized order. The Preview tab always shows the + randomized order when enabled. + +
{ - this.props.onChange({randomize: value}); + this.props.onChange({ + _showShuffledPreview: value, + }); + }} + style={{ + marginBlockEnd: sizing.size_060, + ...(!this.props.randomize && { + color: semanticColor.core.foreground.disabled + .default, + }), }} - style={{marginBlockEnd: sizing.size_060}} />