diff --git a/src/inputs/MultiSelectField.tsx b/src/inputs/MultiSelectField.tsx index 9ef88f3c3..9f10344e4 100644 --- a/src/inputs/MultiSelectField.tsx +++ b/src/inputs/MultiSelectField.tsx @@ -3,7 +3,8 @@ import { Value } from "src/inputs"; import { ComboBoxBase, ComboBoxBaseProps } from "src/inputs/internal/ComboBoxBase"; import { HasIdAndName, Optional } from "src/types"; -export interface MultiSelectFieldProps extends Exclude, "unsetLabel"> { +export interface MultiSelectFieldProps + extends Exclude, "unsetLabel" | "addNew"> { /** Renders `opt` in the dropdown menu, defaults to the `getOptionLabel` prop. */ getOptionMenuLabel?: (opt: O) => string | ReactNode; getOptionValue: (opt: O) => V; diff --git a/src/inputs/SelectField.test.tsx b/src/inputs/SelectField.test.tsx index 5429f194f..b369a5477 100644 --- a/src/inputs/SelectField.test.tsx +++ b/src/inputs/SelectField.test.tsx @@ -1,4 +1,4 @@ -import { clickAndWait } from "@homebound/rtl-utils"; +import { clickAndWait, type } from "@homebound/rtl-utils"; import { fireEvent } from "@testing-library/react"; import { useState } from "react"; import { SelectField, SelectFieldProps, Value } from "src/inputs"; @@ -472,6 +472,25 @@ describe("SelectFieldTest", () => { expect(onSelect.mock.calls[2][0]).toBe(undefined); }); + it("allows to add a new option", async () => { + // Given a SelectField + const onAddNew = jest.fn(); + const r = await render( + o.name} + getOptionValue={(o) => o.id} + onAddNew={onAddNew} + />, + ); + // When we select Add New option + select(r.age, "Add New"); + // Then onAddNew was called + expect(onAddNew).toHaveBeenCalledTimes(1); + }); + // Used to validate the `unset` option can be applied to non-`HasIdAndName` options type HasLabelAndValue = { label: string; diff --git a/src/inputs/SelectField.tsx b/src/inputs/SelectField.tsx index 44a6d1807..2c540857b 100644 --- a/src/inputs/SelectField.tsx +++ b/src/inputs/SelectField.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { Value } from "src/inputs"; import { ComboBoxBase, ComboBoxBaseProps, unsetOption } from "src/inputs/internal/ComboBoxBase"; -import { HasIdAndName, HasIdIsh, HasNameIsh, Optional } from "src/types"; +import { HasIdIsh, HasNameIsh, Optional } from "src/types"; import { defaultOptionLabel, defaultOptionValue } from "src/utils/options"; export interface SelectFieldProps diff --git a/src/inputs/internal/ComboBoxBase.tsx b/src/inputs/internal/ComboBoxBase.tsx index e2650be30..396cd79f3 100644 --- a/src/inputs/internal/ComboBoxBase.tsx +++ b/src/inputs/internal/ComboBoxBase.tsx @@ -17,7 +17,7 @@ import equal from "fast-deep-equal"; /** 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; + getOptionMenuLabel?: (opt: O, isUnsetOpt?: boolean, isAddNewOption?: boolean) => string | ReactNode; getOptionValue: (opt: O) => V; getOptionLabel: (opt: O) => string; /** The current value; it can be `undefined`, even if `V` cannot be. */ @@ -65,6 +65,8 @@ export interface ComboBoxBaseProps extends BeamFocusableProp multiline?: boolean; /* Callback for user searches */ onSearch?: (search: string) => void; + /* Only supported on single Select fields */ + onAddNew?: (v: string) => void; } /** @@ -95,26 +97,39 @@ export function ComboBoxBase(props: ComboBoxBaseProps) getOptionMenuLabel: propOptionMenuLabel, fullWidth = fieldProps?.fullWidth ?? false, onSearch, + onAddNew, ...otherProps } = props; const labelStyle = otherProps.labelStyle ?? fieldProps?.labelStyle ?? "above"; // Memoize the callback functions and handle the `unset` option if provided. const getOptionLabel = useCallback( - (o: O) => (unsetLabel && o === unsetOption ? unsetLabel : propOptionLabel(o)), + (o: O) => + unsetLabel && o === unsetOption + ? unsetLabel + : onAddNew && o === addNewOption + ? addNewOption.name + : propOptionLabel(o), // propOptionLabel is basically always a lambda, so don't dep on it // eslint-disable-next-line react-hooks/exhaustive-deps [unsetLabel], ); const getOptionValue = useCallback( - (o: O) => (unsetLabel && o === unsetOption ? (undefined as V) : propOptionValue(o)), + (o: O) => + unsetLabel && o === unsetOption + ? (undefined as V) + : onAddNew && o === addNewOption + ? (addNewOption.id as V) + : propOptionValue(o), // propOptionValue is basically always a lambda, so don't dep on it // eslint-disable-next-line react-hooks/exhaustive-deps [unsetLabel], ); const getOptionMenuLabel = useCallback( (o: O) => - propOptionMenuLabel ? propOptionMenuLabel(o, Boolean(unsetLabel) && o === unsetOption) : getOptionLabel(o), + propOptionMenuLabel + ? propOptionMenuLabel(o, Boolean(unsetLabel) && o === unsetOption, Boolean(onAddNew) && o === addNewOption) + : getOptionLabel(o), // propOptionMenuLabel is basically always a lambda, so don't dep on it // eslint-disable-next-line react-hooks/exhaustive-deps [unsetLabel, getOptionLabel], @@ -122,11 +137,13 @@ export function ComboBoxBase(props: ComboBoxBaseProps) // Call `initializeOptions` to prepend the `unset` option if the `unsetLabel` was provided. const options = useMemo( - () => initializeOptions(propOptions, getOptionValue, unsetLabel), + () => initializeOptions(propOptions, getOptionValue, unsetLabel, !!onAddNew), // 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 - Array.isArray(propOptions) ? [propOptions, unsetLabel] : [propOptions.current, propOptions.options, unsetLabel], + Array.isArray(propOptions) + ? [propOptions, unsetLabel, onAddNew] + : [propOptions.current, propOptions.options, unsetLabel, onAddNew], ); const values = useMemo(() => propValues ?? [], [propValues]); @@ -160,7 +177,9 @@ export function ComboBoxBase(props: ComboBoxBaseProps) const { searchValue } = fieldState; const filteredOptions = useMemo(() => { - return !searchValue ? options : options.filter((o) => contains(getOptionLabel(o), searchValue)); + return !searchValue + ? options + : options.filter((o) => contains(getOptionLabel(o), searchValue) || o === addNewOption); }, [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) */ @@ -189,6 +208,13 @@ export function ComboBoxBase(props: ComboBoxBaseProps) const selectedKeys = [...keys.values()]; const selectedOptions = options.filter((o) => selectedKeys.includes(valueToKey(getOptionValue(o)))); + + if (!multiselect && selectedOptions[0] === addNewOption && onAddNew) { + onAddNew(fieldState.inputValue); + state.close(); + return; + } + selectionChanged && onSelect(selectedKeys.map(keyToValue) as V[], selectedOptions); if (!multiselect) { @@ -442,6 +468,7 @@ export function initializeOptions( optionsOrLoad: OptionsOrLoad, getOptionValue: (opt: O) => V, unsetLabel: string | undefined, + addNew: boolean, ): O[] { const opts: O[] = []; if (unsetLabel) { @@ -466,11 +493,15 @@ export function initializeOptions( }); } } + if (addNew) { + opts.push(addNewOption as unknown as O); + } return opts; } /** A marker option to automatically add an "Unset" option to the start of options. */ export const unsetOption = {}; +export const addNewOption = { id: "new", name: "Add New" }; export function disabledOptionToKeyedTuple( disabledOption: Value | { value: Value; reason: string },