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) => {