Skip to content

Commit

Permalink
[Locked Figure Labels] Add/edit/delete locked ellipse labels
Browse files Browse the repository at this point in the history
Add the ability to add/edit/delete labels for locked ellipses
within the interactive graph editor.

Issue: https://khanacademy.atlassian.net/browse/LEMS-2349

Test plan:
- Go to http://localhost:6006/?path=/story/perseuseditor-widgets-interactive-graph--mafs-with-locked-ellipse-labels-flag
- Go to locked figures
- Open the locked ellipse settings
- Confirm that the label settings are there
- Play around with the label settings and confirm that it moves
  and updates with the ellipse
  • Loading branch information
nishasy committed Sep 19, 2024
1 parent a0a3b6b commit cd061af
Show file tree
Hide file tree
Showing 6 changed files with 294 additions and 11 deletions.
6 changes: 6 additions & 0 deletions .changeset/dry-parents-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": minor
"@khanacademy/perseus-editor": minor
---

[Locked Figure Labels] Add/edit/delete locked ellipse labels
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,29 @@ import {render, screen} from "@testing-library/react";
import {userEvent as userEventLib} from "@testing-library/user-event";
import * as React from "react";

import {flags} from "../../../__stories__/flags-for-api-options";

import LockedEllipseSettings from "./locked-ellipse-settings";
import {getDefaultFigureForType} from "./util";

import type {UserEvent} from "@testing-library/user-event";

const defaultProps = {
flags: {
...flags,
mafs: {
...flags.mafs,
"locked-ellipse-settings": true,
},
},
...getDefaultFigureForType("ellipse"),
onChangeProps: () => {},
onMove: () => {},
onRemove: () => {},
};

const defaultLabel = getDefaultFigureForType("label");

describe("LockedEllipseSettings", () => {
let userEvent: UserEvent;
beforeEach(() => {
Expand Down Expand Up @@ -138,4 +149,172 @@ describe("LockedEllipseSettings", () => {
// Assert
expect(onToggle).toHaveBeenCalled();
});

describe("Labels", () => {
test("Updates the label coords when the ellipse center change", async () => {
// Arrange
const onChangeProps = jest.fn();
render(
<LockedEllipseSettings
{...defaultProps}
center={[1, 1]}
labels={[
{
...defaultLabel,
coord: [1, 1],
},
]}
onChangeProps={onChangeProps}
/>,
{wrapper: RenderStateRoot},
);

// Act
const point1XInput = screen.getAllByLabelText("x coord")[0];
// Change the x coord of the second point to 20
await userEvent.type(point1XInput, "0");

// Assert
expect(onChangeProps).toHaveBeenCalledWith({
center: [10, 1],
labels: [
{
...defaultLabel,
coord: [10, 1],
},
],
});
});

test("Updates the label color when the ellipse color changes", async () => {
// Arrange
const onChangeProps = jest.fn();
render(
<LockedEllipseSettings
{...defaultProps}
color="green"
labels={[
{
...defaultLabel,
color: "green",
},
]}
onChangeProps={onChangeProps}
/>,
{wrapper: RenderStateRoot},
);

// Act
const colorSelect = screen.getByLabelText("color");
await userEvent.click(colorSelect);
const colorOption = screen.getByText("pink");
await userEvent.click(colorOption);

// Assert
expect(onChangeProps).toHaveBeenCalledWith({
color: "pink",
labels: [
{
...defaultLabel,
color: "pink",
},
],
});
});

test("Updates the label when the label text changes", async () => {
// Arrange
const onChangeProps = jest.fn();
render(
<LockedEllipseSettings
{...defaultProps}
labels={[
{
...defaultLabel,
text: "label text",
},
]}
onChangeProps={onChangeProps}
/>,
{wrapper: RenderStateRoot},
);

// Act
const labelText = screen.getByLabelText("TeX");
await userEvent.type(labelText, "!");

// Assert
expect(onChangeProps).toHaveBeenCalledWith({
labels: [{...defaultLabel, text: "label text!"}],
});
});

test("Removes label when delete button is clicked", async () => {
// Arrange
const onChangeProps = jest.fn();
render(
<LockedEllipseSettings
{...defaultProps}
labels={[
{
...defaultLabel,
text: "label text",
},
]}
onChangeProps={onChangeProps}
/>,
{wrapper: RenderStateRoot},
);

// Act
const deleteButton = screen.getByRole("button", {
name: "Delete locked label",
});
await userEvent.click(deleteButton);

// Assert
expect(onChangeProps).toHaveBeenCalledWith({
labels: [],
});
});

test("Adds a new label when the add label button is clicked", async () => {
// Arrange
const onChangeProps = jest.fn();
render(
<LockedEllipseSettings
{...defaultProps}
labels={[
{
...defaultLabel,
text: "label text",
},
]}
onChangeProps={onChangeProps}
/>,
{wrapper: RenderStateRoot},
);

// Act
const addLabelButton = screen.getByRole("button", {
name: "Add visible label",
});
await userEvent.click(addLabelButton);

// Assert
expect(onChangeProps).toHaveBeenCalledWith({
labels: [
{
...defaultLabel,
text: "label text",
},
{
...defaultLabel,
// One unit down vertically from the first label.
coord: [0, -1],
},
],
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {components, lockedFigureFillStyles} from "@khanacademy/perseus";
import Button from "@khanacademy/wonder-blocks-button";
import {View} from "@khanacademy/wonder-blocks-core";
import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown";
import {Strut} from "@khanacademy/wonder-blocks-layout";
import {spacing} from "@khanacademy/wonder-blocks-tokens";
import {spacing, color as wbColor} from "@khanacademy/wonder-blocks-tokens";
import {LabelMedium, LabelLarge} from "@khanacademy/wonder-blocks-typography";
import plusCircle from "@phosphor-icons/core/regular/plus-circle.svg";
import {StyleSheet} from "aphrodite";
import {vec} from "mafs";

Check failure on line 10 in packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x)

'vec' is defined but never used. Allowed unused vars must match /^_*$/u
import * as React from "react";

import AngleInput from "../../../components/angle-input";
Expand All @@ -15,13 +18,16 @@ import ColorSelect from "./color-select";
import EllipseSwatch from "./ellipse-swatch";
import LineStrokeSelect from "./line-stroke-select";
import LockedFigureSettingsActions from "./locked-figure-settings-actions";
import LockedLabelSettings from "./locked-label-settings";
import {getDefaultFigureForType} from "./util";

import type {LockedFigureSettingsCommonProps} from "./locked-figure-settings";
import type {
Coord,
LockedFigureFillType,
LockedEllipseType,
LockedFigureColor,
LockedLabelType,
} from "@khanacademy/perseus";

const {InfoTip} = components;
Expand All @@ -36,10 +42,12 @@ export type Props = LockedFigureSettingsCommonProps &

const LockedEllipseSettings = (props: Props) => {
const {
flags,
center,
radius,
angle,
color,
labels,
fillStyle,
strokeStyle,
expanded,
Expand All @@ -49,10 +57,55 @@ const LockedEllipseSettings = (props: Props) => {
onRemove,
} = props;

function handleCenterChange(newCoord: Coord) {
const xOffset = newCoord[0] - center[0];
const yOffset = newCoord[1] - center[1];

const newProps: Partial<LockedEllipseType> = {
center: newCoord,
};

// Update the coord by the same amount as the point for all labels
newProps.labels = labels.map((label) => ({
...label,
coord: [label.coord[0] + xOffset, label.coord[1] + yOffset],
}));

onChangeProps(newProps);
}

function handleColorChange(newValue: LockedFigureColor) {
onChangeProps({color: newValue});
const newProps: Partial<LockedEllipseType> = {
color: newValue,
};

// Update the color of the all labels to match the point
newProps.labels = labels.map((label) => ({
...label,
color: newValue,
}));

onChangeProps(newProps);
}

function handleLabelChange(
updatedLabel: LockedLabelType,
labelIndex: number,
) {
const updatedLabels = [...labels];
updatedLabels[labelIndex] = {
...labels[labelIndex],
...updatedLabel,
};

onChangeProps({labels: updatedLabels});
}

function handleLabelRemove(labelIndex: number) {
const updatedLabels = labels.filter((_, index) => index !== labelIndex);

onChangeProps({labels: updatedLabels});
}
return (
<PerseusEditorAccordion
expanded={expanded}
Expand All @@ -75,9 +128,7 @@ const LockedEllipseSettings = (props: Props) => {
<CoordinatePairInput
coord={center}
style={styles.spaceUnder}
onChange={(newCoords: Coord) =>
onChangeProps({center: newCoords})
}
onChange={handleCenterChange}
/>
<View style={styles.spaceUnder}>
<InfoTip>
Expand Down Expand Up @@ -147,6 +198,50 @@ const LockedEllipseSettings = (props: Props) => {
}
/>

{/* Visible Labels */}
{flags?.["mafs"]?.["locked-ellipse-labels"] && (
<>
{labels.map((label, labelIndex) => (
<LockedLabelSettings
{...label}
expanded={true}
onChangeProps={(newLabel: LockedLabelType) => {
handleLabelChange(newLabel, labelIndex);
}}
onRemove={() => {
handleLabelRemove(labelIndex);
}}
containerStyle={styles.labelContainer}
/>
))}

<Button
kind="tertiary"
startIcon={plusCircle}
onClick={() => {
const newLabel = {
...getDefaultFigureForType("label"),
coord: [
center[0],
// Additional vertical offset for each
// label so they don't overlap.
center[1] - labels.length,
],
// Default to the same color as the ellipse
color: color,
} satisfies LockedLabelType;

onChangeProps({
labels: [...labels, newLabel],
});
}}
style={styles.addButton}
>
Add visible label
</Button>
</>
)}

{/* Actions */}
<LockedFigureSettingsActions
figureType={props.type}
Expand All @@ -170,6 +265,12 @@ const styles = StyleSheet.create({
// Allow truncation, stop bleeding over the edge.
minWidth: 0,
},
addButton: {
alignSelf: "start",
},
labelContainer: {
backgroundColor: wbColor.white,
},
});

export default LockedEllipseSettings;
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ const LockedLineSettings = (props: Props) => {
const newLabel = {
...getDefaultFigureForType("label"),
coord: labelLocation,
// Default to the same color as the point
// Default to the same color as the line
color: lineColor,
} satisfies LockedLabelType;

Expand Down
Loading

0 comments on commit cd061af

Please sign in to comment.