Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Locked Labels] Implement adding/editing/deleting a standalone locked label #1539

Merged
merged 9 commits into from
Aug 22, 2024
6 changes: 6 additions & 0 deletions .changeset/angry-hounds-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": patch
"@khanacademy/perseus-editor": minor
---

[Locked Labels] Implement adding/editing/deleting a standalone locked label
6 changes: 6 additions & 0 deletions .changeset/violet-worms-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": minor
"@khanacademy/perseus-editor": minor
---

[Locked labels] View locked labels in an Interactive Graph
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const flags = {

// Locked figures flags
"interactive-graph-locked-features-m2b": true,
"interactive-graph-locked-features-labels": true,

// Start coords UI flags
// TODO(LEMS-2228): Remove flags once this is fully released
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const MafsWithLockedFiguresCurrent = (): React.ReactElement => {
flags: {
mafs: {
...flags.mafs,
"interactive-graph-locked-features-m2b": false,
"interactive-graph-locked-features-labels": false,
},
},
}}
Expand All @@ -152,13 +152,13 @@ MafsWithLockedFiguresCurrent.parameters = {
},
};

export const MafsWithLockedFiguresM2bFlag = (): React.ReactElement => {
export const MafsWithLockedLabelsFlag = (): React.ReactElement => {
return (
<EditorPageWithStorybookPreview question={segmentWithLockedFigures} />
);
};

MafsWithLockedFiguresM2bFlag.parameters = {
MafsWithLockedLabelsFlag.parameters = {
chromatic: {
// Disabling because this isn't visually testing anything on the
// initial load of the editor page.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as React from "react";

import LockedLabelSettings from "../graph-locked-figures/locked-label-settings";
import {getDefaultFigureForType} from "../util";

import type {Meta, StoryObj} from "@storybook/react";

export default {
title: "PerseusEditor/Components/Locked Label Settings",
component: LockedLabelSettings,
} as Meta<typeof LockedLabelSettings>;

export const Default = (args): React.ReactElement => {
return <LockedLabelSettings {...args} />;
};

const defaultProps = {
...getDefaultFigureForType("label"),
onChangeProps: () => {},
onMove: () => {},
onRemove: () => {},
};

type StoryComponentType = StoryObj<typeof LockedLabelSettings>;

// Set the default values in the control panel.
Default.args = defaultProps;

export const Expanded: StoryComponentType = {
render: function Render() {
const [props, setProps] = React.useState(defaultProps);

const handlePropsUpdate = (newProps) => {
setProps({
...props,
...newProps,
});
};

return (
<LockedLabelSettings
{...props}
expanded={true}
onChangeProps={handlePropsUpdate}
/>
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("Locked Function Settings", () => {
expect(titleText).toBeInTheDocument();
});

describe("Heading interactions", () => {
describe("Header interactions", () => {
test("should show the function's color and stroke by default", () => {
// Arrange
render(<LockedFunctionSettings {...defaultProps} />, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
import {render, screen} from "@testing-library/react";
import {userEvent as userEventLib} from "@testing-library/user-event";
import * as React from "react";

import LockedLabelSettings from "../graph-locked-figures/locked-label-settings";
import {getDefaultFigureForType} from "../util";

import type {Props} from "../graph-locked-figures/locked-label-settings";
import type {UserEvent} from "@testing-library/user-event";

const defaultProps = {
...getDefaultFigureForType("label"),
onChangeProps: () => {},
onMove: () => {},
onRemove: () => {},
} as Props;

describe("Locked Label Settings", () => {
let userEvent: UserEvent;
const onChangeProps = jest.fn();

beforeEach(() => {
userEvent = userEventLib.setup({
advanceTimers: jest.advanceTimersByTime,
});
});

test("renders as expected with default values", () => {
// Arrange

// Act
render(<LockedLabelSettings {...defaultProps} />, {
wrapper: RenderStateRoot,
});

// Assert
const titleText = screen.getByText("Label (0, 0)");
expect(titleText).toBeInTheDocument();
});

describe("Header interactions", () => {
test("should show the correct coords in the header", () => {
// Arrange
render(
<LockedLabelSettings
{...defaultProps}
coord={[1, 2]}
text=""
/>,
{
wrapper: RenderStateRoot,
},
);

// Act
const titleText = screen.getByText("Label (1, 2)");

// Assert
expect(titleText).toBeInTheDocument();
});

test("should show the text in the header", () => {
// Arrange
render(
<LockedLabelSettings
{...defaultProps}
color="blue"
text="something"
/>,
{
wrapper: RenderStateRoot,
},
);

// Act
const text = screen.getByText("something");

// Assert
expect(text).toBeInTheDocument();
});

test("calls 'onToggle' when header is clicked", async () => {
// Arrange
const onToggle = jest.fn();
render(
<LockedLabelSettings
{...defaultProps}
text="something"
onToggle={onToggle}
/>,
{
wrapper: RenderStateRoot,
},
);

// Act
const header = screen.getByRole("button", {
name: "Label (0, 0) something",
});
await userEvent.click(header);

// Assert
expect(onToggle).toHaveBeenCalled();
});
});

describe("Settings interactions", () => {
test("calls 'onChangeProps' when coords are changed", async () => {
// Arrange
render(
<LockedLabelSettings
{...defaultProps}
onChangeProps={onChangeProps}
/>,
{
wrapper: RenderStateRoot,
},
);

// Act
const xCoordInput = screen.getByRole("spinbutton", {
name: "x coord",
});
const yCoordInput = screen.getByRole("spinbutton", {
name: "y coord",
});

await userEvent.clear(xCoordInput);
await userEvent.type(xCoordInput, "1");

await userEvent.clear(yCoordInput);
await userEvent.type(yCoordInput, "2");

// Assert
expect(onChangeProps).toHaveBeenCalledTimes(2);
// Calls are not being accumulated because they're mocked.
expect(onChangeProps).toHaveBeenNthCalledWith(1, {coord: [1, 0]});
expect(onChangeProps).toHaveBeenNthCalledWith(2, {coord: [0, 2]});
Copy link
Member

Choose a reason for hiding this comment

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

You can make these assertions with a single expect call by doing:

expect(onChangeProps.mock.calls).toEqual([
  [{coord: [1, 0]}],
  [{coord: [0, 2]}],
]);

});

test("calls 'onChangeProps' when text is changed", async () => {
// Arrange
render(
<LockedLabelSettings
{...defaultProps}
onChangeProps={onChangeProps}
/>,
{
wrapper: RenderStateRoot,
},
);

// Act
const textInput = screen.getByRole("textbox", {
name: "TeX",
});
await userEvent.type(textInput, "x^2");

// Assert
// Assert
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Assert

expect(onChangeProps).toHaveBeenCalledTimes(3);
// NOTE: Since the 'onChangeProps' function is being mocked,
// the equation doesn't get updated,
// and therefore the keystrokes don't accumulate.
// This is reflected in the calls to 'onChangeProps' being
// just 1 character at a time.
expect(onChangeProps).toHaveBeenNthCalledWith(1, {text: "x"});
expect(onChangeProps).toHaveBeenNthCalledWith(2, {text: "^"});
expect(onChangeProps).toHaveBeenNthCalledWith(3, {text: "2"});
});

test("calls 'onChangeProps' when color is changed", async () => {
// Arrange
render(
<LockedLabelSettings
{...defaultProps}
onChangeProps={onChangeProps}
/>,
{
wrapper: RenderStateRoot,
},
);

// Act
// Change the color
const colorSwitch = screen.getByLabelText("color");
await userEvent.click(colorSwitch);
const colorOption = screen.getByText("green");
await userEvent.click(colorOption);

// Assert
expect(onChangeProps).toHaveBeenCalledWith({color: "green"});
});

test("calls 'onChangeProps' when size is changed", async () => {
// Arrange
render(
<LockedLabelSettings
{...defaultProps}
onChangeProps={onChangeProps}
/>,
{
wrapper: RenderStateRoot,
},
);

// Act
// Change the color
const sizeSelect = screen.getByLabelText("size");
await userEvent.click(sizeSelect);
const sizeOption = screen.getByText("small");
await userEvent.click(sizeOption);

// Assert
expect(onChangeProps).toHaveBeenCalledWith({size: "small"});
});
});
});
11 changes: 11 additions & 0 deletions packages/perseus-editor/src/components/__tests__/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ describe("getDefaultFigureForType", () => {
directionalAxis: "x",
});
});

test("should return a 'label' with default values", () => {
const figure = getDefaultFigureForType("label");
expect(figure).toEqual({
type: "label",
coord: [0, 0],
text: "",
color: "grayH",
size: "medium",
});
});
});

describe("degreeToRadian", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import * as React from "react";
type Props = {
// TODO(LEMS-2107): Remove this prop once the M2b flag is fully rolled out.
showM2bFeatures: boolean;
// Whether to show the locked labels in the locked figure settings.
// TODO(LEMS-2274): Remove this prop once the label flag is
// sfully rolled out.
showLabelsFlag?: boolean;
id: string;
onChange: (value: string) => void;
};
Expand All @@ -22,17 +26,20 @@ const LockedFigureSelect = (props: Props) => {
const {id, onChange} = props;

const figureTypes = ["point", "line", "vector", "ellipse", "polygon"];
const figureTypesCurrent = props.showM2bFeatures
? [...figureTypes, "function"]
: figureTypes;
if (props.showM2bFeatures) {
figureTypes.push("function");
}
if (props.showLabelsFlag) {
figureTypes.push("label");
}

return (
<View style={styles.container}>
<ActionMenu
menuText="Add locked figure"
style={styles.addElementSelect}
>
{figureTypesCurrent.map((figureType) => (
{figureTypes.map((figureType) => (
<ActionItem
key={`${id}-${figureType}`}
label={figureType}
Expand Down
Loading
Loading