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

feat: Append, replace, removeOne, & removeAll operations for SetValue #2914

Merged
merged 17 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
94 changes: 78 additions & 16 deletions editor.planx.uk/src/@planx/components/SetValue/Editor.tsx
DafyddLlyr marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Typography from "@mui/material/Typography";
import { ComponentType as TYPES } from "@opensystemslab/planx-core/types";
import { EditorProps, InternalNotes } from "@planx/components/ui";
import { useFormik } from "formik";
Expand All @@ -6,13 +7,72 @@ import ModalSection from "ui/editor/ModalSection";
import ModalSectionContent from "ui/editor/ModalSectionContent";
import Input from "ui/shared/Input";
import InputRow from "ui/shared/InputRow";
import Radio from "ui/shared/Radio";

import { parseSetValue, SetValue } from "./model";

type Props = EditorProps<TYPES.SetValue, SetValue>;

export default SetValueComponent;

interface Option {
value: SetValue["operation"];
label: string;
}

const options: Option[] = [
{
value: "replace",
label: "Replace",
},
{
value: "append",
label: "Append",
},
{
value: "removeOne",
label: "Remove single value",
},
{
value: "removeAll",
label: "Remove all values",
},
];

const DescriptionText: React.FC<SetValue> = ({ fn, val, operation }) => {
if (!fn || !val) return null;

switch (operation) {
case "replace":
return (
<Typography mb={2}>
Any existing value for <strong>{fn}</strong> will be replaced by{" "}
<strong>{val}</strong>
</Typography>
);
case "append":
return (
<Typography mb={2}>
Any existing value for <strong>{fn}</strong> will have{" "}
<strong>{val}</strong> appended to it
</Typography>
);
case "removeOne":
return (
<Typography mb={2}>
Any existing value for <strong>{fn}</strong> set to{" "}
<strong>{val}</strong> will be removed
</Typography>
);
case "removeAll":
return (
<Typography mb={2}>
All existing values for <strong>{fn}</strong> will be removed
</Typography>
);
}
};

function SetValueComponent(props: Props) {
const formik = useFormik({
initialValues: parseSetValue(props.node?.data),
Expand Down Expand Up @@ -41,24 +101,26 @@ function SetValueComponent(props: Props) {
</ModalSectionContent>
<ModalSectionContent title="Field value">
<InputRow>
<div>
<Input
required
format="data"
name="val"
value={formik.values.val}
placeholder="value"
onChange={formik.handleChange}
/>
{formik.values.fn && formik.values.val && (
<p>
any existing value for <strong>{formik.values.fn}</strong>{" "}
will be replaced by <strong>{formik.values.val}</strong>
</p>
)}
</div>
<Input
required
format="data"
name="val"
value={formik.values.val}
placeholder="value"
onChange={formik.handleChange}
/>
</InputRow>
</ModalSectionContent>
<ModalSectionContent title="Operation">
<DescriptionText {...formik.values} />
<Radio
options={options}
value={formik.values.operation}
onChange={(newOperation) => {
formik.setFieldValue("operation", newOperation);
}}
/>
</ModalSectionContent>
</ModalSection>
<InternalNotes
name="notes"
Expand Down
2 changes: 2 additions & 0 deletions editor.planx.uk/src/@planx/components/SetValue/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { MoreInformation, parseMoreInformation } from "../shared";
export interface SetValue extends MoreInformation {
fn: string;
val: string;
operation: "replace" | "append" | "removeOne" | "removeAll";
}

export const parseSetValue = (
data: Record<string, any> | undefined,
): SetValue => ({
fn: data?.fn || "",
val: data?.val || "",
operation: data?.operation || "replace",
...parseMoreInformation(data),
});
135 changes: 135 additions & 0 deletions editor.planx.uk/src/@planx/components/SetValue/utils.test.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This file should represent the inputs/outputs described in this "Logic Camp" diagram -

image

Source: https://miro.com/app/board/uXjVN9K07PQ=/?moveToWidget=3458764578921902831&cot=14

Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { SetValue } from "./model";
import { handleSetValue } from "./utils";
import { Store } from "pages/FlowEditor/lib/store";


describe("calculateNewValues() helper function", () => {
describe('"replace" operation', () => {
test.each([
{ previous: undefined, expected: ["lion"] },
{ previous: "panda", expected: ["lion"] },
{ previous: "lion", expected: ["lion"] },
{ previous: ["lion"], expected: ["lion"] },
{ previous: ["bear", "dog", "monkey"], expected: ["lion"] },
{ previous: ["bear", "dog", "lion"], expected: ["lion"] },
])('input of $previous sets passport value to be $expected', ({ previous, expected }) => {
const mockKey = "myAnimals";
const mockSetValue: SetValue = {
operation: "replace",
fn: mockKey,
val: "lion",
};
const mockPassport: Store.passport = {
data: { mockNode: mockSetValue, [mockKey]: previous },
};

const updatedPassport = handleSetValue({
nodeData: mockSetValue,
previousValues: previous,
passport: mockPassport,
});

const actual = updatedPassport.data?.[mockKey];
expect(actual).toEqual(expected);
});
});

describe('"append" operation', () => {
test.each([
{ previous: undefined, expected: ["lion"] },
{ previous: "panda", expected: ["panda", "lion"] },
{ previous: "lion", expected: ["lion"] },
{ previous: ["lion"], expected: ["lion"] },
{
previous: ["bear", "dog", "monkey"],
expected: ["bear", "dog", "monkey", "lion"],
},
{ previous: ["bear", "dog", "lion"], expected: ["bear", "dog", "lion"] },
])('input of $previous sets passport value to be $expected', ({ previous, expected }) => {
const mockKey = "myAnimals";
const mockSetValue: SetValue = {
operation: "append",
fn: mockKey,
val: "lion",
};
const mockPassport: Store.passport = {
data: { mockNode: mockSetValue, [mockKey]: previous },
};

const updatedPassport = handleSetValue({
nodeData: mockSetValue,
previousValues: previous,
passport: mockPassport,
});

const actual = updatedPassport.data?.[mockKey];
expect(actual).toEqual(expected);
});
});

describe('"removeOne" operation', () => {
test.each([
{ previous: undefined, expected: undefined },
{ previous: "panda", expected: "panda" },
{ previous: "lion", expected: undefined },
{ previous: ["lion"], expected: undefined },
{
previous: ["bear", "dog", "monkey"],
expected: ["bear", "dog", "monkey"],
},
{ previous: ["bear", "dog", "lion"], expected: ["bear", "dog"] },
])('input of $previous sets passport value to be $expected', ({ previous, expected }) => {
const mockKey = "myAnimals";
const mockSetValue: SetValue = {
operation: "removeOne",
fn: mockKey,
val: "lion",
};
const mockPassport: Store.passport = {
data: { mockNode: mockSetValue, [mockKey]: previous },
};

const updatedPassport = handleSetValue({
nodeData: mockSetValue,
previousValues: previous,
passport: mockPassport,
});

const actual = updatedPassport.data?.[mockKey];
expect(actual).toEqual(expected);
});
});

describe('"removeAll" operation', () => {
test.each([
{ previous: undefined, expected: undefined },
{ previous: "panda", expected: undefined },
{ previous: "lion", expected: undefined },
{ previous: ["lion"], expected: undefined },
{
previous: ["bear", "dog", "monkey"],
expected: undefined,
},
{ previous: ["bear", "dog", "lion"], expected: undefined },
])('input of $previous sets passport value to be $expected', ({ previous, expected }) => {
const mockKey = "myAnimals";
const mockSetValue: SetValue = {
operation: "removeAll",
fn: mockKey,
val: "lion",
};
const mockPassport: Store.passport = {
data: { mockNode: mockSetValue, [mockKey]: previous },
};

const updatedPassport = handleSetValue({
nodeData: mockSetValue,
previousValues: previous,
passport: mockPassport,
});

const actual = updatedPassport.data?.[mockKey];
expect(actual).toEqual(expected);
});
});
});
89 changes: 89 additions & 0 deletions editor.planx.uk/src/@planx/components/SetValue/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Store } from "pages/FlowEditor/lib/store";
import { SetValue } from "./model";

type PreviousValues = string | string[] | undefined;

type HandleSetValue = (params: {
nodeData: SetValue;
previousValues: PreviousValues;
passport: Store.passport;
}) => Store.passport;

/**
* Handle modifying passport values when passing through a SetValue component
* Called by computePassport()
*/
export const handleSetValue: HandleSetValue = ({
Copy link
Contributor Author

@DafyddLlyr DafyddLlyr May 6, 2024

Choose a reason for hiding this comment

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

Following previous conversations, I've refactored this logic to be co-located with the component rather than being purely within the store.

The motivations here were two-fold -

  • This is SetValue logic and doesn't relate to any other components - it just can't get called by this component directly
  • We actually want to use some of this code within the Editor modal to show the Editor examples of how this component works (upcoming PR). Locating this within the store would make this harder to re-use in a different context.

nodeData: { operation, fn, val: current },
previousValues,
passport,
}) => {
// We do not amend values set at objects
// These are internal exceptions we do not want to allow users to edit
// e.g. property.boundary.title
const isObject =
typeof previousValues === "object" &&
!Array.isArray(previousValues) &&
previousValues !== null;
if (isObject) return passport;

const previous = formatPreviousValues(previousValues);

const newValues = calculateNewValues({
operation,
previous,
current,
});

if (newValues) {
passport.data![fn] = newValues;

// Operation has cleared passport value
if (!newValues.length) delete passport.data![fn];
}

return passport;
};

type CalculateNewValues = (params: {
operation: SetValue["operation"];
previous: string[];
current: string;
}) => string | string[] | undefined;

const calculateNewValues: CalculateNewValues = ({
operation,
previous,
current,
}) => {
switch (operation) {
case "replace":
return [current];

case "removeOne": {
// Do not convert output from string to string[] if operation not possible
if (previous.length === 1 && previous[0] !== current) {
return previous[0];
}

const removeCurrent = (val: string) => val !== current;
const filtered = previous.filter(removeCurrent);
return filtered;
}

case "removeAll":
return [];

case "append": {
const combined = [...previous, current];
const unique = [...new Set(combined)];
return unique;
}
}
};

const formatPreviousValues = (values: PreviousValues): string[] => {
if (!values) return [];
if (Array.isArray(values)) return values;
return [values];
};
Loading
Loading