Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c45785c
[LEMS-3958/planning-not-scored] Add planning document for unscored IG
handeyeco Apr 8, 2026
c0f0875
[LEMS-3958/planning-not-scored] docs(changeset):
handeyeco Apr 8, 2026
17d86ec
[LEMS-3958/planning-not-scored] update requirements
handeyeco Apr 8, 2026
ae925c0
[LEMS-3958/implementation-experiment] Experiment with Claude implemen…
handeyeco Apr 8, 2026
07211b3
[LEMS-3958/implementation-experiment] added widget editor settings to…
ivyolamit Apr 14, 2026
42d05a1
[LEMS-3958/planning-not-scored] respond to feedback
handeyeco Apr 24, 2026
a92450a
[LEMS-3958/implementation-experiment] fix linting issues
handeyeco Apr 24, 2026
5365c6b
[LEMS-3958/implementation-experiment] fix denylist issue maybe?
handeyeco Apr 24, 2026
8c5827f
[LEMS-3958/implementation-experiment] update comment
handeyeco Apr 24, 2026
625f199
[LEMS-3958/implementation-experiment] add graded to IG builder
handeyeco Apr 24, 2026
1b4a399
[LEMS-3958/implementation-experiment] make string translatable
handeyeco Apr 24, 2026
b389055
[LEMS-3958/implementation-experiment] rename flag
handeyeco Apr 24, 2026
49a824a
[LEMS-3958/implementation-experiment] clean up pointless tests
handeyeco Apr 24, 2026
3ebf3fb
[LEMS-3958/implementation-experiment] don't hide correct answer for I…
handeyeco Apr 24, 2026
20fee9a
[LEMS-3958/implementation-experiment] fix tests
handeyeco Apr 24, 2026
fb9acbc
[LEMS-3958/implementation-experiment] remove unneeded tests
handeyeco Apr 24, 2026
3fc7ff6
[LEMS-3958/implementation-experiment] add some tests around the grade…
handeyeco Apr 24, 2026
c2b5f5f
[LEMS-3958/implementation-experiment] make static/graded mutually exc…
handeyeco Apr 24, 2026
92c3514
[LEMS-3958/implementation-experiment] add visual regression for ungraded
handeyeco Apr 24, 2026
858a81b
[LEMS-3958/implementation-experiment] add interactive ungraded story
handeyeco Apr 24, 2026
4c8216f
[LEMS-3958/implementation-experiment] docs(changeset): Implement "ung…
handeyeco Apr 24, 2026
b7aac45
[LEMS-3958/implementation-experiment] make the UI more intuitive
handeyeco Apr 24, 2026
22a7928
[LEMS-3958/implementation-experiment] respond to Claude feedback
handeyeco Apr 24, 2026
3cafb1d
[LEMS-3958/planning-not-scored] Merge branch 'main' into LEMS-3958/pl…
handeyeco Apr 28, 2026
f525441
[LEMS-3958/implementation-experiment] fix conflict
handeyeco Apr 28, 2026
6b80819
[LEMS-3958/implementation-experiment] fix linting issue
handeyeco Apr 28, 2026
cc38bfc
[LEMS-3958/implementation-experiment] fix space
handeyeco Apr 28, 2026
9a027d4
[LEMS-3958/implementation-experiment] respond to feedback
handeyeco Apr 30, 2026
f2ce259
[LEMS-3958/implementation-experiment] remove strut
handeyeco Apr 30, 2026
58a943e
[LEMS-3958/implementation-experiment] use sizing and interface
ivyolamit Apr 30, 2026
6d81c3e
[LEMS-3958/implementation-experiment] fix alignment
ivyolamit Apr 30, 2026
5d147d0
[LEMS-3958/implementation-experiment] add feature flag
handeyeco May 4, 2026
0cea41e
[LEMS-3958/implementation-experiment] fix conflicts
handeyeco May 5, 2026
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
2 changes: 2 additions & 0 deletions .changeset/famous-ghosts-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
8 changes: 8 additions & 0 deletions .changeset/short-drinks-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@khanacademy/perseus": minor
"@khanacademy/perseus-editor": minor
"@khanacademy/perseus-score": minor
"@khanacademy/perseus-core": patch
---

Implement "ungraded" InteractiveGraphs: interactive IGs that aren't scored as part of the regular scoring flow
2 changes: 1 addition & 1 deletion __docs__/sample-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {PerseusRenderer} from "@khanacademy/perseus-core";
// it.
export const graphExample: PerseusRenderer = {
content:
"An example of a the beautiful**interactive-graph** widget:\n\n[[☃ interactive-graph 1]]",
"An example of a the beautiful **interactive-graph** widget:\n\n[[☃ interactive-graph 1]]",
widgets: {
"interactive-graph 1": {
type: "interactive-graph",
Expand Down
13 changes: 12 additions & 1 deletion packages/perseus-core/src/data-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,19 @@ export type WidgetOptions<
*/
static?: boolean;
/**
* Whether a widget is scored. Usually true except for IFrame widgets (deprecated).
* Whether a widget is scored.
* Default: true
*
* The behavior depends on how the widget decides to implement it.
* For example, Interactive Graph will render an ungraded graph
* that is still interactive that learners can use to visualize
* math.
*
* Historical uses seem questionable (See LEMS-3958):
* - IFrame
* - Explanation
* - Image
* - Transformer (deprecated)
*/
graded?: boolean;
/**
Expand Down
1 change: 1 addition & 0 deletions packages/perseus-core/src/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const PerseusFeatureFlags = [
"interactive-graph-logarithm", // TODO(LEMS-3976): clean up feature flag
"interactive-graph-exponent", // TODO(LEMS-3976): clean up feature flag
"interactive-graph-vector", // TODO(LEMS-3976): clean up feature flag
"interactive-graph-not-scored", // TODO(LEMS-3976): clean up feature flag
] as const;

export default PerseusFeatureFlags;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,15 @@ export function generateInteractiveGraphQuestion(
options?: Partial<PerseusInteractiveGraphWidgetOptions> & {
content?: string;
isStatic?: boolean;
graded?: boolean;
},
): PerseusRenderer {
const {content, isStatic, ...widgetOptions} = options ?? {};
const {
content = "[[☃ interactive-graph 1]]",
isStatic = false,
graded = true,
...widgetOptions
} = options ?? {};

// The `graph` and `correct` fields share all fields except for
// the answers (coords, center, etc.) When only `correct` is provided,
Expand All @@ -293,10 +299,11 @@ export function generateInteractiveGraphQuestion(
};

return generateTestPerseusRenderer({
content: content ?? "[[☃ interactive-graph 1]]",
content: content,
widgets: {
"interactive-graph 1": generateInteractiveGraphWidget({
static: isStatic ?? false,
static: isStatic,
graded,
options: generateInteractiveGraphOptions(optionsWithDefaults),
}),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {testDependencies} from "../../testing/test-dependencies";
import {registerAllWidgetsAndEditorsForTesting} from "../../util/register-all-widgets-and-editors-for-testing";
import WidgetEditor from "../widget-editor";

import type {Alignment} from "@khanacademy/perseus-core";
import type {UserEvent} from "@testing-library/user-event";

describe("WidgetEditor", () => {
Expand Down Expand Up @@ -37,7 +36,7 @@ describe("WidgetEditor", () => {
"getSupportedAlignments",
).mockReturnValue(["block", "inline", "full-width"]);

const {container} = render(
render(
<WidgetEditor
id="radio 1"
type="radio"
Expand All @@ -58,10 +57,9 @@ describe("WidgetEditor", () => {
// Assert
// Even though getSupportedAlignments returns multiple alignments,
// the dropdown should NOT be shown because showAlignmentOptions is false
const alignmentDropdown =
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
container.querySelector("select.alignment");
expect(alignmentDropdown).not.toBeInTheDocument();
expect(
screen.queryByRole("combobox", {name: "Alignment"}),
).not.toBeInTheDocument();
});

it("should display alignment dropdown when widget supports multiple alignments", () => {
Expand Down Expand Up @@ -91,9 +89,10 @@ describe("WidgetEditor", () => {
);

// Assert
const dropdown = screen.getByRole("combobox");
const dropdown = screen.getByRole("combobox", {
name: "Alignment",
});
expect(dropdown).toBeInTheDocument();
expect(dropdown).toHaveClass("alignment");
});

it("should NOT display alignment dropdown when widget supports only one alignment", () => {
Expand Down Expand Up @@ -123,45 +122,9 @@ describe("WidgetEditor", () => {
);

// Assert
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});

it("should display all alignment options in the dropdown", () => {
// Arrange
const alignments: readonly Alignment[] = [
"block",
"inline",
"full-width",
];
jest.spyOn(
CoreWidgetRegistry,
"getSupportedAlignments",
).mockReturnValue(alignments);

render(
<WidgetEditor
id="image 1"
type="image"
alignment="block"
static={false}
graded={true}
options={{}}
version={{major: 0, minor: 0}}
onChange={() => {}}
onRemove={() => {}}
apiOptions={{
...ApiOptions.defaults,
showAlignmentOptions: true,
}}
/>,
);

// Assert
const options = screen.getAllByRole("option");
expect(options).toHaveLength(3);
expect(options[0]).toHaveTextContent("block");
expect(options[1]).toHaveTextContent("inline");
expect(options[2]).toHaveTextContent("full-width");
expect(
screen.queryByRole("combobox", {name: "Alignment"}),
).not.toBeInTheDocument();
});

it("should call onChange when alignment is changed", async () => {
Expand Down Expand Up @@ -191,8 +154,12 @@ describe("WidgetEditor", () => {
);

// Act
const dropdown = screen.getByRole("combobox");
await userEvent.selectOptions(dropdown, "inline");
const dropdown = screen.getByRole("combobox", {
name: "Alignment",
});
await userEvent.click(dropdown);
const option = screen.getByRole("option", {name: "inline"});
await userEvent.click(option);

// Assert
expect(onChangeMock).toHaveBeenCalledWith(
Expand Down Expand Up @@ -229,8 +196,10 @@ describe("WidgetEditor", () => {
);

// Assert
const dropdown = screen.getByRole("combobox");
expect(dropdown).toBeDisabled();
const dropdown = screen.getByRole("combobox", {
name: "Alignment",
});
expect(dropdown).toHaveAttribute("aria-disabled", "true");
});
});
});
67 changes: 52 additions & 15 deletions packages/perseus-editor/src/components/alignment-select.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import {components} from "@khanacademy/perseus";
import {View} from "@khanacademy/wonder-blocks-core";
import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown";
import {sizing} from "@khanacademy/wonder-blocks-tokens";
import {BodyText} from "@khanacademy/wonder-blocks-typography";
import {StyleSheet} from "aphrodite";
import * as React from "react";
import {useId} from "react";

import {alignmentInfoMap} from "./util";

import type {Alignment, PerseusWidget} from "@khanacademy/perseus-core";
import type {StyleType} from "@khanacademy/wonder-blocks-core";

const {InfoTip} = components;

Expand All @@ -13,24 +19,60 @@ interface Props {
widgetInfo: PerseusWidget;
isEditingDisabled: boolean;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
style?: StyleType;
}

export function AlignmentSelect({
supportedAlignments,
widgetInfo,
isEditingDisabled,
onChange,
style,
}: Props) {
const labelId = useId();
return (
<>
<View
style={[
{
flexDirection: "row",
alignItems: "center",
gap: sizing.size_080,
},
style,
]}
>
<BodyText id={labelId} tag="span">
Alignment
</BodyText>
<SingleSelect
aria-labelledby={labelId}
selectedValue={widgetInfo.alignment ?? "default"}
disabled={isEditingDisabled}
onChange={(value) => {
// Create a synthetic-like event to match the existing
// onChange signature expected by WidgetEditor
const syntheticEvent = {
currentTarget: {value},
} as React.ChangeEvent<HTMLSelectElement>;
onChange(syntheticEvent);
}}
placeholder="Select alignment"
style={styles.singleSelectShort}
>
{supportedAlignments.map((alignment) => (
<OptionItem
key={alignment}
value={alignment}
label={alignment}
/>
))}
</SingleSelect>
<InfoTip>
<ul>
{supportedAlignments.map((alignment, index) => (
<li
key={alignment}
style={{
// Put line breaks after each alignment description
// except the last one.
marginBlockEnd:
index < supportedAlignments.length - 1
? sizing.size_240
Expand All @@ -42,17 +84,12 @@ export function AlignmentSelect({
))}
</ul>
</InfoTip>
<select
className="alignment"
value={widgetInfo.alignment}
disabled={isEditingDisabled}
onChange={onChange}
style={{marginLeft: sizing.size_060}}
>
{supportedAlignments.map((alignment) => (
<option key={alignment}>{alignment}</option>
))}
</select>
</>
</View>
);
}

const styles = StyleSheet.create({
singleSelectShort: {
height: sizing.size_260,
},
});
10 changes: 10 additions & 0 deletions packages/perseus-editor/src/components/widget-editor-settings.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* We have to use !important until wonder blocks is in the shared layer. */
/* TODO(LEMS-3686): Remove the !important once we don't need it anymore. */
.widget-editor-settings-container {
padding-inline: var(--wb-sizing-size_120) !important;
padding-block-start: var(--wb-sizing-size_120) !important;
}

.best-practices-container {
margin-block-end: var(--wb-sizing-size_060) !important;
}
Loading