Skip to content

Commit

Permalink
feat: Initial work for add-new-option on select field (#1068)
Browse files Browse the repository at this point in the history
Co-authored-by: Brandon Dow <[email protected]>
Co-authored-by: JonnCh <[email protected]>
  • Loading branch information
3 people authored Sep 16, 2024
1 parent 8f6bfe7 commit 09a5690
Show file tree
Hide file tree
Showing 4 changed files with 61 additions and 10 deletions.
3 changes: 2 additions & 1 deletion src/inputs/MultiSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<O, V extends Value> extends Exclude<ComboBoxBaseProps<O, V>, "unsetLabel"> {
export interface MultiSelectFieldProps<O, V extends Value>
extends Exclude<ComboBoxBaseProps<O, V>, "unsetLabel" | "addNew"> {
/** Renders `opt` in the dropdown menu, defaults to the `getOptionLabel` prop. */
getOptionMenuLabel?: (opt: O) => string | ReactNode;
getOptionValue: (opt: O) => V;
Expand Down
21 changes: 20 additions & 1 deletion src/inputs/SelectField.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
<TestSelectField
label="Age"
value={undefined}
options={options}
getOptionLabel={(o) => 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;
Expand Down
2 changes: 1 addition & 1 deletion src/inputs/SelectField.tsx
Original file line number Diff line number Diff line change
@@ -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<O, V extends Value>
Expand Down
45 changes: 38 additions & 7 deletions src/inputs/internal/ComboBoxBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import equal from "fast-deep-equal";
/** Base props for either `SelectField` or `MultiSelectField`. */
export interface ComboBoxBaseProps<O, V extends Value> 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. */
Expand Down Expand Up @@ -65,6 +65,8 @@ export interface ComboBoxBaseProps<O, V extends Value> extends BeamFocusableProp
multiline?: boolean;
/* Callback for user searches */
onSearch?: (search: string) => void;
/* Only supported on single Select fields */
onAddNew?: (v: string) => void;
}

/**
Expand Down Expand Up @@ -95,38 +97,53 @@ export function ComboBoxBase<O, V extends Value>(props: ComboBoxBaseProps<O, V>)
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],
);

// 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]);
Expand Down Expand Up @@ -160,7 +177,9 @@ export function ComboBoxBase<O, V extends Value>(props: ComboBoxBaseProps<O, V>)

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) */
Expand Down Expand Up @@ -189,6 +208,13 @@ export function ComboBoxBase<O, V extends Value>(props: ComboBoxBaseProps<O, V>)

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) {
Expand Down Expand Up @@ -442,6 +468,7 @@ export function initializeOptions<O, V extends Value>(
optionsOrLoad: OptionsOrLoad<O>,
getOptionValue: (opt: O) => V,
unsetLabel: string | undefined,
addNew: boolean,
): O[] {
const opts: O[] = [];
if (unsetLabel) {
Expand All @@ -466,11 +493,15 @@ export function initializeOptions<O, V extends Value>(
});
}
}
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 },
Expand Down

0 comments on commit 09a5690

Please sign in to comment.