Skip to content

Commit

Permalink
feat: Add more overloads for SelectField options. (#999)
Browse files Browse the repository at this point in the history
`getOptionValue` is now optional for `options` with ids or codes.

`getOptionLabel` is now optional for `options` with name, displayName,
or labels.
  • Loading branch information
stephenh authored Feb 5, 2024
1 parent 6e708a1 commit 0663afe
Show file tree
Hide file tree
Showing 9 changed files with 144 additions and 32 deletions.
41 changes: 40 additions & 1 deletion src/forms/BoundSelectField.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createObjectState, ObjectConfig, ObjectState, required } from "@homebound/form-state";
import { click, render } from "@homebound/rtl-utils";
import { BoundSelectField } from "src/forms/BoundSelectField";
import { AuthorInput } from "src/forms/formStateDomain";
import { AuthorHeight, AuthorInput } from "src/forms/formStateDomain";
import { noop } from "src/utils";

const sports = [
{ id: "s:1", name: "Football" },
Expand All @@ -28,6 +29,43 @@ describe("BoundSelectField", () => {
expect(r.favoriteSport_label).toHaveTextContent("Favorite Sport");
});

it("binds to options with displayNames", async () => {
const sports = [
{ id: "s:1", displayName: "Football" },
{ id: "s:2", displayName: "Soccer" },
];
const author = createObjectState(formConfig, { favoriteSport: "s:1" });
const r = await render(<BoundSelectField field={author.favoriteSport} options={sports} />);
expect(r.favoriteSport).toHaveValue("Football");
});

it("binds to options with labels", async () => {
const sports = [
{ id: "s:1", label: "Football" },
{ id: "s:2", label: "Soccer" },
];
const author = createObjectState(formConfig, { favoriteSport: "s:1" });
const r = await render(<BoundSelectField field={author.favoriteSport} options={sports} />);
expect(r.favoriteSport).toHaveValue("Football");
});

it("binds to options with codes", async () => {
const heights = [
{ code: AuthorHeight.SHORT, name: "Shortish" },
{ code: AuthorHeight.TALL, name: "Tallish" },
];
const author = createObjectState(formConfig, { height: AuthorHeight.TALL });
const r = await render(<BoundSelectField field={author.height} options={heights} />);
expect(r.height).toHaveValue("Tallish");
});

it("requires getOptionValue if no name-ish key", async () => {
const sports = [{ id: "s:1" }, { id: "s:2" }];
const author = createObjectState(formConfig, { favoriteSport: "s:1" });
// @ts-expect-error
noop(<BoundSelectField field={author.favoriteSport} options={sports} />);
});

it("can bind against boolean fields", async () => {
const author = createObjectState(formConfig, { isAvailable: undefined });
const options = [
Expand Down Expand Up @@ -66,5 +104,6 @@ describe("BoundSelectField", () => {

const formConfig: ObjectConfig<AuthorInput> = {
favoriteSport: { type: "value", rules: [required] },
height: { type: "value" },
isAvailable: { type: "value" },
};
35 changes: 24 additions & 11 deletions src/forms/BoundSelectField.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,54 @@
import { FieldState } from "@homebound/form-state";
import { Observer } from "mobx-react";
import { SelectField, SelectFieldProps, Value } from "src/inputs";
import { HasIdAndName, Optional } from "src/types";
import { HasIdIsh, HasNameIsh, Optional } from "src/types";
import { maybeCall } from "src/utils";
import { defaultLabel } from "src/utils/defaultLabel";
import { useTestIds } from "src/utils/useTestIds";
import { defaultOptionLabel, defaultOptionValue } from "src/utils/options";

export type BoundSelectFieldProps<O, V extends Value> = Omit<SelectFieldProps<O, V>, "value" | "onSelect" | "label"> & {
// Allow `onSelect` to be overridden to do more than just `field.set`.
/** Optional, to allow `onSelect` to be overridden to do more than just `field.set`. */
onSelect?: (value: V | undefined, opt: O | undefined) => void;
/** The field we'll read/write data from. */
field: FieldState<V | null | undefined>;
/** An optional label, defaults to the humanized field key, i.e. `authorId` -> `Author`. */
label?: string;
};

/**
* Wraps `SelectField` and binds it to a form field.
*
* To ease integration with "select this fooId" inputs, we can take a list
* of objects, `T` (i.e. `TradePartner[]`), but accept a field of type `V`
* (i.e. `string`).
* To ease integration with GraphQL inputs that want to put `{ authorId: "a:1" }` on
* the wire, we generally expect the FieldState type to be a string/tagged id, but the
* `options` prop to be the full list of id+name options like `AuthorFragment[]`.
*
* The caller has to tell us how to turn `T` into `V`, which is usually a
* lambda like `t => t.id`.
* If `AuthorFragment` type matches `HasIdIsh` and `HasNameIsh`, we'll automatically use
* the `id` and `name` fields from it, otherwise callers need to provide `getOptionValue`
* and `getOptionLabel` to adapt the option, i.e. `getOptionLabel={(author) => author.otherName}`.
*
* Note: there are four overloads here to handle each combination of "HasIdIsh and HasNameId",
* "only has HasIdIsh", "only has HasNameIsh", and "neither".
*/
export function BoundSelectField<T, V extends Value>(props: BoundSelectFieldProps<T, V>): JSX.Element;
export function BoundSelectField<T extends HasIdAndName<V>, V extends Value>(
export function BoundSelectField<T extends HasIdIsh<V> & HasNameIsh, V extends Value>(
props: Optional<BoundSelectFieldProps<T, V>, "getOptionLabel" | "getOptionValue">,
): JSX.Element;
export function BoundSelectField<T extends HasIdIsh<V>, V extends Value>(
props: Optional<BoundSelectFieldProps<T, V>, "getOptionValue">,
): JSX.Element;
export function BoundSelectField<T extends HasNameIsh, V extends Value>(
props: Optional<BoundSelectFieldProps<T, V>, "getOptionLabel">,
): JSX.Element;
export function BoundSelectField<T, V extends Value>(props: BoundSelectFieldProps<T, V>): JSX.Element;
export function BoundSelectField<T extends object, V extends Value>(
props: Optional<BoundSelectFieldProps<T, V>, "getOptionValue" | "getOptionLabel">,
): 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
getOptionValue = defaultOptionValue,
getOptionLabel = defaultOptionLabel,
onSelect = (value) => field.set(value),
label = defaultLabel(field.key),
onBlur,
Expand Down
6 changes: 6 additions & 0 deletions src/forms/formStateDomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ export const jan29 = new Date(2020, 0, 29);
export const dd100: DeweyDecimalClassification = { number: "100", category: "Philosophy" };
export const dd200: DeweyDecimalClassification = { number: "200", category: "Religion" };

export enum AuthorHeight {
SHORT,
TALL,
}

export interface AuthorInput {
id?: string | null;
height?: AuthorHeight | null;
firstName?: string | null;
middleInitial?: string | null;
lastName?: string | null;
Expand Down
44 changes: 33 additions & 11 deletions src/inputs/SelectField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from "react";
import { SelectField, SelectFieldProps, Value } from "src/inputs";
import { HasIdAndName, Optional } from "src/types";
import { blur, click, focus, getOptions, render, select, wait } from "src/utils/rtl";
import { AuthorHeight } from "src/forms/formStateDomain";

describe("SelectFieldTest", () => {
it("can set a value", async () => {
Expand All @@ -17,7 +18,6 @@ describe("SelectFieldTest", () => {
options={options}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
onBlur={onBlur}
onSelect={onSelect}
/>,
Expand Down Expand Up @@ -49,7 +49,6 @@ describe("SelectFieldTest", () => {
getOptionValue={(o) => o.id}
onFocus={onFocus}
onBlur={onBlur}
data-testid="age"
/>,
);
focus(r.age);
Expand All @@ -67,7 +66,6 @@ describe("SelectFieldTest", () => {
options={options}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
/>,
);
// When changing the inputs value, and not selecting an option
Expand Down Expand Up @@ -98,7 +96,6 @@ describe("SelectFieldTest", () => {
options={[{ id: undefined, name: "Unassigned" }, ...options]}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
/>,
);
// Then expect the value to be that of the `undefined` entry
Expand All @@ -114,7 +111,6 @@ describe("SelectFieldTest", () => {
options={[{ id: undefined, name: "Unassigned" }, ...options]}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
/>,
);
// When selecting the option with an `undefined` value
Expand All @@ -123,6 +119,38 @@ describe("SelectFieldTest", () => {
expect(r.age).toHaveValue("Unassigned");
});

it("can use option codes", async () => {
const options = [
{ code: AuthorHeight.SHORT, name: "Shortish" },
{ code: AuthorHeight.TALL, name: "Tallish" },
];
const value = AuthorHeight.TALL as AuthorHeight;
const r = await render(<SelectField label="Age" value={value} options={options} onSelect={() => {}} />);
expect(r.age).toHaveValue("Tallish");
});

it("can use option displayNames", async () => {
const options = [
{ id: "1", displayName: "One" },
{ id: "2", displayName: "Two" },
{ id: "3", displayName: "Three" },
];
const value = "2" as string;
const r = await render(<SelectField label="Age" value={value} options={options} onSelect={() => {}} />);
expect(r.age).toHaveValue("Two");
});

it("can use option labels", async () => {
const options = [
{ id: "1", label: "One" },
{ id: "2", label: "Two" },
{ id: "3", label: "Three" },
];
const value = "2" as string;
const r = await render(<SelectField label="Age" value={value} options={options} onSelect={() => {}} />);
expect(r.age).toHaveValue("Two");
});

it("respects disabled options", async () => {
const onSelect = jest.fn();
// Given a Select Field with a disabled option
Expand All @@ -133,7 +161,6 @@ describe("SelectFieldTest", () => {
options={options}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
disabledOptions={["2"]}
onSelect={onSelect}
/>,
Expand Down Expand Up @@ -161,7 +188,6 @@ describe("SelectFieldTest", () => {
options={options}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
disabledOptions={[{ value: "2", reason: "Example Tooltip" }]}
onSelect={onSelect}
/>,
Expand Down Expand Up @@ -200,7 +226,6 @@ describe("SelectFieldTest", () => {
onSelect={() => {}}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
/>
);
}
Expand All @@ -226,7 +251,6 @@ describe("SelectFieldTest", () => {
options={[options[0]]}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
/>,
);
// When opening the menu
Expand All @@ -250,7 +274,6 @@ describe("SelectFieldTest", () => {
options={options}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
/>,
);
// Then both have the same value
Expand Down Expand Up @@ -285,7 +308,6 @@ describe("SelectFieldTest", () => {
options={[] as HasIdAndName[]}
getOptionLabel={(o) => o.name}
getOptionValue={(o) => o.id}
data-testid="age"
/>,
);
// The input value will initially be blank
Expand Down
17 changes: 12 additions & 5 deletions src/inputs/SelectField.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useMemo } from "react";
import { Value } from "src/inputs";
import { ComboBoxBase, ComboBoxBaseProps, unsetOption } from "src/inputs/internal/ComboBoxBase";
import { HasIdAndName, Optional } from "src/types";
import { HasIdAndName, HasIdIsh, HasNameIsh, Optional } from "src/types";
import { defaultOptionLabel, defaultOptionValue } from "src/utils/options";

export interface SelectFieldProps<O, V extends Value>
extends Omit<ComboBoxBaseProps<O, V>, "values" | "onSelect" | "multiselect"> {
Expand All @@ -21,16 +22,22 @@ export interface SelectFieldProps<O, V extends Value>
* The `O` type is a list of options to show, the `V` is the primitive value of a
* given `O` (i.e. it's id) that you want to use as the current/selected value.
*/
export function SelectField<O, V extends Value>(props: SelectFieldProps<O, V>): JSX.Element;
export function SelectField<O extends HasIdAndName<V>, V extends Value>(
export function SelectField<O extends HasIdIsh<V> & HasNameIsh, V extends Value>(
props: Optional<SelectFieldProps<O, V>, "getOptionValue" | "getOptionLabel">,
): JSX.Element;
export function SelectField<O extends HasIdIsh<V>, V extends Value>(
props: Optional<SelectFieldProps<O, V>, "getOptionValue">,
): JSX.Element;
export function SelectField<O extends HasNameIsh, V extends Value>(
props: Optional<SelectFieldProps<O, V>, "getOptionLabel">,
): JSX.Element;
export function SelectField<O, V extends Value>(props: SelectFieldProps<O, V>): JSX.Element;
export function SelectField<O, V extends Value>(
props: Optional<SelectFieldProps<O, V>, "getOptionLabel" | "getOptionValue">,
): JSX.Element {
const {
getOptionValue = (opt: O) => (opt as any).id, // if unset, assume O implements HasId
getOptionLabel = (opt: O) => (opt as any).name, // if unset, assume O implements HasName
getOptionValue = defaultOptionValue,
getOptionLabel = defaultOptionLabel,
options,
onSelect,
value,
Expand Down
2 changes: 1 addition & 1 deletion src/inputs/Value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function keyToValue<V extends Value>(key: Key): V {
}
}

export function valueToKey(value: Value): Key {
export function valueToKey(value: Value): string {
if (typeof value === "string") {
return value;
} else if (typeof value === "number") {
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import React from "react";
import { DateRange as _DateRange } from "react-day-picker";
export type { _DateRange as DateRange };

export type HasIdIsh<V = string> = { id: V } | { code: V };
export type HasNameIsh = { name: string } | { displayName: string } | { label: string };
export type HasIdAndName<V = string> = { id: V; name: string };

export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<T>;
export type CheckFn = () => boolean;
export type CanCloseCheck = { check: CheckFn; discardText?: string; continueText?: string } | CheckFn;
Expand Down
6 changes: 5 additions & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { MutableRefObject } from "react";
import type { CheckboxGroupState, ToggleState } from "react-stately";

export function fail(message?: string): never {
throw new Error(message || "Failed");
}

/** Adapts our state to what useToggleState returns in a stateless manner. */
export function toToggleState(isSelected: boolean, onChange: (value: boolean) => void): ToggleState {
return {
Expand Down Expand Up @@ -52,7 +56,7 @@ export function safeKeys<T>(instance: T): (keyof T)[] {
// Returns object with specified key removed
export const omitKey = <T, K extends keyof T>(key: K, { [key]: _, ...obj }: T) => obj as T;

export const noop = () => {};
export const noop = (...args: any) => {};

type Entries<T> = {
[K in keyof T]: [K, T[K]];
Expand Down
22 changes: 20 additions & 2 deletions src/utils/options.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,20 @@
export const defaultOptionValue = <O>(opt: O) => (opt as any).id;
export const defaultOptionLabel = <O>(opt: O) => (opt as any).name;
import { fail } from "src/utils";

// This `any` is currently on purpose to ignore type errors in ChipSelectField
export function defaultOptionValue<O>(opt: O): any {
if (typeof opt !== "object" || !opt) fail(`Option ${opt} has no id or code`);
// Use `in` because returning undefined is fine
return "id" in opt ? opt.id : "code" in opt ? opt.code : fail(`Option ${JSON.stringify(opt)} has no id or code`);
}

export function defaultOptionLabel<O>(opt: O): any {
if (typeof opt !== "object" || !opt) fail(`Option ${opt} has no id or code`);
// Use `in` because returning undefined is fine
return "displayName" in opt
? opt.displayName
: "label" in opt
? opt.label
: "name" in opt
? opt.name
: fail(`Option ${JSON.stringify(opt)} has no displayName, label, or name`);
}

0 comments on commit 0663afe

Please sign in to comment.