Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flat-nails-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus-editor": minor
---

Add a shuffle choices toggle for radio widgets in the content editor
95 changes: 95 additions & 0 deletions packages/perseus-editor/src/editor-page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {Dependencies} from "@khanacademy/perseus";
import {
generateRadioWidget,
generateRadioOptions,
type PerseusOrdererWidgetOptions,
type PerseusRenderer,
} from "@khanacademy/perseus-core";
Expand Down Expand Up @@ -278,4 +280,97 @@ describe("EditorPage", () => {
screen.getByDisplayValue(/Updated content from parent/),
).toBeInTheDocument();
});

describe("radio shuffle preview stripping", () => {
function radioQuestion(
optionOverrides: Record<string, unknown> = {},
): 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<EditorPage>();

render(
<EditorPage
ref={editorRef}
dependencies={testDependenciesV2}
question={radioQuestion({
_showShuffledPreview: true,
})}
onChange={() => {}}
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<EditorPage>();

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(
<EditorPage
ref={editorRef}
dependencies={testDependenciesV2}
question={question}
onChange={() => {}}
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();
});
});
});
56 changes: 53 additions & 3 deletions packages/perseus-editor/src/editor-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props, State> {
_isMounted: boolean;

Expand Down Expand Up @@ -124,14 +139,16 @@ class EditorPage extends React.Component<Props, State> {

getSnapshotBeforeUpdate(prevProps: Props, prevState: State) {
if (!prevProps.jsonMode && this.props.jsonMode) {
return {
const snapshot = {
...(this.itemEditor.current?.serialize({
keepDeletedWidgets: true,
}) ?? {}),
hints: this.hintsEditor.current?.serialize({
keepDeletedWidgets: true,
}),
};
stripEditorOnlyRadioFields(snapshot);
return snapshot;
}
return null;
}
Expand Down Expand Up @@ -222,7 +239,7 @@ class EditorPage extends React.Component<Props, State> {
this.itemEditor.current?.triggerPreviewUpdate({
type: "question",
data: _({
item: this.serialize(),
item: this.serializeForPreview(),
apiOptions: deviceBasedApiOptions,
initialHintsVisible: 0,
device: this.props.previewDevice,
Expand Down Expand Up @@ -255,9 +272,42 @@ class EditorPage extends React.Component<Props, State> {
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
Expand Down
139 changes: 139 additions & 0 deletions packages/perseus-editor/src/widgets/__tests__/radio-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RadioEditor>();

render(
<RadioEditor
ref={editorRef}
onChange={() => {}}
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<RadioEditor>();

render(
<RadioEditor
ref={editorRef}
onChange={() => {}}
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<RadioEditor>();

render(
<RadioEditor
ref={editorRef}
onChange={() => {}}
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<RadioEditor>();

render(
<RadioEditor
ref={editorRef}
onChange={() => {}}
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
Expand Down
Loading
Loading