Skip to content

Commit b2b3b52

Browse files
authored
add fixed options to typeaheads (#140)
1 parent 49d2e4f commit b2b3b52

7 files changed

Lines changed: 392 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- property `fixedOptions` to `AsyncTypeaheadInput` and `StaticTypeaheadInput` component, to allow the option, to fix certain options and make them unremovable
13+
- property `withFixedOptionsInValue` to `AsyncTypeaheadInput` and `StaticTypeaheadInput` component, to define, if the fixed options are included in the actual value or not, default is `true`
14+
- if yes, the fixed options have to be in the default form value (or defaultSelected for `AsyncTypeaheadInput`) and will always be included in the form value
15+
- if not, the fixed options are not allowed in the default form value (or defaultSelected for `AsyncTypeaheadInput`) and will never be added to the form value
16+
1017
## [3.8.0] - 2025-07-21
1118

1219
### Added

cypress/cypress/component/Typeahead/AsyncTypeaheadInput.cy.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,3 +814,114 @@ it("cannot select already selected option when multiple", () => {
814814
waitLoadingOptions();
815815
cy.get('div[role="presentation"]').should("have.class", "MuiAutocomplete-noOptions");
816816
});
817+
818+
it("works with fixed options included", () => {
819+
const options = generateOptions();
820+
const name = faker.random.alpha(10);
821+
const randomOptions = faker.helpers.arrayElements(options.objectOptions, 5);
822+
823+
const [defaultSelectedAndFixedOption, changedOption] = randomOptions;
824+
825+
cy.mount(
826+
<div className="p-4">
827+
<Form
828+
onSubmit={cy.spy().as("onSubmitSpy")}
829+
defaultValues={{
830+
[name]: [defaultSelectedAndFixedOption.value],
831+
}}
832+
>
833+
<AsyncTypeaheadInput
834+
name={name}
835+
label={name}
836+
defaultSelected={[defaultSelectedAndFixedOption]}
837+
queryFn={async (query: string) => await fetchMock(options.objectOptions, query, false)}
838+
fixedOptions={[defaultSelectedAndFixedOption]}
839+
multiple
840+
withFixedOptionsInValue={true}
841+
/>
842+
<input type="submit" className="mt-4" />
843+
</Form>
844+
</div>,
845+
);
846+
847+
// Check that the default value is set correctly
848+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultSelectedAndFixedOption.label).should("be.visible");
849+
cy.get("input[type=submit]").click({ force: true });
850+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedAndFixedOption.value] });
851+
852+
// Reset the spy's history and call count
853+
cy.get("@onSubmitSpy").invoke("resetHistory");
854+
855+
// Clear the input, select a new option, and check that the new value and fixed option are submitted
856+
selectOption(name, changedOption.label);
857+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultSelectedAndFixedOption.label).should("be.visible");
858+
cy.get("input[type=submit]").click({ force: true });
859+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedAndFixedOption.value, changedOption.value] });
860+
861+
// Reset the spy's history and call count
862+
cy.get("@onSubmitSpy").invoke("resetHistory");
863+
864+
// Clear the input and check that the fixed option is still present
865+
cy.get(`#${name}`).click();
866+
cy.get("button[title=Clear]").click({ force: true });
867+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultSelectedAndFixedOption.label).should("be.visible");
868+
cy.get("input[type=submit]").click({ force: true });
869+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedAndFixedOption.value] });
870+
});
871+
872+
it("works with fixed options excluded", () => {
873+
const options = generateOptions();
874+
const name = faker.random.alpha(10);
875+
const randomOptions = faker.helpers.arrayElements(options.objectOptions, 5);
876+
const {
877+
objectOptions: [defaultFixedOption],
878+
} = generateOptions(1);
879+
880+
const [defaultSelectedOption, changedOption] = randomOptions;
881+
882+
cy.mount(
883+
<div className="p-4">
884+
<Form
885+
onSubmit={cy.spy().as("onSubmitSpy")}
886+
defaultValues={{
887+
[name]: [defaultSelectedOption.value],
888+
}}
889+
>
890+
<AsyncTypeaheadInput
891+
name={name}
892+
label={name}
893+
defaultSelected={[defaultSelectedOption]}
894+
queryFn={async (query: string) => await fetchMock(options.objectOptions, query, false)}
895+
fixedOptions={[defaultFixedOption]}
896+
multiple
897+
withFixedOptionsInValue={false}
898+
/>
899+
<input type="submit" className="mt-4" />
900+
</Form>
901+
</div>,
902+
);
903+
904+
// Check that the default value is set correctly and the fixed option is present
905+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultFixedOption.label).should("be.visible");
906+
cy.get("input[type=submit]").click({ force: true });
907+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedOption.value] });
908+
909+
// Reset the spy's history and call count
910+
cy.get("@onSubmitSpy").invoke("resetHistory");
911+
912+
// Clear the input, select a new option, and check that the new value is also submitted, but the fixed option is still present
913+
selectOption(name, changedOption.label);
914+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultFixedOption.label).should("be.visible");
915+
cy.get("input[type=submit]").click({ force: true });
916+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedOption.value, changedOption.value] });
917+
918+
// Reset the spy's history and call count
919+
cy.get("@onSubmitSpy").invoke("resetHistory");
920+
921+
// Clear the input and check that nothing is submitted, but the fixed option is still present
922+
cy.get(`#${name}`).click();
923+
cy.get("button[title=Clear]").click({ force: true });
924+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultFixedOption.label).should("be.visible");
925+
cy.get("input[type=submit]").click({ force: true });
926+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [] });
927+
});

cypress/cypress/component/Typeahead/StaticTypeaheadInput.cy.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,3 +482,112 @@ it("test grouping options", () => {
482482
cy.get(`#${name}`).type(groupedOptions[COUNT].label);
483483
cy.get('li[role="option"]').contains(groupedOptions[COUNT].label).should("exist");
484484
});
485+
486+
it("works with fixed options included", () => {
487+
const { simpleOptions } = generateOptions();
488+
const name = faker.random.alpha(10);
489+
const randomOptions = faker.helpers.arrayElements(simpleOptions, 5);
490+
491+
const [defaultSelectedAndFixedOption, changedOption] = randomOptions;
492+
493+
cy.mount(
494+
<div className="p-4">
495+
<Form
496+
onSubmit={cy.spy().as("onSubmitSpy")}
497+
defaultValues={{
498+
[name]: [defaultSelectedAndFixedOption],
499+
}}
500+
>
501+
<StaticTypeaheadInput
502+
name={name}
503+
label={name}
504+
options={simpleOptions}
505+
fixedOptions={[defaultSelectedAndFixedOption]}
506+
multiple
507+
withFixedOptionsInValue={true}
508+
/>
509+
<input type="submit" className="mt-4" />
510+
</Form>
511+
</div>,
512+
);
513+
514+
// Check that the default value is set correctly
515+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultSelectedAndFixedOption).should("be.visible");
516+
cy.get("input[type=submit]").click({ force: true });
517+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedAndFixedOption] });
518+
519+
// Reset the spy's history and call count
520+
cy.get("@onSubmitSpy").invoke("resetHistory");
521+
522+
// Clear the input, select a new option, and check that the new value and fixed option are submitted
523+
selectOption(name, changedOption);
524+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultSelectedAndFixedOption).should("be.visible");
525+
cy.get("input[type=submit]").click({ force: true });
526+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedAndFixedOption, changedOption] });
527+
528+
// Reset the spy's history and call count
529+
cy.get("@onSubmitSpy").invoke("resetHistory");
530+
531+
// Clear the input and check that the fixed option is still present
532+
cy.get(`#${name}`).click();
533+
cy.get("button[title=Clear]").click({ force: true });
534+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultSelectedAndFixedOption).should("be.visible");
535+
cy.get("input[type=submit]").click({ force: true });
536+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedAndFixedOption] });
537+
});
538+
539+
it("works with fixed options excluded", () => {
540+
const { simpleOptions } = generateOptions();
541+
const name = faker.random.alpha(10);
542+
const randomOptions = faker.helpers.arrayElements(simpleOptions, 5);
543+
const {
544+
simpleOptions: [defaultFixedOption],
545+
} = generateOptions(1);
546+
547+
const [defaultSelectedOption, changedOption] = randomOptions;
548+
549+
cy.mount(
550+
<div className="p-4">
551+
<Form
552+
onSubmit={cy.spy().as("onSubmitSpy")}
553+
defaultValues={{
554+
[name]: [defaultSelectedOption],
555+
}}
556+
>
557+
<StaticTypeaheadInput
558+
name={name}
559+
label={name}
560+
options={simpleOptions}
561+
fixedOptions={[defaultFixedOption]}
562+
multiple
563+
withFixedOptionsInValue={false}
564+
/>
565+
<input type="submit" className="mt-4" />
566+
</Form>
567+
</div>,
568+
);
569+
570+
// Check that the default value is set correctly and the fixed option is present
571+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultFixedOption).should("be.visible");
572+
cy.get("input[type=submit]").click({ force: true });
573+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedOption] });
574+
575+
// Reset the spy's history and call count
576+
cy.get("@onSubmitSpy").invoke("resetHistory");
577+
578+
// Clear the input, select a new option, and check that the new value is also submitted, but the fixed option is still present
579+
selectOption(name, changedOption);
580+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultFixedOption).should("be.visible");
581+
cy.get("input[type=submit]").click({ force: true });
582+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [defaultSelectedOption, changedOption] });
583+
584+
// Reset the spy's history and call count
585+
cy.get("@onSubmitSpy").invoke("resetHistory");
586+
587+
// Clear the input and check that nothing is submitted, but the fixed option is still present
588+
cy.get(`#${name}`).click();
589+
cy.get("button[title=Clear]").click({ force: true });
590+
cy.get("div.Mui-disabled span.MuiChip-label").should("have.text", defaultFixedOption).should("be.visible");
591+
cy.get("input[type=submit]").click({ force: true });
592+
cy.get("@onSubmitSpy").should("be.calledOnceWith", { [name]: [] });
593+
});

src/lib/AsyncTypeaheadInput.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import { AsyncTypeaheadAutocompleteProps, CommonTypeaheadProps, TypeaheadOption,
55
import Autocomplete from "@mui/material/Autocomplete";
66
import {
77
convertAutoCompleteOptionsToStringArray,
8+
createTagRenderer,
9+
getOptionsFromValue,
810
groupOptions,
911
isDisabledGroup,
1012
renderHighlightedOptionFunction,
13+
resolveInputValue,
14+
validateFixedOptions,
1115
} from "./helpers/typeahead";
1216
import { useDebounceHook } from "./hooks/useDebounceHook";
1317
import { useSafeNameId } from "./hooks/useSafeNameId";
@@ -68,6 +72,8 @@ const AsyncTypeaheadInput = <T extends FieldValues>(props: AsyncTypeaheadInputPr
6872
paginationIcon,
6973
useBootstrapStyle = false,
7074
autocompleteProps,
75+
fixedOptions,
76+
withFixedOptionsInValue = true,
7177
} = props;
7278

7379
const [options, setOptions] = useState<TypeaheadOptions>(defaultOptions);
@@ -78,6 +84,8 @@ const AsyncTypeaheadInput = <T extends FieldValues>(props: AsyncTypeaheadInputPr
7884
const { setDebounceSearch, isLoading } = useDebounceHook(queryFn, setOptions);
7985
const { control, disabled: formDisabled, getFieldState, setValue: setFormValue } = useFormContext();
8086

87+
validateFixedOptions(fixedOptions, multiple, autocompleteProps, withFixedOptionsInValue, value);
88+
8189
const { field } = useController({
8290
name,
8391
control,
@@ -138,7 +146,7 @@ const AsyncTypeaheadInput = <T extends FieldValues>(props: AsyncTypeaheadInputPr
138146
multiple={multiple}
139147
loading={isLoading}
140148
options={paginatedOptions}
141-
value={(multiple ? value : value[0]) || null}
149+
value={resolveInputValue(multiple, fixedOptions, withFixedOptionsInValue, value)}
142150
filterSelectedOptions={autocompleteProps?.filterSelectedOptions ?? multiple}
143151
filterOptions={(currentOptions) => currentOptions}
144152
isOptionEqualToValue={
@@ -174,7 +182,7 @@ const AsyncTypeaheadInput = <T extends FieldValues>(props: AsyncTypeaheadInputPr
174182
onChange={(_e, value) => {
175183
// value is typed as Autocomplete<Value> (aka TypeaheadOption) or an array of Autocomplete<Value> (aka TypeaheadOption[])
176184
// however, the component is not intended to be used with mixed types
177-
const optionsArray = value ? ((Array.isArray(value) ? value : [value]) as TypeaheadOptions) : undefined;
185+
const optionsArray = getOptionsFromValue(value, fixedOptions, withFixedOptionsInValue);
178186
setValue(optionsArray ?? []);
179187
const values = convertAutoCompleteOptionsToStringArray(optionsArray);
180188
const finalValue = multiple ? values : values[0];
@@ -219,6 +227,7 @@ const AsyncTypeaheadInput = <T extends FieldValues>(props: AsyncTypeaheadInputPr
219227
{...params}
220228
/>
221229
)}
230+
renderTags={createTagRenderer(fixedOptions, autocompleteProps)}
222231
/>
223232
</FormGroupLayout>
224233
);

src/lib/StaticTypeaheadInput.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
sortOptionsByGroup,
1313
groupOptions,
1414
renderHighlightedOptionFunction,
15+
combineOptions,
16+
validateFixedOptions,
17+
createTagRenderer,
18+
resolveInputValue,
19+
getOptionsFromValue,
1520
} from "./helpers/typeahead";
1621
import { TypeaheadTextField } from "./components/Typeahead/TypeaheadTextField";
1722
import { FormGroupLayout } from "./FormGroupLayout";
@@ -57,6 +62,8 @@ const StaticTypeaheadInput = <T extends FieldValues>(props: StaticTypeaheadInput
5762
useBootstrapStyle = false,
5863
isLoading = false,
5964
autocompleteProps,
65+
fixedOptions,
66+
withFixedOptionsInValue = true,
6067
} = props;
6168

6269
const [page, setPage] = useState(1);
@@ -84,11 +91,13 @@ const StaticTypeaheadInput = <T extends FieldValues>(props: StaticTypeaheadInput
8491
const value = useMemo(
8592
() =>
8693
multiple
87-
? getMultipleAutoCompleteValue(options, fieldValue as string[] | number[] | undefined)
94+
? getMultipleAutoCompleteValue(combineOptions(options, fixedOptions), fieldValue as string[] | number[] | undefined)
8895
: getSingleAutoCompleteValue(options, fieldValue as string | number | undefined),
89-
[fieldValue, multiple, options],
96+
[fieldValue, multiple, options, fixedOptions],
9097
);
9198

99+
validateFixedOptions(fixedOptions, multiple, autocompleteProps, withFixedOptionsInValue, value);
100+
92101
useEffect(() => {
93102
if (limitResults !== undefined) {
94103
setLoadMoreOptions(page * limitResults < options.length);
@@ -118,7 +127,7 @@ const StaticTypeaheadInput = <T extends FieldValues>(props: StaticTypeaheadInput
118127
((option: TypeaheadOption) => (typeof option === "string" ? option : `${option.label}-${option.value ?? ""}`))
119128
}
120129
disableCloseOnSelect={multiple}
121-
value={(multiple ? value : value[0]) || null}
130+
value={resolveInputValue(multiple, fixedOptions, withFixedOptionsInValue, value)}
122131
getOptionLabel={(option: TypeaheadOption) => (typeof option === "string" ? option : option.label)}
123132
getOptionDisabled={(option) =>
124133
getOptionDisabled?.(option) ||
@@ -143,7 +152,7 @@ const StaticTypeaheadInput = <T extends FieldValues>(props: StaticTypeaheadInput
143152
onChange={(_, value) => {
144153
// value is typed as Autocomplete<Value> (aka TypeaheadOption) or an array of Autocomplete<Value> (aka TypeaheadOption[])
145154
// however, the component is not intended to be used with mixed types
146-
const optionsArray = value ? ((Array.isArray(value) ? value : [value]) as TypeaheadOptions) : undefined;
155+
const optionsArray = getOptionsFromValue(value, fixedOptions, withFixedOptionsInValue);
147156
const values = convertAutoCompleteOptionsToStringArray(optionsArray);
148157
const finalValue = multiple ? values : values[0];
149158
clearErrors(field.name);
@@ -182,6 +191,7 @@ const StaticTypeaheadInput = <T extends FieldValues>(props: StaticTypeaheadInput
182191
{...params}
183192
/>
184193
)}
194+
renderTags={createTagRenderer(fixedOptions, autocompleteProps)}
185195
/>
186196
</FormGroupLayout>
187197
);

0 commit comments

Comments
 (0)