Skip to content

Commit

Permalink
Add story for dependent async selects (#2078)
Browse files Browse the repository at this point in the history
  • Loading branch information
johnnyomair authored and Markus Fichtner committed Jul 18, 2024
1 parent 6ce4a19 commit fef890a
Show file tree
Hide file tree
Showing 7 changed files with 323 additions and 2 deletions.
15 changes: 15 additions & 0 deletions .changeset/silly-candles-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@comet/admin": minor
---

Add `OnChangeField` helper to listen to field changes

**Example**

```tsx
<OnChangeField name="product">
{(value, previousValue) => {
// Will be called when field 'product' changes
}}
</OnChangeField>
```
138 changes: 138 additions & 0 deletions packages/admin/admin/src/form/helpers/OnChangeField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copied from https://github.com/final-form/react-final-form-listeners/blob/master/src/OnChange.test.js
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { Field, Form } from "react-final-form";

import { OnChangeField } from "./OnChangeField";

const onSubmitMock = () => {
// Noop
};

describe("OnChangeField", () => {
afterEach(cleanup);

it("should not call listener on first render", () => {
const spy = jest.fn();
render(
<Form onSubmit={onSubmitMock} initialValues={{ foo: "bar" }}>
{() => <OnChangeField name="foo">{spy}</OnChangeField>}
</Form>,
);
expect(spy).not.toHaveBeenCalled();
});

it("should call listener when going from uninitialized to value", () => {
const spy = jest.fn();
const { getByTestId } = render(
<Form onSubmit={onSubmitMock}>
{() => (
<form>
<Field name="name" component="input" data-testid="name" />
<OnChangeField name="name">{spy}</OnChangeField>
</form>
)}
</Form>,
);
expect(spy).not.toHaveBeenCalled();
fireEvent.change(getByTestId("name"), { target: { value: "erikras" } });
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith("erikras", "");
});

it("should call listener when going from initialized to value", () => {
const spy = jest.fn();
const { getByTestId } = render(
<Form onSubmit={onSubmitMock} initialValues={{ name: "erik" }}>
{() => (
<form>
<Field name="name" component="input" data-testid="name" />
<OnChangeField name="name">{spy}</OnChangeField>
</form>
)}
</Form>,
);
expect(spy).not.toHaveBeenCalled();
fireEvent.change(getByTestId("name"), { target: { value: "erikras" } });
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith("erikras", "erik");
});

it("should call listener when changing to null", () => {
const spy = jest.fn();
const { getByTestId } = render(
<Form onSubmit={onSubmitMock} initialValues={{ name: "erikras" }}>
{() => (
<form>
<Field name="name" component="input" data-testid="name" />
<OnChangeField name="name">{spy}</OnChangeField>
</form>
)}
</Form>,
);
expect(spy).not.toHaveBeenCalled();
fireEvent.change(getByTestId("name"), { target: { value: null } });
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith("", "erikras");
});

it("should handle quick subsequent changes properly", () => {
const toppings = ["Pepperoni", "Mushrooms", "Olives"];
const { getByTestId } = render(
<Form onSubmit={onSubmitMock}>
{({ form }) => (
<React.Fragment>
<Field name="everything" component="input" type="checkbox" data-testid="everything" />
<OnChangeField name="everything">
{(next) => {
if (next) {
return form.change("toppings", toppings);
}
}}
</OnChangeField>
{toppings.length > 0 &&
toppings.map((topping, index) => {
return (
<Field component="input" key={topping} name="toppings" value={topping} type="checkbox" data-testid={topping} />
);
})}
<OnChangeField name="toppings">
{(next) => {
form.change("everything", next && next.length === toppings.length);
}}
</OnChangeField>
</React.Fragment>
)}
</Form>,
);
expect((getByTestId("everything") as HTMLInputElement).checked).toBe(false);
expect((getByTestId("Pepperoni") as HTMLInputElement).checked).toBe(false);
expect((getByTestId("Mushrooms") as HTMLInputElement).checked).toBe(false);
expect((getByTestId("Olives") as HTMLInputElement).checked).toBe(false);

fireEvent.click(getByTestId("Pepperoni"));
expect((getByTestId("Pepperoni") as HTMLInputElement).checked).toBe(true);
expect((getByTestId("everything") as HTMLInputElement).checked).toBe(false);

fireEvent.click(getByTestId("Mushrooms"));
expect((getByTestId("Mushrooms") as HTMLInputElement).checked).toBe(true);
expect((getByTestId("everything") as HTMLInputElement).checked).toBe(false);

fireEvent.click(getByTestId("Olives"));
expect((getByTestId("Olives") as HTMLInputElement).checked).toBe(true);
expect((getByTestId("everything") as HTMLInputElement).checked).toBe(true);

fireEvent.click(getByTestId("Olives"));
expect((getByTestId("Olives") as HTMLInputElement).checked).toBe(false);
expect((getByTestId("everything") as HTMLInputElement).checked).toBe(false);

fireEvent.click(getByTestId("everything"));
expect((getByTestId("Pepperoni") as HTMLInputElement).checked).toBe(true);
expect((getByTestId("Mushrooms") as HTMLInputElement).checked).toBe(true);
expect((getByTestId("Olives") as HTMLInputElement).checked).toBe(true);
expect((getByTestId("everything") as HTMLInputElement).checked).toBe(true);
});
});
21 changes: 21 additions & 0 deletions packages/admin/admin/src/form/helpers/OnChangeField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Inspired by https://github.com/final-form/react-final-form-listeners/blob/master/src/OnChange.js
import { useEffect, useRef } from "react";
import { useField } from "react-final-form";

type Props = { name: string; children: (value: any, previousValue: any) => void };

function OnChangeField({ name, children }: Props) {
const { input } = useField(name);
const previousValue = useRef(input.value);

useEffect(() => {
if (input.value !== previousValue.current) {
children(input.value, previousValue.current);
previousValue.current = input.value;
}
}, [input.value, children]);

return null;
}

export { OnChangeField };
5 changes: 3 additions & 2 deletions packages/admin/admin/src/hooks/useAsyncOptionsProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ export function useAsyncOptionsProps<T>(loadOptions: () => Promise<T[]>): AsyncO
const loading = open && options.length === 0;
React.useEffect(() => {
let active = true;
if (!loading) {
if (!open) {
return undefined;
}
setOptions([]);
(async () => {
const response = await loadOptions();
if (active) {
Expand All @@ -26,7 +27,7 @@ export function useAsyncOptionsProps<T>(loadOptions: () => Promise<T[]>): AsyncO
return () => {
active = false;
};
}, [loadOptions, loading]);
}, [loadOptions, open]);
return {
isAsync: true,
open,
Expand Down
1 change: 1 addition & 0 deletions packages/admin/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export { FinalFormRangeInput, FinalFormRangeInputClassKey, FinalFormRangeInputPr
export { FinalFormSearchTextField, FinalFormSearchTextFieldProps } from "./form/FinalFormSearchTextField";
export { FinalFormSelect, FinalFormSelectProps } from "./form/FinalFormSelect";
export { FormSection, FormSectionClassKey, FormSectionProps } from "./form/FormSection";
export { OnChangeField } from "./form/helpers/OnChangeField";
export { FinalFormRadio, FinalFormRadioProps } from "./form/Radio";
export { FinalFormSwitch, FinalFormSwitchProps } from "./form/Switch";
export { FormMutation } from "./FormMutation";
Expand Down
63 changes: 63 additions & 0 deletions storybook/.storybook/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,23 @@ input LaunchesPastFilter {
or: [LaunchesPastFilter!]
}
type Manufacturer {
id: ID!
name: String!
}
type Product {
id: ID!
name: String!
manufacturer: Manufacturer!
}
type Query {
launchesPastResult(limit: Int, offset: Int, sort: String, order: String, filter: LaunchesPastFilter): LaunchesPastResult!
launchesPastPagePaging(page: Int, size: Int): LaunchesPastPagePagingResult!
manufacturers: [Manufacturer!]!
products(manufacturer: ID): [Product!]!
}
`;

Expand Down Expand Up @@ -197,11 +211,60 @@ const launchesPastRest: ResponseResolver = (req, res, ctx: RestContext) => {
return res(ctx.status(200), ctx.json(allLaunches));
};

export type Manufacturer = {
id: string;
name: string;
};

const allManufacturers: Manufacturer[] = [];

for (let i = 0; i < 10; i += 1) {
allManufacturers.push({
id: faker.datatype.uuid(),
name: faker.company.name(),
});
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const manufacturers: GraphQLFieldResolver<unknown, unknown> = async () => {
await sleep(500);
return allManufacturers;
};

export type Product = {
id: string;
name: string;
manufacturer: Manufacturer;
};

const allProducts: Product[] = [];

for (let i = 0; i < 100; i += 1) {
allProducts.push({
id: faker.datatype.uuid(),
name: faker.commerce.product(),
manufacturer: faker.helpers.arrayElement(allManufacturers),
});
}

const products: GraphQLFieldResolver<unknown, unknown, { manufacturer?: string }> = async (source, { manufacturer }) => {
await sleep(500);

if (manufacturer) {
return allProducts.filter((product) => product.manufacturer.id === manufacturer);
}

return allProducts;
};

const graphqlHandler = new GraphQLHandler({
resolverMap: {
Query: {
launchesPastResult,
launchesPastPagePaging,
manufacturers,
products,
},
},

Expand Down
82 changes: 82 additions & 0 deletions storybook/src/admin/form/DependentAsyncSelects.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { gql, useApolloClient } from "@apollo/client";
import { AsyncSelectField, FinalForm, OnChangeField } from "@comet/admin";
import { Box } from "@mui/material";
import { storiesOf } from "@storybook/react";
import * as React from "react";

import { apolloStoryDecorator } from "../../apollo-story.decorator";
import { Manufacturer, Product } from "../../mocks/handlers";

interface FormValues {
manufacturer?: Manufacturer;
product?: Product;
}

storiesOf("@comet/admin/form", module)
.addDecorator(apolloStoryDecorator("/graphql"))
.add("Dependent async selects", function () {
const client = useApolloClient();

return (
<Box maxWidth={400}>
<FinalForm<FormValues>
mode="add"
onSubmit={() => {
// Noop
}}
>
{({ values, form }) => (
<>
<AsyncSelectField
name="manufacturer"
loadOptions={async () => {
const { data } = await client.query({
query: gql`
query Manufacturers {
manufacturers {
id
name
}
}
`,
});

return data.manufacturers;
}}
getOptionLabel={(option: Manufacturer) => option.name}
label="Manufacturer"
fullWidth
/>
<AsyncSelectField
name="product"
loadOptions={async () => {
const { data } = await client.query({
query: gql`
query Products($manufacturer: ID) {
products(manufacturer: $manufacturer) {
id
name
}
}
`,
variables: { manufacturer: values.manufacturer?.id },
});

return data.products;
}}
getOptionLabel={(option: Product) => option.name}
label="Product"
fullWidth
disabled={!values.manufacturer}
/>
<OnChangeField name="manufacturer">
{() => {
form.change("product", undefined);
}}
</OnChangeField>
</>
)}
</FinalForm>
</Box>
);
});

0 comments on commit fef890a

Please sign in to comment.