diff --git a/.changeset/neat-carrots-guess.md b/.changeset/neat-carrots-guess.md new file mode 100644 index 0000000000..375bbbed7b --- /dev/null +++ b/.changeset/neat-carrots-guess.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Interactive Graph Editor] Add the ability to reorder locked figure settings diff --git a/packages/perseus-editor/src/components/__stories__/locked-ellipse-settings.stories.tsx b/packages/perseus-editor/src/components/__stories__/locked-ellipse-settings.stories.tsx index 7945f9313e..ade3409a31 100644 --- a/packages/perseus-editor/src/components/__stories__/locked-ellipse-settings.stories.tsx +++ b/packages/perseus-editor/src/components/__stories__/locked-ellipse-settings.stories.tsx @@ -14,21 +14,21 @@ export const Default = (args): React.ReactElement => { return ; }; -type StoryComponentType = StoryObj; - -// Set the default values in the control panel. -Default.args = { +const defaultProps = { ...getDefaultFigureForType("ellipse"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, }; +type StoryComponentType = StoryObj; + +// Set the default values in the control panel. +Default.args = defaultProps; + export const Controlled: StoryComponentType = { render: function Render() { - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("ellipse"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ @@ -57,10 +57,7 @@ Controlled.parameters = { export const Expanded: StoryComponentType = { render: function Render() { const [expanded, setExpanded] = React.useState(true); - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("ellipse"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ diff --git a/packages/perseus-editor/src/components/__stories__/locked-line-settings.stories.tsx b/packages/perseus-editor/src/components/__stories__/locked-line-settings.stories.tsx index 8c053f8158..50c762d67a 100644 --- a/packages/perseus-editor/src/components/__stories__/locked-line-settings.stories.tsx +++ b/packages/perseus-editor/src/components/__stories__/locked-line-settings.stories.tsx @@ -14,21 +14,21 @@ export const Default = (args): React.ReactElement => { return ; }; -type StoryComponentType = StoryObj; - -// Set the default values in the control panel. -Default.args = { +const defaultProps = { ...getDefaultFigureForType("line"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, }; +type StoryComponentType = StoryObj; + +// Set the default values in the control panel. +Default.args = defaultProps; + export const Controlled: StoryComponentType = { render: function Render() { - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("line"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ @@ -57,10 +57,7 @@ Controlled.parameters = { */ export const WithInvalidPoints: StoryComponentType = { render: function Render() { - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("line"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ @@ -87,10 +84,7 @@ export const WithInvalidPoints: StoryComponentType = { export const Expanded: StoryComponentType = { render: function Render() { const [expanded, setExpanded] = React.useState(true); - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("line"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ @@ -115,8 +109,7 @@ export const ExpandedNondefaultProps: StoryComponentType = { render: function Render() { const [expanded, setExpanded] = React.useState(true); const [props, setProps] = React.useState({ - ...getDefaultFigureForType("line"), - onRemove: () => {}, + ...defaultProps, kind: "segment" as const, color: "green" as const, lineStyle: "dashed" as const, diff --git a/packages/perseus-editor/src/components/__stories__/locked-point-settings.stories.tsx b/packages/perseus-editor/src/components/__stories__/locked-point-settings.stories.tsx index a500d36bb6..1b45eeff3f 100644 --- a/packages/perseus-editor/src/components/__stories__/locked-point-settings.stories.tsx +++ b/packages/perseus-editor/src/components/__stories__/locked-point-settings.stories.tsx @@ -14,21 +14,21 @@ export const Default = (args): React.ReactElement => { return ; }; -type StoryComponentType = StoryObj; - -// Set the default values in the control panel. -Default.args = { +const defaultProps = { ...getDefaultFigureForType("point"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, }; +type StoryComponentType = StoryObj; + +// Set the default values in the control panel. +Default.args = defaultProps; + export const Controlled: StoryComponentType = { render: function Render() { - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("point"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ @@ -54,10 +54,7 @@ Controlled.parameters = { export const Expanded: StoryComponentType = { render: function Render() { const [expanded, setExpanded] = React.useState(true); - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("point"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ @@ -81,12 +78,7 @@ export const Expanded: StoryComponentType = { export const ExpandedNondefaultProps: StoryComponentType = { render: function Render() { const [expanded, setExpanded] = React.useState(true); - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("point"), - onRemove: () => {}, - color: "green" as const, - filled: false, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ diff --git a/packages/perseus-editor/src/components/__stories__/locked-polygon-settings.stories.tsx b/packages/perseus-editor/src/components/__stories__/locked-polygon-settings.stories.tsx index 45a6116950..b0836aae80 100644 --- a/packages/perseus-editor/src/components/__stories__/locked-polygon-settings.stories.tsx +++ b/packages/perseus-editor/src/components/__stories__/locked-polygon-settings.stories.tsx @@ -14,21 +14,21 @@ export const Default = (args): React.ReactElement => { return ; }; -type StoryComponentType = StoryObj; - -// Set the default values in the control panel. -Default.args = { +const defaultProps = { ...getDefaultFigureForType("polygon"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, }; +type StoryComponentType = StoryObj; + +// Set the default values in the control panel. +Default.args = defaultProps; + export const Controlled: StoryComponentType = { render: function Render() { - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("polygon"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ @@ -57,10 +57,7 @@ Controlled.parameters = { export const Expanded: StoryComponentType = { render: function Render() { const [expanded, setExpanded] = React.useState(true); - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("polygon"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ diff --git a/packages/perseus-editor/src/components/__stories__/locked-vector-settings.stories.tsx b/packages/perseus-editor/src/components/__stories__/locked-vector-settings.stories.tsx index d2f765b2a2..4886bf5311 100644 --- a/packages/perseus-editor/src/components/__stories__/locked-vector-settings.stories.tsx +++ b/packages/perseus-editor/src/components/__stories__/locked-vector-settings.stories.tsx @@ -14,21 +14,21 @@ export const Default = (args): React.ReactElement => { return ; }; -type StoryComponentType = StoryObj; - -// Set the default values in the control panel. -Default.args = { +const defaultProps = { ...getDefaultFigureForType("vector"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, }; +type StoryComponentType = StoryObj; + +// Set the default values in the control panel. +Default.args = defaultProps; + export const Expanded: StoryComponentType = { render: function Render() { - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("vector"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ @@ -54,10 +54,7 @@ export const Expanded: StoryComponentType = { */ export const WithInvalidPoints: StoryComponentType = { render: function Render() { - const [props, setProps] = React.useState({ - ...getDefaultFigureForType("vector"), - onRemove: () => {}, - }); + const [props, setProps] = React.useState(defaultProps); const handlePropsUpdate = (newProps) => { setProps({ diff --git a/packages/perseus-editor/src/components/__tests__/locked-ellipse-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/locked-ellipse-settings.test.tsx index 04e9918758..566307c427 100644 --- a/packages/perseus-editor/src/components/__tests__/locked-ellipse-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/locked-ellipse-settings.test.tsx @@ -11,6 +11,7 @@ import type {UserEvent} from "@testing-library/user-event"; const defaultProps = { ...getDefaultFigureForType("ellipse"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, }; diff --git a/packages/perseus-editor/src/components/__tests__/locked-line-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/locked-line-settings.test.tsx index 26e6f09d0c..32f4986e69 100644 --- a/packages/perseus-editor/src/components/__tests__/locked-line-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/locked-line-settings.test.tsx @@ -11,6 +11,7 @@ import type {UserEvent} from "@testing-library/user-event"; const defaultProps = { ...getDefaultFigureForType("line"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, }; diff --git a/packages/perseus-editor/src/components/__tests__/locked-point-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/locked-point-settings.test.tsx index 7172038840..5ad1cf2a0e 100644 --- a/packages/perseus-editor/src/components/__tests__/locked-point-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/locked-point-settings.test.tsx @@ -11,6 +11,7 @@ import type {UserEvent} from "@testing-library/user-event"; const defaultProps = { ...getDefaultFigureForType("point"), onRemove: () => {}, + onMove: () => {}, onChangeProps: () => {}, }; diff --git a/packages/perseus-editor/src/components/__tests__/locked-polygon-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/locked-polygon-settings.test.tsx index a062b483c5..66c3c474e6 100644 --- a/packages/perseus-editor/src/components/__tests__/locked-polygon-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/locked-polygon-settings.test.tsx @@ -11,6 +11,7 @@ import type {UserEvent} from "@testing-library/user-event"; const defaultProps = { ...getDefaultFigureForType("polygon"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, }; diff --git a/packages/perseus-editor/src/components/__tests__/locked-vector-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/locked-vector-settings.test.tsx index a9ff8caf3d..e1005b6968 100644 --- a/packages/perseus-editor/src/components/__tests__/locked-vector-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/locked-vector-settings.test.tsx @@ -12,6 +12,7 @@ import type {UserEvent} from "@testing-library/user-event"; const defaultProps = { ...getDefaultFigureForType("vector"), onChangeProps: () => {}, + onMove: () => {}, onRemove: () => {}, } as Props; diff --git a/packages/perseus-editor/src/components/defining-point-settings.tsx b/packages/perseus-editor/src/components/defining-point-settings.tsx index 17debd6f9a..dba6308f73 100644 --- a/packages/perseus-editor/src/components/defining-point-settings.tsx +++ b/packages/perseus-editor/src/components/defining-point-settings.tsx @@ -17,33 +17,41 @@ import CoordinatePairInput from "./coordinate-pair-input"; import LabeledSwitch from "./labeled-switch"; import LockedFigureSettingsAccordion from "./locked-figure-settings-accordion"; -import type {AccordionProps} from "./locked-figure-settings"; import type {LockedPointType} from "@khanacademy/perseus"; -export type Props = AccordionProps & - LockedPointType & { - /** - * Optional label for the point to display in the header summary. - * Defaults to "Point". - */ - label: string; - /** - * Whether the extra point settings are toggled open. - */ - showPoint?: boolean; - /** - * Optional error message to display. - */ - error?: string | null; - /** - * Called when the extra settings toggle switch is changed. - */ - onTogglePoint?: (newValue) => void; - /** - * Called when the props (coords, color, etc.) are updated. - */ - onChangeProps: (newProps: Partial) => void; - }; +export type Props = LockedPointType & { + /** + * Optional label for the point to display in the header summary. + * Defaults to "Point". + */ + label: string; + /** + * Whether the extra point settings are toggled open. + */ + showPoint?: boolean; + /** + * Optional error message to display. + */ + error?: string | null; + /** + * Called when the extra settings toggle switch is changed. + */ + onTogglePoint?: (newValue) => void; + /** + * Called when the props (coords, color, etc.) are updated. + */ + onChangeProps: (newProps: Partial) => void; + + // Accordion props + /** + * Whether this accordion is expanded. + */ + expanded?: boolean; + /** + * Called when the accordion is expanded or collapsed. + */ + onToggle?: (expanded: boolean) => void; +}; const DefiningPointSettings = (props: Props) => { const { @@ -55,6 +63,8 @@ const DefiningPointSettings = (props: Props) => { error, onChangeProps, onTogglePoint, + expanded, + onToggle, } = props; function handleColorChange(newValue) { @@ -63,8 +73,8 @@ const DefiningPointSettings = (props: Props) => { return ( void; /** * Called when the props (coords, color, etc.) are updated. */ @@ -47,6 +43,7 @@ const LockedEllipseSettings = (props: Props) => { expanded, onToggle, onChangeProps, + onMove, onRemove, } = props; @@ -162,8 +159,9 @@ const LockedEllipseSettings = (props: Props) => { {/* Actions */} ); diff --git a/packages/perseus-editor/src/components/locked-figure-settings-actions.tsx b/packages/perseus-editor/src/components/locked-figure-settings-actions.tsx index b8def139b6..dcdaa2e3e9 100644 --- a/packages/perseus-editor/src/components/locked-figure-settings-actions.tsx +++ b/packages/perseus-editor/src/components/locked-figure-settings-actions.tsx @@ -5,29 +5,76 @@ */ import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; +import IconButton from "@khanacademy/wonder-blocks-icon-button"; +import {Spring} from "@khanacademy/wonder-blocks-layout"; import {spacing} from "@khanacademy/wonder-blocks-tokens"; +import caretDoubleDownIcon from "@phosphor-icons/core/bold/caret-double-down-bold.svg"; +import caretDoubleUpIcon from "@phosphor-icons/core/bold/caret-double-up-bold.svg"; +import caretDownIcon from "@phosphor-icons/core/bold/caret-down-bold.svg"; +import caretUpIcon from "@phosphor-icons/core/bold/caret-up-bold.svg"; import trashIcon from "@phosphor-icons/core/bold/trash-bold.svg"; import {StyleSheet} from "aphrodite"; import * as React from "react"; +import type {LockedFigureType} from "@khanacademy/perseus"; + +export type LockedFigureSettingsMovementType = + | "back" + | "backward" + | "forward" + | "front"; + type Props = { + figureType: LockedFigureType; + onMove: (movement: LockedFigureSettingsMovementType) => void; onRemove: () => void; - figureAriaLabel: string; }; const LockedFigureSettingsActions = (props: Props) => { - const {onRemove, figureAriaLabel} = props; + const {figureType, onMove, onRemove} = props; + return ( + + + + onMove("back")} + style={styles.iconButton} + /> + onMove("backward")} + style={styles.iconButton} + /> + onMove("forward")} + style={styles.iconButton} + /> + onMove("front")} + style={styles.iconButton} + /> ); }; @@ -43,6 +90,11 @@ const styles = StyleSheet.create({ // Line up the delete icon with the rest of the content. marginInlineStart: -spacing.xxxSmall_4, }, + iconButton: { + // Reset the margin because the icon buttons + // overlap each other otherwise. + margin: 0, + }, }); export default LockedFigureSettingsActions; diff --git a/packages/perseus-editor/src/components/locked-figure-settings.tsx b/packages/perseus-editor/src/components/locked-figure-settings.tsx index ed3987e07b..9ca354b459 100644 --- a/packages/perseus-editor/src/components/locked-figure-settings.tsx +++ b/packages/perseus-editor/src/components/locked-figure-settings.tsx @@ -14,15 +14,28 @@ import LockedPolygonSettings from "./locked-polygon-settings"; import LockedVectorSettings from "./locked-vector-settings"; import type {Props as LockedEllipseProps} from "./locked-ellipse-settings"; +import type {LockedFigureSettingsMovementType} from "./locked-figure-settings-actions"; import type {Props as LockedLineProps} from "./locked-line-settings"; import type {Props as LockedPointProps} from "./locked-point-settings"; import type {Props as LockedPolygonProps} from "./locked-polygon-settings"; import type {Props as LockedVectorProps} from "./locked-vector-settings"; -export type AccordionProps = { +export type LockedFigureSettingsCommonProps = { // Whether to show the M2 features in the locked figure settings. // TODO(LEMS-2016): Remove this prop once the M2 flag is fully rolled out. showM2Features?: boolean; + + // Movement props + /** + * Called when a movement button (top, up, down, bottom) is pressed. + */ + onMove: (movement: LockedFigureSettingsMovementType) => void; + /** + * Called when the delete button is pressed. + */ + onRemove: () => void; + + // Accordion props /** * Whether this accordion is expanded. */ @@ -34,7 +47,7 @@ export type AccordionProps = { }; // Union this type with other locked figure types when they are added. -type Props = AccordionProps & +type Props = LockedFigureSettingsCommonProps & ( | LockedPointProps | LockedLineProps diff --git a/packages/perseus-editor/src/components/locked-figures-section.tsx b/packages/perseus-editor/src/components/locked-figures-section.tsx index 748d266a28..7770cda492 100644 --- a/packages/perseus-editor/src/components/locked-figures-section.tsx +++ b/packages/perseus-editor/src/components/locked-figures-section.tsx @@ -14,6 +14,7 @@ import LockedFigureSelect from "./locked-figure-select"; import LockedFigureSettings from "./locked-figure-settings"; import {getDefaultFigureForType} from "./util"; +import type {LockedFigureSettingsMovementType} from "./locked-figure-settings-actions"; import type {Props as InteractiveGraphEditorProps} from "../widgets/interactive-graph-editor"; import type {LockedFigure, LockedFigureType} from "@khanacademy/perseus"; @@ -47,6 +48,56 @@ const LockedFiguresSection = (props: Props) => { setExpandedStates([...expandedStates, true]); } + function moveLockedFigure( + index: number, + movement: LockedFigureSettingsMovementType, + ) { + // Don't allow moving the first figure up or the last figure down. + if (index === 0 && (movement === "back" || movement === "backward")) { + return; + } + if ( + figures && + index === figures.length - 1 && + (movement === "front" || movement === "forward") + ) { + return; + } + + const lockedFigures = figures || []; + const newFigures = [...lockedFigures]; + const newExpandedStates = [...expandedStates]; + + // First, remove the figure from its current position + // in the figures array and the expanded states array. + const [removedFigure] = newFigures.splice(index, 1); + newExpandedStates.splice(index, 1); + + // Then, add it back in the new position. Add "true" to the + // expanded states array for the new position (it must already + // be open since the button to move it is being pressed from there). + switch (movement) { + case "back": + newFigures.unshift(removedFigure); + newExpandedStates.unshift(true); + break; + case "backward": + newFigures.splice(index - 1, 0, removedFigure); + newExpandedStates.splice(index - 1, 0, true); + break; + case "forward": + newFigures.splice(index + 1, 0, removedFigure); + newExpandedStates.splice(index + 1, 0, true); + break; + case "front": + newFigures.push(removedFigure); + newExpandedStates.push(true); + break; + } + onChange({lockedFigures: newFigures}); + setExpandedStates(newExpandedStates); + } + function removeLockedFigure(index: number) { // eslint-disable-next-line no-alert if (window.confirm("Are you sure you want to delete this figure?")) { @@ -110,6 +161,7 @@ const LockedFiguresSection = (props: Props) => { onChangeProps={(newProps) => changeProps(index, newProps) } + onMove={(movement) => moveLockedFigure(index, movement)} onRemove={() => removeLockedFigure(index)} /> ); diff --git a/packages/perseus-editor/src/components/locked-line-settings.tsx b/packages/perseus-editor/src/components/locked-line-settings.tsx index 94ed532adc..08aeb77c71 100644 --- a/packages/perseus-editor/src/components/locked-line-settings.tsx +++ b/packages/perseus-editor/src/components/locked-line-settings.tsx @@ -19,7 +19,7 @@ import LineSwatch from "./line-swatch"; import LockedFigureSettingsAccordion from "./locked-figure-settings-accordion"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; -import type {AccordionProps} from "./locked-figure-settings"; +import type {LockedFigureSettingsCommonProps} from "./locked-figure-settings"; import type { LockedFigure, LockedFigureColor, @@ -30,11 +30,7 @@ import type { const lengthZeroStr = "The line cannot have length 0."; export type Props = LockedLineType & - AccordionProps & { - /** - * Called when the delete button is pressed. - */ - onRemove: () => void; + LockedFigureSettingsCommonProps & { /** * Called when the props (points, color, etc.) are updated. */ @@ -50,6 +46,7 @@ const LockedLineSettings = (props: Props) => { showPoint1, showPoint2, onChangeProps, + onMove, onRemove, } = props; const [point1, point2] = points; @@ -180,10 +177,9 @@ const LockedLineSettings = (props: Props) => { {/* Actions */} ); diff --git a/packages/perseus-editor/src/components/locked-point-settings.tsx b/packages/perseus-editor/src/components/locked-point-settings.tsx index 0ff6de3dd7..5cfa2f868d 100644 --- a/packages/perseus-editor/src/components/locked-point-settings.tsx +++ b/packages/perseus-editor/src/components/locked-point-settings.tsx @@ -18,15 +18,11 @@ import LabeledSwitch from "./labeled-switch"; import LockedFigureSettingsAccordion from "./locked-figure-settings-accordion"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; -import type {AccordionProps} from "./locked-figure-settings"; +import type {LockedFigureSettingsCommonProps} from "./locked-figure-settings"; import type {LockedPointType} from "@khanacademy/perseus"; -export type Props = AccordionProps & +export type Props = LockedFigureSettingsCommonProps & LockedPointType & { - /** - * Called when the delete button is pressed. - */ - onRemove: () => void; /** * Called when the props (coords, color, etc.) are updated. */ @@ -39,6 +35,7 @@ const LockedPointSettings = (props: Props) => { color: pointColor, filled = true, onChangeProps, + onMove, onRemove, } = props; @@ -82,8 +79,9 @@ const LockedPointSettings = (props: Props) => { /> ); diff --git a/packages/perseus-editor/src/components/locked-polygon-settings.tsx b/packages/perseus-editor/src/components/locked-polygon-settings.tsx index 8bfbdd5dc0..9363448067 100644 --- a/packages/perseus-editor/src/components/locked-polygon-settings.tsx +++ b/packages/perseus-editor/src/components/locked-polygon-settings.tsx @@ -24,14 +24,10 @@ import LockedFigureSettingsAccordion from "./locked-figure-settings-accordion"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; import PolygonSwatch from "./polygon-swatch"; -import type {AccordionProps} from "./locked-figure-settings"; +import type {LockedFigureSettingsCommonProps} from "./locked-figure-settings"; -export type Props = AccordionProps & +export type Props = LockedFigureSettingsCommonProps & LockedPolygonType & { - /** - * Called when the delete button is pressed. - */ - onRemove: () => void; /** * Called when the props (coords, color, etc.) are updated. */ @@ -48,6 +44,8 @@ const LockedPolygonSettings = (props: Props) => { expanded, onToggle, onChangeProps, + onMove, + onRemove, } = props; function handleColorChange(newValue: LockedFigureColor) { @@ -199,8 +197,9 @@ const LockedPolygonSettings = (props: Props) => { {/* Actions */} ); diff --git a/packages/perseus-editor/src/components/locked-vector-settings.tsx b/packages/perseus-editor/src/components/locked-vector-settings.tsx index d3a2ea9ac2..ba4253622a 100644 --- a/packages/perseus-editor/src/components/locked-vector-settings.tsx +++ b/packages/perseus-editor/src/components/locked-vector-settings.tsx @@ -18,7 +18,7 @@ import LineSwatch from "./line-swatch"; import LockedFigureSettingsAccordion from "./locked-figure-settings-accordion"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; -import type {AccordionProps} from "./locked-figure-settings"; +import type {LockedFigureSettingsCommonProps} from "./locked-figure-settings"; import type { Coord, LockedFigure, @@ -29,11 +29,7 @@ import type { const lengthErrorMessage = "The vector cannot have length 0."; export type Props = LockedVectorType & - AccordionProps & { - /** - * Called when the delete button is pressed. - */ - onRemove: () => void; + LockedFigureSettingsCommonProps & { /** * Called when the props (points, color, etc.) are updated. */ @@ -41,7 +37,7 @@ export type Props = LockedVectorType & }; const LockedVectorSettings = (props: Props) => { - const {points, color: lineColor, onChangeProps, onRemove} = props; + const {points, color: lineColor, onChangeProps, onMove, onRemove} = props; const [tail, tip] = points; const lineLabel = `Vector (${tail[0]}, ${tail[1]}), (${tip[0]}, ${tip[1]})`; @@ -132,10 +128,9 @@ const LockedVectorSettings = (props: Props) => { {/* Actions */} ); diff --git a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor-locked-figures.test.tsx b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor-locked-figures.test.tsx index 26cc84aa8b..e5cd7caa54 100644 --- a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor-locked-figures.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor-locked-figures.test.tsx @@ -97,7 +97,7 @@ describe("InteractiveGraphEditor locked figures", () => { // Act const deleteButton = screen.getByRole("button", { - name: /Delete/, + name: `Delete locked ${figureType}`, }); await userEvent.click(deleteButton); @@ -125,7 +125,7 @@ describe("InteractiveGraphEditor locked figures", () => { // Act const deleteButton = screen.getByRole("button", { - name: /Delete/, + name: `Delete locked ${figureType}`, }); await userEvent.click(deleteButton); @@ -134,6 +134,189 @@ describe("InteractiveGraphEditor locked figures", () => { expect(onChangeMock).not.toBeCalled(); }); + describe("movement", () => { + const first = { + ...getDefaultFigureForType(figureType), + color: "blue", + }; + const second = { + ...getDefaultFigureForType(figureType), + color: "green", + }; + const third = { + ...getDefaultFigureForType(figureType), + color: "red", + }; + + test(`Calls onChange when a locked ${figureType} is moved back`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + renderEditor({ + onChange: onChangeMock, + lockedFigures: [first, second, third], + }); + + // Act + const moveButton = screen.getAllByRole("button", { + name: `Move locked ${figureType} to the back`, + }); + await userEvent.click(moveButton[2]); + + // Assert + expect(onChangeMock).toBeCalledWith( + expect.objectContaining({ + lockedFigures: [third, first, second], + }), + ); + }); + + test(`Calls onChange when a locked ${figureType} is moved backward`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + renderEditor({ + onChange: onChangeMock, + lockedFigures: [first, second, third], + }); + + // Act + const moveButton = screen.getAllByRole("button", { + name: `Move locked ${figureType} backward`, + }); + await userEvent.click(moveButton[2]); + + // Assert + expect(onChangeMock).toBeCalledWith( + expect.objectContaining({ + lockedFigures: [first, third, second], + }), + ); + }); + + test(`Calls onChange when a locked ${figureType} is moved forward`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + renderEditor({ + onChange: onChangeMock, + lockedFigures: [first, second, third], + }); + + // Act + const moveButton = screen.getAllByRole("button", { + name: `Move locked ${figureType} forward`, + }); + await userEvent.click(moveButton[0]); + + // Assert + expect(onChangeMock).toBeCalledWith( + expect.objectContaining({ + lockedFigures: [second, first, third], + }), + ); + }); + + test(`Calls onChange when a locked ${figureType} is moved front`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + renderEditor({ + onChange: onChangeMock, + lockedFigures: [first, second, third], + }); + + // Act + const moveButton = screen.getAllByRole("button", { + name: `Move locked ${figureType} to the front`, + }); + await userEvent.click(moveButton[0]); + + // Assert + expect(onChangeMock).toBeCalledWith( + expect.objectContaining({ + lockedFigures: [second, third, first], + }), + ); + }); + + test(`Does not call onChange when a locked ${figureType} is moved to the back and is already in the back`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + renderEditor({ + onChange: onChangeMock, + lockedFigures: [first, second, third], + }); + + // Act + const moveButton = screen.getAllByRole("button", { + name: `Move locked ${figureType} to the back`, + }); + await userEvent.click(moveButton[0]); + + // Assert + expect(onChangeMock).not.toBeCalled(); + }); + + test(`Does not call onChange when a locked ${figureType} is moved backward and is already in the back`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + renderEditor({ + onChange: onChangeMock, + lockedFigures: [first, second, third], + }); + + // Act + const moveButton = screen.getAllByRole("button", { + name: `Move locked ${figureType} backward`, + }); + await userEvent.click(moveButton[0]); + + // Assert + expect(onChangeMock).not.toBeCalled(); + }); + + test(`Does not call onChange when a locked ${figureType} is moved forward and is already in the front`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + renderEditor({ + onChange: onChangeMock, + lockedFigures: [first, second, third], + }); + + // Act + const moveButton = screen.getAllByRole("button", { + name: `Move locked ${figureType} forward`, + }); + await userEvent.click(moveButton[2]); + + // Assert + expect(onChangeMock).not.toBeCalled(); + }); + + test(`Does not call onChange when a locked ${figureType} is moved to the front and is already in the front`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + renderEditor({ + onChange: onChangeMock, + lockedFigures: [first, second, third], + }); + + // Act + const moveButton = screen.getAllByRole("button", { + name: `Move locked ${figureType} to the front`, + }); + await userEvent.click(moveButton[2]); + + // Assert + expect(onChangeMock).not.toBeCalled(); + }); + }); + test("Shows settings accordion when a locked $figureType is passed in", async () => { // Arrange renderEditor({ @@ -996,7 +1179,7 @@ describe("InteractiveGraphEditor locked figures", () => { // Delete the figure const deleteButton = screen.getByRole("button", { - name: "Delete locked line defined by 0, 0 and 2, 2.", + name: "Delete locked line", }); await userEvent.click(deleteButton);