Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Initial work for add-new-option on select field #1068

Merged
merged 2 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading