Skip to content

Commit

Permalink
Create UI for adding a locked point to interactive graph editor (mafs…
Browse files Browse the repository at this point in the history
… only) (#1074)

## Summary:
UI for adding a locked point to interactive graph editor! This will only be for mafs graphs as mafs graphs have a slightly different look and API (and also it was just not working very well with graphie anyway).

This UI includes:
- A dropdown that allows adding elements (just "Point" for now) added to the interactive graph editor.
- Once added, settings for points
  - The ability to delete the setting in question
  - The x and y coordinates of the point
- The removal of the locked point investigation from graph.tsx and related files.

[The ability to change the color of a point](https://khanacademy.atlassian.net/browse/LEMS-1751) is coming in a future PR.

Issue: LEMS-1813

## Test plan:
`yarn jest packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx`
`yarn jest packages/perseus-editor/src/components/__tests__/util.test.ts`

Storybook
- Go to http://localhost:6006/?path=/story/perseus-editor-widgets-interactive-graph-editor--with-locked-points
- Scroll down to the "Add an element" dropdown
- Click "Add an element"
- Click "Point"
- Confirm that a black point appears at (0, 0) on the graph
- Change the coordinates and confirm that the point moves
- Add another point
- Use the trash button to delete a point
- Confirm that the correct point is deleted

## Demo:

https://github.com/Khan/perseus/assets/13231763/9eca725b-651a-4312-b639-bda26163ab8e

Author: nishasy

Reviewers: nishasy, nedredmond, jeremywiebe, benchristel, mark-fitzgerald

Required Reviewers:

Approved By: nedredmond

Checks: ✅ codecov/project, ✅ codecov/patch, ✅ gerald, ✅ Upload Coverage, ⏭️  Publish npm snapshot, ✅ Extract i18n strings (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Jest Coverage (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ gerald

Pull Request URL: #1074
  • Loading branch information
nishasy authored Mar 14, 2024
1 parent e3b4b95 commit a263e94
Show file tree
Hide file tree
Showing 18 changed files with 647 additions and 73 deletions.
6 changes: 6 additions & 0 deletions .changeset/short-eggs-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": minor
"@khanacademy/perseus-editor": minor
---

Add "add a locked figure" UI to interactive graph editor + adding points (mafs graphs only)
2 changes: 2 additions & 0 deletions packages/perseus-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@khanacademy/wonder-blocks-form": "^4.4.5",
"@khanacademy/wonder-blocks-i18n": "^2.0.2",
"@khanacademy/wonder-blocks-icon": "4.0.1",
"@khanacademy/wonder-blocks-icon-button": "^5.1.13",
"@khanacademy/wonder-blocks-spacing": "^4.0.1",
"@khanacademy/wonder-blocks-tokens": "^1.1.0",
"@khanacademy/wonder-blocks-typography": "^2.1.10",
Expand All @@ -65,6 +66,7 @@
"@khanacademy/wonder-blocks-form": "^4.4.5",
"@khanacademy/wonder-blocks-i18n": "^2.0.2",
"@khanacademy/wonder-blocks-icon": "4.0.1",
"@khanacademy/wonder-blocks-icon-button": "^5.1.13",
"@khanacademy/wonder-blocks-spacing": "^4.0.1",
"@khanacademy/wonder-blocks-tokens": "^1.1.0",
"@khanacademy/wonder-blocks-typography": "^2.1.10",
Expand Down
42 changes: 42 additions & 0 deletions packages/perseus-editor/src/components/__tests__/util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {getValidNumberFromString, getDefaultFigureForFigureType} from "../util";

describe("getValidNumberFromString", () => {
test("should return a number from a string", () => {
expect(getValidNumberFromString("123")).toBe(123);
});

test("should return a negative number from a string", () => {
expect(getValidNumberFromString("-123")).toBe(-123);
});

test("should return 0 from an invalid string", () => {
expect(getValidNumberFromString("abc")).toBe(0);
});

test("should return 0 from an empty string", () => {
expect(getValidNumberFromString("")).toBe(0);
});

test("should return 0 from a string with spaces", () => {
expect(getValidNumberFromString(" ")).toBe(0);
});

test("should return the number from a combo of numbers followed by letters", () => {
expect(getValidNumberFromString("123abc")).toBe(123);
});

test("should return 0 from a combo of letters followed by numbers", () => {
expect(getValidNumberFromString("abc123")).toBe(0);
});

test("should return the first number from a mixed combo of numbers and letters", () => {
expect(getValidNumberFromString("123abc123")).toBe(123);
});
});

describe("getDefaultFigureForFigureType", () => {
test("should return a point with default coordinates", () => {
const figure = getDefaultFigureForFigureType("point");
expect(figure).toEqual({type: "point", coord: [0, 0]});
});
});
8 changes: 6 additions & 2 deletions packages/perseus-editor/src/components/labeled-row.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {View} from "@khanacademy/wonder-blocks-core";
import {spacing} from "@khanacademy/wonder-blocks-tokens";
import {LabelSmall} from "@khanacademy/wonder-blocks-typography";
import {StyleSheet} from "aphrodite";
import {StyleSheet, css} from "aphrodite";
import * as React from "react";

import type {StyleType} from "@khanacademy/wonder-blocks-core";
Expand All @@ -17,7 +17,7 @@ const LabeledRow = (props: {
const {children, label, labelSide = "left", style} = props;

return (
<label>
<label className={css(styles.label)}>
<View style={[styles.row, style]}>
{labelSide === "start" || (
<LabelSmall style={styles.spaceEnd}>{label}</LabelSmall>
Expand All @@ -32,10 +32,14 @@ const LabeledRow = (props: {
};

const styles = StyleSheet.create({
label: {
width: "fit-content",
},
row: {
flexDirection: "row",
marginTop: spacing.xSmall_8,
alignItems: "center",
width: "fit-content",
},
spaceStart: {
marginInlineStart: spacing.xSmall_8,
Expand Down
49 changes: 49 additions & 0 deletions packages/perseus-editor/src/components/locked-figure-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Dropdown for selecting a locked figure to add to an interactive graph.
* Locked figures are elements (points, segmeents, etc.) that are not
* interactive, just present in the graph's background.
*
* Used in the interactive graph editor's locked figures section.
*/
import {View} from "@khanacademy/wonder-blocks-core";
import {ActionItem, ActionMenu} from "@khanacademy/wonder-blocks-dropdown";
import {spacing, color} from "@khanacademy/wonder-blocks-tokens";
import {StyleSheet} from "aphrodite";
import * as React from "react";

type Props = {
id: string;
onChange: (value: string) => void;
};

const LockedFigureSelect = (props: Props) => {
const {id, onChange} = props;

return (
<View style={styles.container}>
<ActionMenu menuText="Add element" style={styles.addElementSelect}>
{[
<ActionItem
key={`${id}-point`}
label="Point"
onClick={() => onChange("point")}
>
Point
</ActionItem>,
]}
</ActionMenu>
</View>
);
};

const styles = StyleSheet.create({
container: {
marginTop: spacing.medium_16,
},
addElementSelect: {
backgroundColor: color.fadedBlue16,
borderRadius: spacing.xxxSmall_4,
},
});

export default LockedFigureSelect;
26 changes: 26 additions & 0 deletions packages/perseus-editor/src/components/locked-figure-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* LockedFigureSettings takes in a figure and renders the
* respective settings for that figure type.
*
* Used in the interactive graph editor's locked figures section.
*/

import * as React from "react";

import LockedPointSettings from "./locked-point-settings";

import type {Props as LockedPointProps} from "./locked-point-settings";

// Union this type with other locked figure types when they are added.
type Props = LockedPointProps;

const LockedFigureSettings = (props: Props) => {
switch (props.type) {
case "point":
return <LockedPointSettings {...props} />;
}

return null;
};

export default LockedFigureSettings;
87 changes: 87 additions & 0 deletions packages/perseus-editor/src/components/locked-figures-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* LockedFiguresSection is a section of the InteractiveGraphEditor that allows
* the user to add and remove locked figures from the graph. It includes
* the dropdown for adding figures as well as the settings for each figure.
*/
import {View, useUniqueIdWithMock} from "@khanacademy/wonder-blocks-core";
import {spacing} from "@khanacademy/wonder-blocks-tokens";
import {StyleSheet} from "aphrodite";
import * as React from "react";

import LockedFigureSelect from "./locked-figure-select";
import LockedFigureSettings from "./locked-figure-settings";
import {getDefaultFigureForFigureType} from "./util";

import type {Props as InteractiveGraphEditorProps} from "../widgets/interactive-graph-editor";
import type {LockedFigure, LockedFigureType} from "@khanacademy/perseus";

type Props = {
figures?: Array<LockedFigure>;
onChange: (props: Partial<InteractiveGraphEditorProps>) => void;
};

const LockedFiguresSection = (props: Props) => {
const uniqueId = useUniqueIdWithMock().get("locked-figures-section");
const {figures, onChange} = props;

function addLockedFigure(newFigure: LockedFigureType) {
const lockedFigures = figures || [];
const newProps = {
lockedFigures: [
...lockedFigures,
getDefaultFigureForFigureType(newFigure),
],
};
onChange(newProps);
}

function removeLockedFigure(index: number) {
const lockedFigures = figures || [];
onChange({
lockedFigures: [
...lockedFigures.slice(0, index),
...lockedFigures.slice(index + 1),
],
});
}

function changeCoord(index: number, coord: [number, number]) {
const lockedFigures = figures || [];
const newProps = {
lockedFigures: [
...lockedFigures.slice(0, index),
{
...lockedFigures[index],
coord,
},
...lockedFigures.slice(index + 1),
],
};
onChange(newProps);
}

return (
<View style={styles.container}>
{figures?.map((figure, index) => (
<LockedFigureSettings
key={`${uniqueId}-locked-${figure}-${index}`}
{...figure}
onRemove={() => removeLockedFigure(index)}
onChangeCoord={(coord) => changeCoord(index, coord)}
/>
))}
<LockedFigureSelect
id={`${uniqueId}-select`}
onChange={addLockedFigure}
/>
</View>
);
};

const styles = StyleSheet.create({
container: {
paddingTop: spacing.large_24,
},
});

export default LockedFiguresSection;
131 changes: 131 additions & 0 deletions packages/perseus-editor/src/components/locked-point-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* LockedPointSettings is a component that allows the user to edit the
* settings of specifically a locked point on the graph.
*
* Used in the interactive graph editor's locked figures section.
*/
import {View, useUniqueIdWithMock} from "@khanacademy/wonder-blocks-core";
import {TextField} from "@khanacademy/wonder-blocks-form";
import IconButton from "@khanacademy/wonder-blocks-icon-button";
import {Spring} from "@khanacademy/wonder-blocks-layout";
import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
import {LabelMedium, LabelLarge} from "@khanacademy/wonder-blocks-typography";
import trashIcon from "@phosphor-icons/core/bold/trash-bold.svg";
import {StyleSheet} from "aphrodite";
import * as React from "react";

import {getValidNumberFromString} from "./util";

import type {LockedPoint} from "@khanacademy/perseus";

export type Props = LockedPoint & {
onRemove: () => void;
onChangeCoord: (coord: [number, number]) => void;
};

const LockedPointSettings = (props: Props) => {
const {coord, onRemove, onChangeCoord} = props;
const [coordState, setCoordState] = React.useState([
// Using strings to make it easier to work with the text fields.
coord[0].toString(),
coord[1].toString(),
]);

// Generate unique IDs so that the programmatic labels can be associated
// with their respective text fields.
const ids = useUniqueIdWithMock();
const xCoordId = ids.get("x-coord");
const yCoordId = ids.get("y-coord");

function handleBlur() {
const validCoord = [
getValidNumberFromString(coordState[0]),
getValidNumberFromString(coordState[1]),
] as [number, number];

// Make the text field only show valid numbers after blur.
setCoordState([validCoord[0].toString(), validCoord[1].toString()]);
// Update the graph with the new coordinates.
onChangeCoord(validCoord);
}

function handleChange(newValue, coordIndex) {
const newCoord = [...coordState];
newCoord[coordIndex] = newValue;
setCoordState(newCoord);
}

return (
<View style={styles.container}>
{/* Title row */}
<View style={styles.row}>
<LabelLarge>Point</LabelLarge>
<Spring />
<IconButton
icon={trashIcon}
aria-label={`Delete locked point at ${coordState[0]}, ${coordState[1]}`}
onClick={onRemove}
/>
</View>

{/* Coordinates */}
<View>
<View style={styles.row}>
<LabelMedium
htmlFor={xCoordId}
style={styles.label}
tag="label"
>
x Coordinate
</LabelMedium>
<TextField
id={xCoordId}
value={coordState[0]}
onChange={(newValue) => handleChange(newValue, 0)}
onBlur={handleBlur}
style={styles.textField}
/>
</View>

<View style={styles.row}>
<LabelMedium
htmlFor={yCoordId}
style={styles.label}
tag="label"
>
y Coordinate
</LabelMedium>
<TextField
id={yCoordId}
value={coordState[1]}
onChange={(newValue) => handleChange(newValue, 1)}
onBlur={handleBlur}
style={styles.textField}
/>
</View>
</View>
</View>
);
};

const styles = StyleSheet.create({
container: {
backgroundColor: color.fadedBlue8,
marginBottom: spacing.xSmall_8,
padding: spacing.medium_16,
borderRadius: spacing.xSmall_8,
},
row: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xSmall_8,
},
label: {
marginInlineEnd: spacing.xSmall_8,
},
textField: {
width: spacing.xxxLarge_64,
},
});

export default LockedPointSettings;
Loading

0 comments on commit a263e94

Please sign in to comment.