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

[Interactive Graph Editor] Add locked vector to storybook story for all locked figures #1350

Merged
merged 5 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 6 additions & 0 deletions .changeset/chatty-pears-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": patch
"@khanacademy/perseus-editor": patch
---

[Interactive Graph Editor] Add locked vector to storybook story for all locked figures
6 changes: 6 additions & 0 deletions .changeset/healthy-peas-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": minor
"@khanacademy/perseus-editor": minor
---

[Interactive Graph Editor] Update the locked ellipse settings so they only take degrees as input.
Original file line number Diff line number Diff line change
Expand Up @@ -15,91 +15,52 @@ describe("AngleInput", () => {
});
});

test("calls onChange with new angle (radians)", async () => {
test("displays angle in degrees", () => {
// Arrange
const onChangeProps = jest.fn();
render(<AngleInput angle={0} onChange={onChangeProps} />, {
render(<AngleInput angle={Math.PI / 4} onChange={() => {}} />, {
wrapper: RenderStateRoot,
});

// Act
const angleInput = screen.getByLabelText("angle");
await userEvent.type(angleInput, "2pi");

// Assert
expect(onChangeProps).toHaveBeenLastCalledWith(2 * Math.PI);
// Called with "2" and "2pi", not with "2p".
expect(onChangeProps).toHaveBeenCalledTimes(2);
});

test("calls onChange with new angle (degrees)", async () => {
// Arrange
const onChangeProps = jest.fn();
render(<AngleInput angle={0} onChange={onChangeProps} />, {
wrapper: RenderStateRoot,
const angleInput = screen.getByRole("spinbutton", {
name: "angle (degrees)",
});

// Act
const angleSwitch = screen.getByRole("switch");
await userEvent.click(angleSwitch);
const angleInput = screen.getByLabelText("angle");
await userEvent.type(angleInput, "180");

// Assert
expect(onChangeProps).toHaveBeenLastCalledWith(Math.PI);
// Called with the switch, then with "1", "18", and "180".
expect(onChangeProps).toHaveBeenCalledTimes(4);
expect(angleInput).toHaveValue(45);
});

test("does not call onChange with invalid expression", async () => {
test("calls onChange with new angle in radians", async () => {
// Arrange
const onChangeProps = jest.fn();
render(<AngleInput angle={0} onChange={onChangeProps} />, {
wrapper: RenderStateRoot,
});

// Act
const angleInput = screen.getByLabelText("angle");
await userEvent.type(angleInput, "2pi +");

// Assert
// Called with "2", "2pi", and "2pi ", but
// not with "2p" or "2pi +".
expect(onChangeProps).toHaveBeenCalledTimes(3);
expect(onChangeProps).toHaveBeenLastCalledWith(2 * Math.PI);
});

test("calls onChange in radians when switched to degrees", async () => {
// Arrange
const onChangeProps = jest.fn();
render(<AngleInput angle={0} onChange={onChangeProps} />, {
wrapper: RenderStateRoot,
const angleInput = screen.getByRole("spinbutton", {
name: "angle (degrees)",
});

// Act
const angleInput = screen.getByLabelText("angle");
await userEvent.type(angleInput, "180");
const angleSwitch = screen.getByRole("switch");
await userEvent.click(angleSwitch);
await userEvent.type(angleInput, "90");

// Assert
expect(onChangeProps).toHaveBeenLastCalledWith(Math.PI);
expect(onChangeProps).toHaveBeenLastCalledWith(Math.PI / 2);
});

test("calls onChange in radians when switched to from degrees to radians", async () => {
test("does not call onChange with invalid expression", async () => {
// Arrange
const onChangeProps = jest.fn();
render(<AngleInput angle={0} onChange={onChangeProps} />, {
wrapper: RenderStateRoot,
});

// Act
const angleInput = screen.getByLabelText("angle");
await userEvent.type(angleInput, "360");
const angleSwitch = screen.getByRole("switch");
await userEvent.click(angleSwitch);
const angleInput = screen.getByRole("spinbutton", {
name: "angle (degrees)",
});
await userEvent.type(angleInput, "-");

// Assert
expect(onChangeProps).toHaveBeenLastCalledWith(2 * Math.PI);
expect(onChangeProps).not.toHaveBeenCalled();
});
});
92 changes: 24 additions & 68 deletions packages/perseus-editor/src/components/angle-input.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import * as KAS from "@khanacademy/kas";
import {components} from "@khanacademy/perseus";
import {View} from "@khanacademy/wonder-blocks-core";
import {TextField} from "@khanacademy/wonder-blocks-form";
import {Strut} from "@khanacademy/wonder-blocks-layout";
import Switch from "@khanacademy/wonder-blocks-switch";
import {spacing} from "@khanacademy/wonder-blocks-tokens";
import {LabelMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography";
import {LabelMedium} from "@khanacademy/wonder-blocks-typography";
import {StyleSheet} from "aphrodite";
import * as React from "react";

const {InfoTip} = components;

const degreeToRadian = (degrees: number) => {
return (degrees / 180) * Math.PI;
};
const radianToDegree = (radians: number) => {
return (radians / Math.PI) * 180;
};

type Props = {
angle: number;
Expand All @@ -23,73 +20,36 @@ type Props = {
const AngleInput = (props: Props) => {
const {angle, onChange} = props;

const [angleInput, setAngleInput] = React.useState(angle.toString());
const [isInDegrees, setIsInDegrees] = React.useState(false);
const [angleInput, setAngleInput] = React.useState(
radianToDegree(angle).toString(),
);

function handleAngleChange(newValue, useDegrees = isInDegrees) {
function handleAngleChange(newValue) {
// Update the local state (update the input field value).
setAngleInput(newValue);

try {
// If the new value is a valid expression, update the props.
// Save the angle in radians.
const evaluatedAngle = KAS.parse(newValue).expr.eval();

if (useDegrees) {
onChange(degreeToRadian(evaluatedAngle));
} else {
onChange(evaluatedAngle);
}
} catch (e) {
// The user likely has not finished typing the expression.
// Do nothing.
// If the new value is not a number, don't update the props.
// If it's empty, keep the props the same value instead of setting to 0.
if (isNaN(+newValue) || newValue === "") {
return;
}
}

function handleAngleTypeChange() {
// Change the angle based on the new angle type.
handleAngleChange(angleInput, !isInDegrees);

// Update the angle to the new type.
setIsInDegrees((usingDegrees) => !usingDegrees);
// Update the graph.
onChange(degreeToRadian(newValue));
}

return (
<View style={[styles.row, styles.spaceUnder]}>
{/* Label */}
<LabelMedium tag="label" style={styles.row}>
angle
<Strut size={spacing.xxSmall_6} />
<TextField
value={angleInput}
onChange={handleAngleChange}
style={styles.textField}
/>
</LabelMedium>

{/* Spacing */}
<LabelMedium tag="label" style={styles.row}>
angle (degrees)
<Strut size={spacing.xxSmall_6} />

{/* Radian/Degree Toggle */}
<LabelSmall>radians</LabelSmall>
<View style={styles.switch}>
<Switch
onChange={handleAngleTypeChange}
checked={isInDegrees}
/>
</View>
<LabelSmall>degrees</LabelSmall>

{/* Info Tooltip */}
<InfoTip>
<p>
The angle of rotation for the ellipse (if the x radius and y
radius are different).
</p>
<p>Expressions will be evaluted (e.g. "pi/2" or "5pi/4").</p>
</InfoTip>
</View>
<TextField
type="number"
value={angleInput}
onChange={handleAngleChange}
style={styles.textField}
/>
<Strut size={spacing.xxSmall_6} />
</LabelMedium>
);
};

Expand All @@ -99,12 +59,8 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
},
switch: {
marginLeft: spacing.xxSmall_6,
marginRight: spacing.xxSmall_6,
},
textField: {
maxWidth: spacing.xxxLarge_64,
width: spacing.xxxLarge_64,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ const LockedLineSettings = (props: Props) => {

const styles = StyleSheet.create({
row: {
display: "flex",
flexDirection: "row",
alignItems: "center",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -672,41 +672,11 @@ describe("InteractiveGraphEditor locked figures", () => {
});

// Act
const angleInput = screen.getByLabelText("angle");
await userEvent.clear(angleInput);
await userEvent.type(angleInput, "1");
await userEvent.tab();

// Assert
expect(onChangeMock).toBeCalledWith(
expect.objectContaining({
lockedFigures: [
expect.objectContaining({
type: "ellipse",
angle: 1,
}),
],
}),
);
});

test("Calls onChange when a locked ellipse's angle is changed (degrees)", async () => {
// Arrange
const onChangeMock = jest.fn();

renderEditor({
onChange: onChangeMock,
lockedFigures: [getDefaultFigureForType("ellipse")],
const angleInput = screen.getByRole("spinbutton", {
name: "angle (degrees)",
});

// Act
// Switch to degrees first
const angleUnitsSwitch = screen.getByRole("switch");
await userEvent.click(angleUnitsSwitch);

const angleInput = screen.getByLabelText("angle");
await userEvent.clear(angleInput);
await userEvent.type(angleInput, "90");
await userEvent.type(angleInput, "30");
await userEvent.tab();

// Assert
Expand All @@ -715,7 +685,7 @@ describe("InteractiveGraphEditor locked figures", () => {
lockedFigures: [
expect.objectContaining({
type: "ellipse",
angle: Math.PI / 2,
angle: Math.PI / 6,
}),
],
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2089,13 +2089,6 @@ export const segmentWithAllLockedRayVariations: PerseusRenderer = {
},
};

export const segmentWithLockedFigures: PerseusRenderer =
interactiveGraphQuestionBuilder()
.addLockedPointAt(-7, -7)
.addLockedLine([-7, -5], [2, -3])
.addLockedEllipse([0, 5], [2, 2])
.build();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

moved this because I wanted it after all the other locked figure testdata blocks

export const segmentWithLockedEllipses: PerseusRenderer =
interactiveGraphQuestionBuilder()
.addLockedEllipse([0, 0], [5, 5])
Expand All @@ -2119,6 +2112,14 @@ export const segmentWithLockedVectors: PerseusRenderer =
.addLockedVector([2, 2], [-2, 4], "green")
.build();

export const segmentWithLockedFigures: PerseusRenderer =
interactiveGraphQuestionBuilder()
.addLockedPointAt(-7, -7)
.addLockedLine([-7, -5], [2, -3])
.addLockedEllipse([0, 5], [4, 2], {angle: Math.PI / 4})
.addLockedVector([0, 0], [8, 2], "purple")
.build();

export const quadraticQuestion: PerseusRenderer = {
content: "All locked lines\n\n[[☃ interactive-graph 1]]",
images: {},
Expand Down