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: Add SelectField.mapOption prop. #195

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
22 changes: 11 additions & 11 deletions src/forms/BoundSelectField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createObjectState, ObjectConfig, required } from "@homebound/form-state
import { render } from "@homebound/rtl-utils";
import { BoundSelectField } from "src/forms/BoundSelectField";
import { AuthorInput } from "src/forms/formStateDomain";
import { idAndName, identity } from "src/inputs/SelectField";

const sports = [
{ id: "s:1", name: "Football" },
Expand All @@ -11,20 +12,26 @@ const sports = [
describe("BoundSelectField", () => {
it("shows the current value", async () => {
const author = createObjectState(formConfig, { favoriteSport: "s:1" });
const { favoriteSport } = await render(<BoundSelectField field={author.favoriteSport} options={sports} />);
const { favoriteSport } = await render(
<BoundSelectField field={author.favoriteSport} options={sports} mapOption={idAndName} />,
);
expect(favoriteSport()).toHaveValue("Football");
});

it("shows the error message", async () => {
const author = createObjectState(formConfig, {});
author.favoriteSport.touched = true;
const { favoriteSport_errorMsg } = await render(<BoundSelectField field={author.favoriteSport} options={sports} />);
const { favoriteSport_errorMsg } = await render(
<BoundSelectField field={author.favoriteSport} options={sports} mapOption={idAndName} />,
);
expect(favoriteSport_errorMsg()).toHaveTextContent("Required");
});

it("shows the label", async () => {
const author = createObjectState(formConfig, { favoriteSport: "s:1" });
const { favoriteSport_label } = await render(<BoundSelectField field={author.favoriteSport} options={sports} />);
const { favoriteSport_label } = await render(
<BoundSelectField field={author.favoriteSport} options={sports} mapOption={idAndName} />,
);
expect(favoriteSport_label()).toHaveTextContent("Favorite Sport");
});

Expand All @@ -35,14 +42,7 @@ describe("BoundSelectField", () => {
{ label: "No", value: false },
{ label: "", value: undefined },
];
const r = await render(
<BoundSelectField
field={author.isAvailable}
options={options}
getOptionLabel={(o) => o.label}
getOptionValue={(o) => o.value}
/>,
);
const r = await render(<BoundSelectField field={author.isAvailable} options={options} mapOption={identity} />);
expect(r.isAvailable()).toHaveValue("");
});
});
Expand Down
13 changes: 1 addition & 12 deletions src/forms/BoundSelectField.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { FieldState } from "@homebound/form-state/dist/formState";
import { Observer } from "mobx-react";
import { SelectField, SelectFieldProps, Value } from "src/inputs";
import { HasIdAndName, Optional } from "src/types";
import { defaultLabel } from "src/utils/defaultLabel";
import { useTestIds } from "src/utils/useTestIds";

Expand All @@ -24,19 +23,11 @@ export type BoundSelectFieldProps<T, V extends Value> = Omit<
* The caller has to tell us how to turn `T` into `V`, which is usually a
* lambda like `t => t.id`.
*/
export function BoundSelectField<T, V extends Value>(props: BoundSelectFieldProps<T, V>): JSX.Element;
export function BoundSelectField<T extends HasIdAndName<V>, V extends Value>(
props: Optional<BoundSelectFieldProps<T, V>, "getOptionLabel" | "getOptionValue">,
): JSX.Element;
export function BoundSelectField<T extends object, V extends Value>(
props: Optional<BoundSelectFieldProps<T, V>, "getOptionValue" | "getOptionLabel">,
): JSX.Element {
export function BoundSelectField<T extends object, V extends Value>(props: BoundSelectFieldProps<T, V>): JSX.Element {
const {
field,
options,
readOnly,
getOptionValue = (opt: T) => (opt as any).id, // if unset, assume O implements HasId
getOptionLabel = (opt: T) => (opt as any).name, // if unset, assume O implements HasName
onSelect = (value) => field.set(value),
label = defaultLabel(field.key),
...others
Expand All @@ -53,8 +44,6 @@ export function BoundSelectField<T extends object, V extends Value>(
readOnly={readOnly ?? field.readOnly}
errorMsg={field.touched ? field.errors.join(" ") : undefined}
required={field.required}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
onBlur={() => field.blur()}
onFocus={() => field.focus()}
{...others}
Expand Down
163 changes: 75 additions & 88 deletions src/inputs/SelectField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { Meta } from "@storybook/react";
import { useState } from "react";
import { GridColumn, GridTable, Icon, Icons, simpleHeader, SimpleHeaderAndDataOf } from "src/components";
import { Css } from "src/Css";
import { SelectField, SelectFieldProps, Value } from "src/inputs";
import { HasIdAndName, Optional } from "src/types";
import { idAndName2, identity, SelectField, SelectFieldProps, Value } from "src/inputs";
import { noop } from "src/utils";
import { zeroTo } from "src/utils/sb";

Expand Down Expand Up @@ -51,60 +50,58 @@ export function SelectFields() {
label="Favorite Icon"
value={options[2].id}
options={options}
getOptionMenuLabel={(o) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
)}
mapOption={{
menuLabel: (o: TestOption) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
),
}}
/>
<TestSelectField
label="Favorite Icon - with field decoration"
options={options}
fieldDecoration={(o) => o.icon && <Icon icon={o.icon} />}
value={options[1].id}
getOptionMenuLabel={(o) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
)}
mapOption={{
menuLabel: (o: TestOption) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
),
}}
/>
<TestSelectField<TestOption, string>
<TestSelectField
label="Favorite Icon - Disabled"
value={undefined}
options={options}
disabled
mapOption={idAndName2}
/>
<TestSelectField label="Favorite Icon - Read Only" options={options} value={options[2].id} readOnly />
<TestSelectField<TestOption, string> label="Favorite Icon - Invalid" value={undefined} options={options} />
<TestSelectField label="Favorite Icon - Invalid" value={undefined} options={options} mapOption={idAndName2} />
<TestSelectField
label="Favorite Icon - Helper Text"
value={options[0].id}
options={options}
helperText="Some really long helper text that we expect to wrap."
/>
<TestSelectField
label="Favorite Number - Numeric"
value={1}
options={optionsWithNumericIds}
getOptionValue={(o) => o.id}
getOptionLabel={(o) => o.name}
/>
<TestSelectField label="Favorite Number - Numeric" value={1 as number} options={optionsWithNumericIds} />
<TestSelectField
label="Is Available - Boolean"
value={false}
value={false as boolean | undefined}
options={booleanOptions}
getOptionValue={(o) => o.value}
getOptionLabel={(o) => o.label}
mapOption={identity}
/>
</div>

Expand All @@ -115,47 +112,53 @@ export function SelectFields() {
label="Favorite Icon"
value={options[2].id}
options={options}
getOptionMenuLabel={(o) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
)}
mapOption={{
menuLabel: (o: TestOption) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
),
}}
/>
<TestSelectField
compact
label="Favorite Icon - with field decoration"
options={options}
fieldDecoration={(o) => o.icon && <Icon icon={o.icon} />}
value={options[1].id}
getOptionMenuLabel={(o) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
)}
mapOption={{
menuLabel: (o: TestOption) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
),
}}
/>
<TestSelectField<TestOption, string>
<TestSelectField
compact
label="Favorite Icon - Disabled"
value={undefined}
options={options}
disabled
mapOption={idAndName2}
/>
<TestSelectField compact label="Favorite Icon - Read Only" options={options} value={options[2].id} readOnly />
<TestSelectField<TestOption, string>
<TestSelectField
compact
label="Favorite Icon - Invalid"
options={options}
value={undefined}
mapOption={idAndName2}
/>
</div>
<div css={Css.df.flexColumn.childGap2.$}>
Expand All @@ -168,16 +171,18 @@ export function SelectFields() {
options={options}
fieldDecoration={(o) => o.icon && <Icon icon={o.icon} />}
value={options[4].id}
getOptionMenuLabel={(o) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
)}
mapOption={{
menuLabel: (o: TestOption) => (
<div css={Css.df.itemsCenter.$}>
{o.icon && (
<span css={Css.fs0.mr2.$}>
<Icon icon={o.icon} />
</span>
)}
{o.name}
</div>
),
}}
/>
</div>
<div css={Css.df.flexColumn.childGap2.$}>
Expand Down Expand Up @@ -210,40 +215,22 @@ const columns: GridColumn<Row>[] = [
{ header: "Address", data: (data) => data.address },
{
header: "Contact",
data: (data) => (
<SelectField
getOptionValue={(iu) => iu.id}
getOptionLabel={(iu) => iu.name}
value={data.user.id}
onSelect={noop}
options={people}
/>
),
data: (data) => <SelectField value={data.user.id} onSelect={noop} options={people} />,
},
{ header: "Market", data: (data) => data.market },
];
type Row = SimpleHeaderAndDataOf<Request>;
type InternalUser = { name: string; id: string };
type Request = { id: string; user: InternalUser; address: string; homeowner: string; market: string };

// Kind of annoying but to get type inference for HasIdAndName working, we
// have to re-copy/paste the overload here.
function TestSelectField<T extends object, V extends Value>(
props: Omit<SelectFieldProps<T, V>, "onSelect">,
): JSX.Element;
function TestSelectField<O extends HasIdAndName<V>, V extends Value>(
props: Optional<Omit<SelectFieldProps<O, V>, "onSelect">, "getOptionValue" | "getOptionLabel">,
): JSX.Element;
function TestSelectField<T extends object, V extends Value>(
props: Optional<Omit<SelectFieldProps<T, V>, "onSelect">, "getOptionValue" | "getOptionLabel">,
function TestSelectField<T extends object, V extends Value, V2 extends Value>(
props: Omit<SelectFieldProps<T, V, V2>, "onSelect">,
): JSX.Element {
const [selectedOption, setSelectedOption] = useState<V | undefined>(props.value);

return (
<div css={Css.df.$}>
<SelectField<T, V>
// The `as any` is due to something related to https://github.com/emotion-js/emotion/issues/2169
// We may have to redo the conditional getOptionValue/getOptionLabel
<SelectField<T, V, V2>
{...(props as any)}
value={selectedOption}
onSelect={setSelectedOption}
Expand Down
20 changes: 8 additions & 12 deletions src/inputs/SelectField.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { click, input, render } from "@homebound/rtl-utils";
import { useState } from "react";
import { SelectField, SelectFieldProps, Value } from "src/inputs";
import { idAndName2, SelectField, SelectFieldProps, Value } from "src/inputs";

const options = [
{ id: "1", name: "One" },
Expand All @@ -14,13 +14,7 @@ describe("SelectFieldTest", () => {
it("can set a value", async () => {
// Given a MultiSelectField
const { getByRole } = await render(
<TestSelectField
label="Age"
value={"1"}
options={options}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
/>,
<TestSelectField label="Age" value={"1" as string} options={options} mapOption={idAndName2} />,
);
// That initially has "One" selected
const text = getByRole("combobox");
Expand All @@ -33,11 +27,13 @@ describe("SelectFieldTest", () => {
expect(onSelect).toHaveBeenCalledWith("3");
});

function TestSelectField<O, V extends Value>(props: Omit<SelectFieldProps<O, V>, "onSelect">): JSX.Element {
const [selected, setSelected] = useState<V | undefined>(props.value);
function TestSelectField<O, V extends Value, V2 extends Value>(
props: Omit<SelectFieldProps<O, V, V2>, "onSelect">,
): JSX.Element {
const [selected, setSelected] = useState<V2 | undefined>(props.value);
return (
<SelectField<O, V>
{...props}
<SelectField<O, V, V2>
{...(props as any)}
value={selected}
onSelect={(value) => {
onSelect(value);
Expand Down
Loading