diff --git a/package.json b/package.json
index f1acd38bf..f1f0abaa8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@homebound/beam",
- "version": "2.318.4",
+ "version": "2.322.0",
"author": "Homebound",
"license": "MIT",
"main": "dist/index.js",
diff --git a/src/components/Banner.test.tsx b/src/components/Banner.test.tsx
new file mode 100644
index 000000000..e7edf7301
--- /dev/null
+++ b/src/components/Banner.test.tsx
@@ -0,0 +1,23 @@
+import { Banner } from "src";
+import { click, render } from "src/utils/rtl";
+
+describe(Banner, () => {
+ it("should render", async () => {
+ // Given the Banner with a message and no onClose callback
+ const r = await render();
+ // Then the banner should be visible
+ expect(r.banner_message).toHaveTextContent("Banner message");
+ // And there should be no close button
+ expect(r.query.banner_close).not.toBeInTheDocument();
+ });
+
+ it("should trigger onClose", async () => {
+ const onClose = jest.fn();
+ // Given the Banner with a message and an onClose callback
+ const r = await render();
+ // When clicking on the close button
+ click(r.banner_close);
+ // Then the onClose callback should be called
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx
new file mode 100644
index 000000000..ac824b5a1
--- /dev/null
+++ b/src/components/Banner.tsx
@@ -0,0 +1,48 @@
+import { ReactNode } from "react";
+import { Icon, IconKey } from "src/components/Icon";
+import { IconButton } from "src/components/IconButton";
+import { Css, Palette, Properties } from "src/Css";
+import { useTestIds } from "src/utils";
+
+export interface BannerProps {
+ type: BannerTypes;
+ message: ReactNode;
+ onClose?: VoidFunction;
+}
+
+export function Banner(props: BannerProps) {
+ const { type, message, onClose = false, ...others } = props;
+ const tid = useTestIds(others, "banner");
+ return (
+
+
+
+
+
+ {message}
+
+ {onClose && (
+
+
+
+ )}
+
+ );
+}
+const typeToIcon: Record = {
+ success: "checkCircle",
+ info: "infoCircle",
+ warning: "error",
+ alert: "errorCircle",
+ error: "xCircle",
+};
+
+const variantStyles: Record = {
+ success: Css.bgGreen100.gray900.$,
+ info: Css.bgBlue100.gray900.$,
+ warning: Css.bgYellow200.gray900.$,
+ alert: Css.bgGray200.gray900.$,
+ error: Css.bgRed100.gray900.$,
+};
+
+export type BannerTypes = "error" | "warning" | "success" | "info" | "alert";
diff --git a/src/components/Filters/SingleFilter.tsx b/src/components/Filters/SingleFilter.tsx
index 76afc1bd5..9174c8e8b 100644
--- a/src/components/Filters/SingleFilter.tsx
+++ b/src/components/Filters/SingleFilter.tsx
@@ -37,7 +37,7 @@ class SingleFilter extends BaseFilter
diff --git a/src/components/Icon.stories.tsx b/src/components/Icon.stories.tsx
index 6fb938020..92758a847 100644
--- a/src/components/Icon.stories.tsx
+++ b/src/components/Icon.stories.tsx
@@ -103,6 +103,7 @@ export const Icon = (props: IconProps) => {
"openBook",
];
const miscIcons: IconProps["icon"][] = [
+ "inbox",
"dollar",
"userCircle",
"calendar",
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx
index e61adbfed..0b235c7eb 100644
--- a/src/components/Icon.tsx
+++ b/src/components/Icon.tsx
@@ -368,6 +368,9 @@ export const Icons = {
),
// Misc
+ inbox: (
+
+ ),
criticalPath: (
),
diff --git a/src/components/ScrollShadows.tsx b/src/components/ScrollShadows.tsx
index 205a43cec..4ca25ae32 100644
--- a/src/components/ScrollShadows.tsx
+++ b/src/components/ScrollShadows.tsx
@@ -29,7 +29,7 @@ export function ScrollShadows(props: ScrollShadowsProps) {
// The shadow styles will rarely every change. Memoize them to avoid recomputing them when we don't have to.
const [startShadowStyles, endShadowStyles] = useMemo(() => {
const transparentBgColor = bgColor.replace(/,1\)$/, ",0)");
- const commonStyles = Css.absolute.z3.$;
+ const commonStyles = Css.absolute.z3.add({ pointerEvents: "none" }).$;
const startShadowStyles = !horizontal ? Css.top0.left0.right0.hPx(40).$ : Css.left0.top0.bottom0.wPx(25).$;
const endShadowStyles = !horizontal ? Css.bottom0.left0.right0.hPx(40).$ : Css.right0.top0.bottom0.wPx(25).$;
const startGradient = `linear-gradient(${!horizontal ? 180 : 90}deg, ${bgColor} 0%, ${transparentBgColor} 92%);`;
diff --git a/src/components/Table/GridTable.test.tsx b/src/components/Table/GridTable.test.tsx
index c615c5c2f..f4e1cc85e 100644
--- a/src/components/Table/GridTable.test.tsx
+++ b/src/components/Table/GridTable.test.tsx
@@ -32,6 +32,7 @@ import {
type Data = { name: string; value: number | undefined | null };
type Row = SimpleHeaderAndData;
+const idColumn: GridColumn = { id: "id", header: () => "Id", data: (data, { row }) => row.id };
const nameColumn: GridColumn = { id: "name", header: () => "Name", data: ({ name }) => name };
const valueColumn: GridColumn = { id: "value", header: () => "Value", data: ({ value }) => value };
const columns = [nameColumn, valueColumn];
@@ -3265,6 +3266,32 @@ describe("GridTable", () => {
`);
});
+ it("tableSnapshot can use a subset of columns", async () => {
+ // Given a table with simple data
+ const r = await render(
+ ,
+ );
+
+ // Then a text snapshot should be generated when using `tableSnapshot`
+ expect(tableSnapshot(r, ["Id", "Value"])).toMatchInlineSnapshot(`
+ "
+ | Id | Value |
+ | -- | ----- |
+ | 1 | 200 |
+ | 2 | 300 |
+ | 3 | 1000 |
+ "
+ `);
+ });
+
it("renders totals row in the correct order", async () => {
type Row = SimpleHeaderAndData | TotalsRow;
// Given a table with simple header, totals, and data row
diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx
index a8c75c088..2d161259b 100644
--- a/src/components/Toast/Toast.tsx
+++ b/src/components/Toast/Toast.tsx
@@ -1,49 +1,9 @@
-import { Icon, IconKey } from "src/components/Icon";
-import { Css, Palette, Properties } from "src/Css";
+import { Banner } from "src/components";
import { useTestIds } from "src/utils";
-import { IconButton } from "../IconButton";
import { useToastContext } from "./ToastContext";
export function Toast() {
const { setNotice, notice } = useToastContext();
const tid = useTestIds({}, "toast");
- return (
- <>
- {notice && (
-
-
-
-
-
- {notice.message}
-
-
- setNotice(undefined)} {...tid.close} color={Palette.Gray900} />
-
-
- )}
- >
- );
+ return <>{notice && setNotice(undefined)} />}>;
}
-
-const typeToIcon: Record = {
- success: "checkCircle",
- info: "infoCircle",
- warning: "error",
- alert: "errorCircle",
- error: "xCircle",
-};
-
-const variantStyles: Record = {
- success: Css.bgGreen100.gray900.$,
- info: Css.bgBlue100.gray900.$,
- warning: Css.bgYellow200.gray900.$,
- alert: Css.bgGray200.gray900.$,
- error: Css.bgRed100.gray900.$,
-};
-
-export type ToastTypes = "error" | "warning" | "success" | "info" | "alert";
diff --git a/src/components/Toast/ToastContext.tsx b/src/components/Toast/ToastContext.tsx
index 8d3d0343f..a9ab38ba9 100644
--- a/src/components/Toast/ToastContext.tsx
+++ b/src/components/Toast/ToastContext.tsx
@@ -1,10 +1,7 @@
import React, { createContext, ReactNode, useContext, useMemo, useState } from "react";
-import { ToastTypes } from "./Toast";
+import { BannerProps } from "src/components";
-export interface ToastNoticeProps {
- type: ToastTypes;
- message: ReactNode;
-}
+export interface ToastNoticeProps extends Omit {}
export type ToastContextProps = {
notice: ToastNoticeProps | undefined;
diff --git a/src/components/index.ts b/src/components/index.ts
index 35d5d9e5c..86aeb029f 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -8,6 +8,8 @@ export * from "./Accordion";
export * from "./AccordionList";
export * from "./AutoSaveIndicator";
export * from "./Avatar";
+export * from "./Banner";
+export type { BannerProps } from "./Banner";
export { BeamProvider } from "./BeamContext";
export * from "./Button";
export * from "./ButtonDatePicker";
diff --git a/src/inputs/SelectField.stories.tsx b/src/inputs/SelectField.stories.tsx
index 685ff7ad4..3591acd00 100644
--- a/src/inputs/SelectField.stories.tsx
+++ b/src/inputs/SelectField.stories.tsx
@@ -224,6 +224,7 @@ Contrast.args = { compact: true, contrast: true };
const loadTestOptions: TestOption[] = zeroTo(1000).map((i) => ({ id: String(i), name: `Project ${i}` }));
export function PerfTest() {
+ const [loaded, setLoaded] = useState([]);
const [selectedValue, setSelectedValue] = useState(loadTestOptions[2].id);
return (
{
- return new Promise((resolve) => {
- // @ts-ignore - believes `options` should be of type `never[]`
- setTimeout(() => resolve({ options: loadTestOptions }), 1500);
- });
+ await sleep(1500);
+ setLoaded(loadTestOptions);
},
+ options: loaded,
}}
onBlur={action("onBlur")}
onFocus={action("onFocus")}
@@ -248,6 +248,7 @@ export function PerfTest() {
PerfTest.parameters = { chromatic: { disableSnapshot: true } };
export function LazyLoadStateFields() {
+ const [loaded, setLoaded] = useState([]);
const [selectedValue, setSelectedValue] = useState(loadTestOptions[2].id);
return (
<>
@@ -257,13 +258,12 @@ export function LazyLoadStateFields() {
onSelect={setSelectedValue}
unsetLabel={"-"}
options={{
- initial: [loadTestOptions.find((o) => o.id === selectedValue)!],
+ current: loadTestOptions.find((o) => o.id === selectedValue)!,
load: async () => {
- return new Promise((resolve) => {
- // @ts-ignore - believes `options` should be of type `never[]`
- setTimeout(() => resolve({ options: loadTestOptions }), 1500);
- });
+ await sleep(1500);
+ setLoaded(loadTestOptions);
},
+ options: loaded,
}}
/>
o.id === selectedValue)!],
+ current: loadTestOptions.find((o) => o.id === selectedValue)!,
load: async () => {
- return new Promise((resolve) => {
- // @ts-ignore - believes `options` should be of type `never[]`
- setTimeout(() => resolve({ options: loadTestOptions }), 1500);
- });
+ await sleep(1500);
+ setLoaded(loadTestOptions);
},
+ options: loaded,
}}
/>
>
@@ -287,21 +286,20 @@ export function LazyLoadStateFields() {
LazyLoadStateFields.parameters = { chromatic: { disableSnapshot: true } };
export function LoadingState() {
+ const [loaded, setLoaded] = useState([]);
const [selectedValue, setSelectedValue] = useState(loadTestOptions[2].id);
-
return (
{
- return new Promise((resolve) => {
- // @ts-ignore - believes `options` should be of type `never[]`
- setTimeout(() => resolve({ options: loadTestOptions }), 5000);
- });
+ await sleep(5000);
+ setLoaded(loadTestOptions);
},
+ options: loadTestOptions,
}}
/>
);
@@ -392,3 +390,5 @@ function TestSelectField(
);
}
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/src/inputs/SelectField.test.tsx b/src/inputs/SelectField.test.tsx
index 7abfe4354..b2bcf8c4c 100644
--- a/src/inputs/SelectField.test.tsx
+++ b/src/inputs/SelectField.test.tsx
@@ -181,16 +181,28 @@ describe("SelectFieldTest", () => {
it("can load options via options prop callback", async () => {
// Given a Select Field with options that are loaded via a callback
- const r = await render(
- ({ options }) }}
- getOptionLabel={(o) => o.name}
- getOptionValue={(o) => o.id}
- data-testid="age"
- />,
- );
+ function Test() {
+ const [loaded, setLoaded] = useState([]);
+ return (
+ {
+ await sleep(0);
+ setLoaded(options);
+ },
+ options: loaded,
+ }}
+ onSelect={() => {}}
+ getOptionLabel={(o) => o.name}
+ getOptionValue={(o) => o.id}
+ data-testid="age"
+ />
+ );
+ }
+ const r = await render();
// When opening the menu
click(r.age);
// Then expect to see the initial option and loading state
@@ -305,17 +317,25 @@ describe("SelectFieldTest", () => {
it("can define and select 'unsetLabel' when options are lazily loaded", async () => {
const onSelect = jest.fn();
// Given a Select Field with options that are loaded lazily
- const r = await render(
- ({ options: labelValueOptions }) }}
- getOptionLabel={(o) => o.label}
- getOptionValue={(o) => o.value}
- onSelect={onSelect}
- />,
- );
+ function Test() {
+ const [loaded, setLoaded] = useState([]);
+ return (
+ setLoaded(labelValueOptions),
+ options: loaded,
+ }}
+ getOptionLabel={(o) => o.label}
+ getOptionValue={(o) => o.value}
+ onSelect={onSelect}
+ />
+ );
+ }
+ const r = await render();
// When we click the field to open the menu
await clickAndWait(r.age);
// The 'unset' option is in the menu and we select it
@@ -327,13 +347,27 @@ describe("SelectFieldTest", () => {
it("can initially be set to the 'unsetLabel' option", async () => {
// Given a Select Field with the value set to `undefined`
const r = await render(
- {}}
+ />,
+ );
+ // The input value will be set to the `unsetLabel`
+ expect(r.age).toHaveValue("None");
+ });
+
+ it("can initially be set to the 'unsetLabel' option when lazy loading options", async () => {
+ // Given a Select Field with the value set to `undefined`
+ const r = await render(
+
label="Age"
value={undefined}
unsetLabel="None"
- options={labelValueOptions}
- getOptionLabel={(o) => o.label}
- getOptionValue={(o) => o.value}
+ options={{ current: undefined, load: async () => {}, options: undefined }}
+ onSelect={() => {}}
/>,
);
// The input value will be set to the `unsetLabel`
@@ -369,20 +403,28 @@ describe("SelectFieldTest", () => {
it("supports boolean values properly", async () => {
// Given a select field with boolean and an undefined values
const onSelect = jest.fn();
- const r = await render(
- o.name}
- getOptionValue={(o) => o.id}
- />,
- );
+ type Option = { id: undefined | boolean; name: string };
+ function Test() {
+ const [value, setValue] = useState(true);
+ return (
+
+ label="label"
+ value={value}
+ onSelect={(value) => {
+ onSelect(value);
+ setValue(value);
+ }}
+ options={[
+ { id: undefined, name: "Undefined" },
+ { id: false, name: "False" },
+ { id: true, name: "True" },
+ ]}
+ getOptionLabel={(o) => o.name}
+ getOptionValue={(o) => o.id}
+ />
+ );
+ }
+ const r = await render();
// When selecting the `false` option
click(r.label);
@@ -442,11 +484,12 @@ describe("SelectFieldTest", () => {
);
}
- function TestMultipleSelectField(
+ function TestMultipleSelectField(
props: Optional, "onSelect">,
): JSX.Element {
const [selected, setSelected] = useState(props.value);
const init = options.find((o) => o.id === selected) as O;
+ const [loaded, setLoaded] = useState([]);
return (
<>
@@ -455,13 +498,12 @@ describe("SelectFieldTest", () => {
onSelect={setSelected}
unsetLabel={"-"}
options={{
- initial: [init],
+ current: init,
load: async () => {
- return new Promise((resolve) => {
- // @ts-ignore - believes `options` should be of type `never[]`
- setTimeout(() => resolve({ options }), 1500);
- });
+ await sleep(1500);
+ setLoaded(props.options as O[]);
},
+ options: loaded,
}}
/>
@@ -470,16 +512,17 @@ describe("SelectFieldTest", () => {
onSelect={setSelected}
unsetLabel={"-"}
options={{
- initial: [init],
+ current: init,
load: async () => {
- return new Promise((resolve) => {
- // @ts-ignore - believes `options` should be of type `never[]`
- setTimeout(() => resolve({ options }), 1500);
- });
+ await sleep(1500);
+ setLoaded(props.options as O[]);
},
+ options: loaded,
}}
/>
>
);
}
});
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/src/inputs/SelectField.tsx b/src/inputs/SelectField.tsx
index 3db9af6cc..066d0998f 100644
--- a/src/inputs/SelectField.tsx
+++ b/src/inputs/SelectField.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from "react";
import { Value } from "src/inputs";
import { ComboBoxBase, ComboBoxBaseProps, unsetOption } from "src/inputs/internal/ComboBoxBase";
import { HasIdAndName, Optional } from "src/types";
@@ -35,14 +36,14 @@ export function SelectField(
value,
...otherProps
} = props;
-
+ const values = useMemo(() => [value], [value]);
return (
{
// If the user used `unsetLabel`, then values will be `[undefined]` and options `[unsetOption]`
if (values.length > 0 && options.length > 0) {
diff --git a/src/inputs/TreeSelectField/TreeSelectField.stories.tsx b/src/inputs/TreeSelectField/TreeSelectField.stories.tsx
index 341b1e988..9d6efbbf5 100644
--- a/src/inputs/TreeSelectField/TreeSelectField.stories.tsx
+++ b/src/inputs/TreeSelectField/TreeSelectField.stories.tsx
@@ -187,7 +187,7 @@ export function AsyncOptions() {
{
return new Promise((resolve) => {
// @ts-ignore - believes `options` should be of type `never[]`
diff --git a/src/inputs/TreeSelectField/TreeSelectField.test.tsx b/src/inputs/TreeSelectField/TreeSelectField.test.tsx
index fb4bf21b3..1e6d254b3 100644
--- a/src/inputs/TreeSelectField/TreeSelectField.test.tsx
+++ b/src/inputs/TreeSelectField/TreeSelectField.test.tsx
@@ -132,7 +132,7 @@ describe(TreeSelectField, () => {
const r = await render(
({ options }) }}
+ options={{ current: initialOption, load: async () => ({ options }) }}
label="Favorite League"
values={[]}
getOptionValue={(o) => o.id}
diff --git a/src/inputs/TreeSelectField/TreeSelectField.tsx b/src/inputs/TreeSelectField/TreeSelectField.tsx
index 5346ac985..0139a7cbd 100644
--- a/src/inputs/TreeSelectField/TreeSelectField.tsx
+++ b/src/inputs/TreeSelectField/TreeSelectField.tsx
@@ -147,7 +147,7 @@ function TreeSelectFieldBase(props: TreeSelectFieldProps = { option: NestedOption; parents: NestedOption[] };
export type NestedOption = O & { children?: NestedOption[] };
export type NestedOptionsOrLoad =
| NestedOption[]
- | { initial: NestedOption[]; load: () => Promise<{ options: NestedOption[] }> };
+ | { current: NestedOption[]; load: () => Promise<{ options: NestedOption[] }> };
export type LeveledOption = [NestedOption, number];
export type TreeFieldState = {
diff --git a/src/inputs/internal/ComboBoxBase.tsx b/src/inputs/internal/ComboBoxBase.tsx
index d5bae196d..3a3031703 100644
--- a/src/inputs/internal/ComboBoxBase.tsx
+++ b/src/inputs/internal/ComboBoxBase.tsx
@@ -10,8 +10,8 @@ import { ComboBoxInput } from "src/inputs/internal/ComboBoxInput";
import { ListBox } from "src/inputs/internal/ListBox";
import { keyToValue, Value, valueToKey } from "src/inputs/Value";
import { BeamFocusableProps } from "src/interfaces";
-import { areArraysEqual } from "src/utils";
+/** Base props for either `SelectField` or `MultiSelectField`. */
export interface ComboBoxBaseProps extends BeamFocusableProps, PresentationFieldProps {
/** Renders `opt` in the dropdown menu, defaults to the `getOptionLabel` prop. `isUnsetOpt` is only defined for single SelectField */
getOptionMenuLabel?: (opt: O, isUnsetOpt?: boolean) => string | ReactNode;
@@ -77,81 +77,78 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
disabled,
readOnly,
onSelect,
- options,
+ options: propOptions,
multiselect = false,
- values = [],
+ values: propValues,
nothingSelectedText = "",
contrast,
disabledOptions,
borderless,
unsetLabel,
+ getOptionLabel: propOptionLabel,
+ getOptionValue: propOptionValue,
+ getOptionMenuLabel: propOptionMenuLabel,
...otherProps
} = props;
const labelStyle = otherProps.labelStyle ?? fieldProps?.labelStyle ?? "above";
- // Call `initializeOptions` to prepend the `unset` option if the `unsetLabel` was provided.
- const maybeOptions = useMemo(() => initializeOptions(options, unsetLabel), [options, unsetLabel]);
// Memoize the callback functions and handle the `unset` option if provided.
const getOptionLabel = useCallback(
- (o: O) => (unsetLabel && o === unsetOption ? unsetLabel : props.getOptionLabel(o)),
- // TODO: validate this eslint-disable. It was automatically ignored as part of https://app.shortcut.com/homebound-team/story/40033/enable-react-hooks-exhaustive-deps-for-react-projects
+ (o: O) => (unsetLabel && o === unsetOption ? unsetLabel : propOptionLabel(o)),
+ // propOptionLabel is basically always a lambda, so don't dep on it
// eslint-disable-next-line react-hooks/exhaustive-deps
- [props.getOptionLabel, unsetLabel],
+ [unsetLabel],
);
const getOptionValue = useCallback(
- (o: O) => (unsetLabel && o === unsetOption ? (undefined as V) : props.getOptionValue(o)),
- // TODO: validate this eslint-disable. It was automatically ignored as part of https://app.shortcut.com/homebound-team/story/40033/enable-react-hooks-exhaustive-deps-for-react-projects
+ (o: O) => (unsetLabel && o === unsetOption ? (undefined as V) : propOptionValue(o)),
+ // propOptionValue is basically always a lambda, so don't dep on it
// eslint-disable-next-line react-hooks/exhaustive-deps
- [props.getOptionValue, unsetLabel],
+ [unsetLabel],
);
const getOptionMenuLabel = useCallback(
(o: O) =>
- props.getOptionMenuLabel
- ? props.getOptionMenuLabel(o, Boolean(unsetLabel) && o === unsetOption)
- : getOptionLabel(o),
- // TODO: validate this eslint-disable. It was automatically ignored as part of https://app.shortcut.com/homebound-team/story/40033/enable-react-hooks-exhaustive-deps-for-react-projects
+ propOptionMenuLabel ? propOptionMenuLabel(o, Boolean(unsetLabel) && o === unsetOption) : getOptionLabel(o),
+ // propOptionMenuLabel is basically always a lambda, so don't dep on it
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [unsetLabel, getOptionLabel],
+ );
+
+ // Call `initializeOptions` to prepend the `unset` option if the `unsetLabel` was provided.
+ const options = useMemo(
+ () => initializeOptions(propOptions, getOptionValue, unsetLabel),
+ // If the caller is using { current, load, options }, memoize on only `current` and `options` values.
+ // ...and don't bother on memoizing on getOptionValue b/c it's basically always a lambda
// eslint-disable-next-line react-hooks/exhaustive-deps
- [props.getOptionValue, unsetLabel, getOptionLabel],
+ Array.isArray(propOptions) ? [propOptions, unsetLabel] : [propOptions.current, propOptions.options, unsetLabel],
);
+ const values = useMemo(() => propValues ?? [], [propValues]);
+
+ const selectedOptions = useMemo(() => {
+ return options.filter((o) => values.includes(getOptionValue(o)));
+ }, [options, values, getOptionValue]);
+
const { contains } = useFilter({ sensitivity: "base" });
const isDisabled = !!disabled;
const isReadOnly = !!readOnly;
- const [fieldState, setFieldState] = useState>(() => {
- const initOptions = Array.isArray(maybeOptions) ? maybeOptions : maybeOptions.initial;
- const selectedOptions = initOptions.filter((o) => values.includes(getOptionValue(o)));
+ // Do a one-time initialize of fieldState
+ const [fieldState, setFieldState] = useState(() => {
return {
- selectedKeys: selectedOptions?.map((o) => valueToKey(getOptionValue(o))) ?? [],
- inputValue: getInputValue(
- initOptions.filter((o) => values?.includes(getOptionValue(o))),
- getOptionLabel,
- multiselect,
- nothingSelectedText,
- ),
- filteredOptions: initOptions,
- allOptions: initOptions,
- selectedOptions: selectedOptions,
+ inputValue: getInputValue(selectedOptions, getOptionLabel, multiselect, nothingSelectedText),
+ searchValue: undefined,
optionsLoading: false,
};
});
+ const { searchValue } = fieldState;
+ const filteredOptions = useMemo(() => {
+ return !searchValue ? options : options.filter((o) => contains(getOptionLabel(o), searchValue));
+ }, [options, searchValue, getOptionLabel, contains]);
+
/** Resets field's input value and filtered options list for cases where the user exits the field without making changes (on Escape, or onBlur) */
function resetField() {
- const inputValue = getInputValue(
- fieldState.allOptions.filter((o) => values?.includes(getOptionValue(o))),
- getOptionLabel,
- multiselect,
- nothingSelectedText,
- );
- // Conditionally reset the value if the current inputValue doesn't match that of the passed in value, or we filtered the list
- if (inputValue !== fieldState.inputValue || fieldState.filteredOptions.length !== fieldState.allOptions.length) {
- setFieldState((prevState) => ({
- ...prevState,
- inputValue,
- filteredOptions: prevState.allOptions,
- }));
- }
+ setFieldState((prevState) => ({ ...prevState, searchValue: undefined }));
}
function onSelectionChange(keys: Selection): void {
@@ -169,34 +166,12 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
);
if (multiselect && keys.size === 0) {
- setFieldState({
- ...fieldState,
- inputValue: state.isOpen ? "" : nothingSelectedText,
- selectedKeys: [],
- selectedOptions: [],
- });
selectionChanged && onSelect([], []);
return;
}
const selectedKeys = [...keys.values()];
- const selectedOptions = fieldState.allOptions.filter((o) => selectedKeys.includes(valueToKey(getOptionValue(o))));
- const firstSelectedOption = selectedOptions[0];
-
- setFieldState((prevState) => ({
- ...prevState,
- // If menu is open then reset inputValue to "". Otherwise set inputValue depending on number of options selected.
- inputValue:
- multiselect && (state.isOpen || selectedKeys.length > 1)
- ? ""
- : firstSelectedOption
- ? getOptionLabel(firstSelectedOption!)
- : "",
- selectedKeys,
- selectedOptions,
- filteredOptions: fieldState.allOptions,
- }));
-
+ const selectedOptions = options.filter((o) => selectedKeys.includes(valueToKey(getOptionValue(o))));
selectionChanged && onSelect(selectedKeys.map(keyToValue) as V[], selectedOptions);
if (!multiselect) {
@@ -207,26 +182,15 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
function onInputChange(value: string) {
if (value !== fieldState.inputValue) {
- setFieldState((prevState) => ({
- ...prevState,
- inputValue: value,
- filteredOptions: fieldState.allOptions.filter((o) => contains(getOptionLabel(o), value)),
- }));
+ setFieldState((prevState) => ({ ...prevState, inputValue: value, searchValue: value }));
}
}
async function maybeInitLoad() {
- if (!Array.isArray(maybeOptions)) {
+ if (!Array.isArray(propOptions)) {
setFieldState((prevState) => ({ ...prevState, optionsLoading: true }));
- const loadedOptions = (await maybeOptions.load()).options;
- // Ensure the `unset` option is prepended to the top of the list if `unsetLabel` was provided
- const options = !unsetLabel ? loadedOptions : getOptionsWithUnset(unsetLabel, loadedOptions);
- setFieldState((prevState) => ({
- ...prevState,
- filteredOptions: options,
- allOptions: options,
- optionsLoading: false,
- }));
+ await propOptions.load();
+ setFieldState((prevState) => ({ ...prevState, optionsLoading: false }));
}
}
@@ -236,7 +200,6 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
maybeInitLoad();
firstOpen.current = false;
}
-
// When using the multiselect field, always empty the input upon open.
if (multiselect && isOpen) {
setFieldState((prevState) => ({ ...prevState, inputValue: "" }));
@@ -259,7 +222,7 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
...otherProps,
disabledKeys: Object.keys(disabledOptionsWithReasons),
inputValue: fieldState.inputValue,
- items: fieldState.filteredOptions,
+ items: filteredOptions,
isDisabled,
isReadOnly,
onInputChange,
@@ -290,74 +253,34 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
},
});
+ const selectedKeys = useMemo(() => {
+ return selectedOptions.map((o) => valueToKey(getOptionValue(o)));
+ }, [selectedOptions, getOptionValue]);
// @ts-ignore - `selectionManager.state` exists, but not according to the types
state.selectionManager.state = useMultipleSelectionState({
selectionMode: multiselect ? "multiple" : "single",
// Do not allow an empty selection if single select mode
disallowEmptySelection: !multiselect,
- selectedKeys: fieldState.selectedKeys,
+ selectedKeys,
onSelectionChange,
});
- // Ensure we reset if the field's values change and the user is not actively selecting options.
- useEffect(
- () => {
- if (!state.isOpen && !areArraysEqual(values, fieldState.selectedKeys)) {
- setFieldState((prevState) => {
- const selectedOptions = prevState.allOptions.filter((o) => values?.includes(getOptionValue(o)));
- return {
- ...prevState,
- selectedKeys: selectedOptions?.map((o) => valueToKey(getOptionValue(o))) ?? [],
- inputValue:
- selectedOptions.length === 1
- ? getOptionLabel(selectedOptions[0])
- : multiselect && selectedOptions.length === 0
- ? nothingSelectedText
- : "",
- selectedOptions: selectedOptions,
- };
- });
- }
- },
- // TODO: validate this eslint-disable. It was automatically ignored as part of https://app.shortcut.com/homebound-team/story/40033/enable-react-hooks-exhaustive-deps-for-react-projects
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [values],
- );
-
- useEffect(
- () => {
- // When options are an array, then use them as-is.
- // If options are an object, then use the `initial` array if the menu has not been opened
- // Otherwise, use the current fieldState array options.
- const maybeUpdatedOptions = Array.isArray(maybeOptions)
- ? maybeOptions
- : firstOpen.current === false
- ? fieldState.allOptions
- : maybeOptions.initial;
-
- if (maybeUpdatedOptions !== fieldState.allOptions) {
- setFieldState((prevState) => {
- const selectedOptions = maybeUpdatedOptions.filter((o) => values?.includes(getOptionValue(o)));
- return {
- ...prevState,
- selectedKeys: selectedOptions?.map((o) => valueToKey(getOptionValue(o))) ?? [],
- inputValue:
- selectedOptions.length === 1
- ? getOptionLabel(selectedOptions[0])
- : multiselect && selectedOptions.length === 0
- ? nothingSelectedText
- : "",
- selectedOptions: selectedOptions,
- filteredOptions: maybeUpdatedOptions,
- allOptions: maybeUpdatedOptions,
- };
- });
- }
- },
- // TODO: validate this eslint-disable. It was automatically ignored as part of https://app.shortcut.com/homebound-team/story/40033/enable-react-hooks-exhaustive-deps-for-react-projects
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [maybeOptions],
- );
+ // Reset inputValue when closed or selected changes
+ useEffect(() => {
+ if (state.isOpen && multiselect) {
+ // While the multiselect is open, let the user keep typing
+ setFieldState((prevState) => ({
+ ...prevState,
+ inputValue: "",
+ searchValue: "",
+ }));
+ } else {
+ setFieldState((prevState) => ({
+ ...prevState,
+ inputValue: getInputValue(selectedOptions, getOptionLabel, multiselect, nothingSelectedText),
+ }));
+ }
+ }, [state.isOpen, selectedOptions, getOptionLabel, multiselect, nothingSelectedText]);
// For the most part, the returned props contain `aria-*` and `id` attributes for accessibility purposes.
const {
@@ -409,7 +332,7 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
listBoxRef={listBoxRef}
state={state}
labelProps={labelProps}
- selectedOptions={fieldState.selectedOptions}
+ selectedOptions={selectedOptions}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
contrast={contrast}
@@ -432,7 +355,7 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
positionProps={positionProps}
state={state}
listBoxRef={listBoxRef}
- selectedOptions={fieldState.selectedOptions}
+ selectedOptions={selectedOptions}
getOptionLabel={getOptionLabel}
getOptionValue={(o) => valueToKey(getOptionValue(o))}
contrast={contrast}
@@ -446,15 +369,26 @@ export function ComboBoxBase(props: ComboBoxBaseProps)
);
}
-type FieldState = {
- selectedKeys: Key[];
+type FieldState = {
inputValue: string;
- filteredOptions: O[];
- selectedOptions: O[];
- allOptions: O[];
+ // We need separate `searchValue` vs. `inputValue` b/c we might be showing the
+ // currently-loaded option in the input, without the user having typed a filter yet.
+ searchValue: string | undefined;
optionsLoading: boolean;
};
-export type OptionsOrLoad = O[] | { initial: O[]; load: () => Promise<{ options: O[] }> };
+
+/** Allows lazy-loading select fields, which is useful for pages w/lots of fields the user may not actually use. */
+export type OptionsOrLoad =
+ | O[]
+ | {
+ /** The initial option to show before the user interacts with the dropdown. */
+ current: O | undefined;
+ /** Fired when the user interacts with the dropdown, to load the real options. */
+ load: () => Promise;
+ /** The full list of options, after load() has been fired. */
+ options: O[] | undefined;
+ };
+
type UnsetOption = { id: undefined; name: string };
function getInputValue(
@@ -470,22 +404,36 @@ function getInputValue(
: "";
}
-export function initializeOptions(options: OptionsOrLoad, unsetLabel: string | undefined): OptionsOrLoad {
- if (!unsetLabel) {
- return options;
+/** Transforms/simplifies `optionsOrLoad` into just options, with unsetLabel maybe added. */
+export function initializeOptions(
+ optionsOrLoad: OptionsOrLoad,
+ getOptionValue: (opt: O) => V,
+ unsetLabel: string | undefined,
+): O[] {
+ const opts: O[] = [];
+ if (unsetLabel) {
+ opts.push(unsetOption as unknown as O);
}
-
- if (Array.isArray(options)) {
- return getOptionsWithUnset(unsetLabel, options);
+ if (Array.isArray(optionsOrLoad)) {
+ opts.push(...optionsOrLoad);
+ } else {
+ const { options, current } = optionsOrLoad;
+ if (options) {
+ opts.push(...options);
+ }
+ // Even if the SelectField has lazy-loaded options, make sure the current value is really in there
+ if (current) {
+ const value = getOptionValue(current);
+ const found = options && options.find((o) => getOptionValue(o) === value);
+ if (!found) {
+ opts.push(current);
+ }
+ }
}
-
- return { ...options, initial: getOptionsWithUnset(unsetLabel, options.initial) };
-}
-
-function getOptionsWithUnset(unsetLabel: string, options: O[]): O[] {
- return [unsetOption as unknown as O, ...options];
+ return opts;
}
+/** A marker option to automatically add an "Unset" option to the start of options. */
export const unsetOption = {};
export function disabledOptionToKeyedTuple(
diff --git a/src/utils/rtl.test.tsx b/src/utils/rtl.test.tsx
index d4c450f54..eca686488 100644
--- a/src/utils/rtl.test.tsx
+++ b/src/utils/rtl.test.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import { MultiSelectField, NestedOption, SelectField, TreeSelectField } from "src/inputs";
import { HasIdAndName } from "src/types";
import { getOptions, getSelected, render, select, selectAndWait } from "src/utils/rtl";
@@ -6,18 +7,25 @@ describe("rtl", () => {
it("can use select helpers and select an option via value on SelectField", async () => {
const onSelect = jest.fn();
// Given the SelectField
- const r = await render(
- ,
- );
+ function Test() {
+ const [value, setValue] = useState();
+ return (
+ {
+ setValue(value);
+ onSelect(value, option);
+ }}
+ options={[
+ { id: "1", name: "One" },
+ { id: "2", name: "Two" },
+ { id: "3", name: "Three" },
+ ]}
+ />
+ );
+ }
+ const r = await render();
// Then the getOptions helper returns the correct options
expect(getOptions(r.number)).toEqual(["One", "Two", "Three"]);
@@ -89,18 +97,25 @@ describe("rtl", () => {
it("can use select helpers and select an option via value on MultiSelectField", async () => {
const onSelect = jest.fn();
// Given the MultiSelectField
- const r = await render(
- ,
- );
+ function Test() {
+ const [values, setValues] = useState([]);
+ return (
+ {
+ setValues(values);
+ onSelect(values, options);
+ }}
+ options={[
+ { id: "1", name: "One" },
+ { id: "2", name: "Two" },
+ { id: "3", name: "Three" },
+ ]}
+ />
+ );
+ }
+ const r = await render();
// Then the getOptions helper returns the correct options
expect(getOptions(r.number)).toEqual(["One", "Two", "Three"]);
@@ -127,18 +142,25 @@ describe("rtl", () => {
it("can select options via label on MultiSelectField", async () => {
const onSelect = jest.fn();
// Given the MultiSelectField
- const r = await render(
- ,
- );
+ function Test() {
+ const [values, setValues] = useState([]);
+ return (
+ {
+ setValues(values);
+ onSelect(values, options);
+ }}
+ options={[
+ { id: "1", name: "One" },
+ { id: "2", name: "Two" },
+ { id: "3", name: "Three" },
+ ]}
+ />
+ );
+ }
+ const r = await render();
// When selecting options by label
select(r.number, ["One", "Three"]);
@@ -215,30 +237,35 @@ describe("rtl", () => {
expect(getSelected(r.number)).toEqual("One One");
});
- // TODO: validate this eslint-disable with https://app.shortcut.com/homebound-team/story/40045
- // eslint-disable-next-line jest/no-identical-title
- it("can select options via label on MultiSelectField", async () => {
+ it("can select options via label on TreeSelectField", async () => {
const onSelect = jest.fn();
// Given the TreeSelectField
- const r = await render(
- onSelect(all)}
- options={
- [
- {
- id: "1",
- name: "One",
- children: [
- { id: "1.1", name: "One One" },
- { id: "1.2", name: "One Two" },
- ],
- },
- ] as NestedOption[]
- }
- />,
- );
+ function Test() {
+ const [values, setValues] = useState([]);
+ return (
+ {
+ setValues(all.values);
+ onSelect(all);
+ }}
+ options={
+ [
+ {
+ id: "1",
+ name: "One",
+ children: [
+ { id: "1.1", name: "One One" },
+ { id: "1.2", name: "One Two" },
+ ],
+ },
+ ] as NestedOption[]
+ }
+ />
+ );
+ }
+ const r = await render();
// When selecting an option by its label
select(r.number, ["One One"]);
// Then the onSelect handler is called with the correct values
@@ -285,19 +312,26 @@ describe("rtl", () => {
it("can use select helpers on multiline SelectField", async () => {
const onSelect = jest.fn();
// Given the SelectField is multline
- const r = await render(
- ,
- );
+ function Test() {
+ const [value, setValue] = useState();
+ return (
+ {
+ setValue(value);
+ onSelect(value, option);
+ }}
+ options={[
+ { id: "1", name: "One" },
+ { id: "2", name: "Two" },
+ { id: "3", name: "Three" },
+ ]}
+ multiline
+ />
+ );
+ }
+ const r = await render();
// Then the getOptions helper returns the correct options
expect(getOptions(r.number)).toEqual(["One", "Two", "Three"]);
// When selecting an option
diff --git a/src/utils/rtl.tsx b/src/utils/rtl.tsx
index 752a1fd8a..ad10738c8 100644
--- a/src/utils/rtl.tsx
+++ b/src/utils/rtl.tsx
@@ -109,25 +109,32 @@ export function rowAnd(r: RenderResult, rowNum: number, testId: string): HTMLEle
"
`);
* */
-export function tableSnapshot(r: RenderResult): string {
+export function tableSnapshot(r: RenderResult, columnNames: string[] = []): string {
const tableEl = r.getByTestId("gridTable");
const dataRows = Array.from(tableEl.querySelectorAll("[data-gridrow]"));
const hasExpandableHeader = !!tableEl.querySelector(`[data-testid="expandableColumn"]`);
- const tableDataAsStrings = dataRows.map((row) => {
+ let tableDataAsStrings = dataRows.map((row) => {
return Array.from(row.childNodes).map(getTextFromTableCellNode);
});
- return toMarkupTableString({ tableRows: tableDataAsStrings, hasExpandableHeader });
+ // If the user wants a subset of columns, look for column names
+ if (columnNames.length > 0) {
+ const headerCells = tableDataAsStrings[0];
+ if (headerCells) {
+ const columnIndices = columnNames.map((name) => {
+ const i = headerCells.indexOf(name);
+ if (i === -1) throw new Error(`Could not find header '${name}' in ${headerCells.join(", ")}`);
+ return i;
+ });
+ tableDataAsStrings = tableDataAsStrings.map((row) => columnIndices.map((index) => row[index]));
+ }
+ }
+
+ return toMarkupTableString(tableDataAsStrings, hasExpandableHeader);
}
-function toMarkupTableString({
- tableRows,
- hasExpandableHeader,
-}: {
- tableRows: (string | null)[][];
- hasExpandableHeader: boolean;
-}) {
+function toMarkupTableString(tableRows: (string | null)[][], hasExpandableHeader: boolean): string {
// Find the largest width of each column to set a consistent width for each row
const columnWidths = tableRows.reduce((acc, row) => {
row.forEach((cell, columnIndex) => {