From 294bc61c22e9b8be3383ea760027fbb9a8157217 Mon Sep 17 00:00:00 2001 From: JpMaxMan Date: Mon, 13 Jul 2026 13:59:50 -0500 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20company-input-v2=20=E2=80=94=20expli?= =?UTF-8?q?cit=20selection=20only,=20no=20clear=20icon,=20"Use=20""?= =?UTF-8?q?"=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the wrong-company bug: `autoSelect` committed the currently *highlighted* option on blur, so merely mousing over a suggestion and then tabbing away silently populated the wrong company. - Remove `autoSelect`. Selection is now explicit (click / Enter) only. - New onBlur: tab/click-away commits exactly what was typed — resolved to an existing company only on an exact (case-insensitive) name match, else a free-text { id: 0, name }. Never commits a merely-highlighted option. - `disableClearable`: remove the MUI clear (x) icon; the field commits free text, so an explicit clear affordance isn't wanted (delete text to empty). - Append a synthetic 'Use ""' row so users can explicitly commit their text; skipped when it already matches a listed company. Commits a clean { id: 0, name }, dropping the display-only marker. - Predictive typeahead unchanged (freeSolo + server-side queryRegistrationCompanies). Tests: +5 (blur keeps typed text / never a highlighted option; clear icon never renders even with a real value; "Use" row commits clean free text; no redundant "Use" row on exact match). 25/25 green. --- .../inputs/__tests__/company-input-v2.test.js | 79 +++++++++++++++++++ src/components/inputs/company-input-v2.js | 59 +++++++++++--- 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/components/inputs/__tests__/company-input-v2.test.js b/src/components/inputs/__tests__/company-input-v2.test.js index 705ae59f..348b91ad 100644 --- a/src/components/inputs/__tests__/company-input-v2.test.js +++ b/src/components/inputs/__tests__/company-input-v2.test.js @@ -178,6 +178,31 @@ describe("CompanyInputV2 integration", () => { return { ...utils, getValue: () => setValue }; }; + it("on blur takes the typed text and, when it matches no option, commits it as free text (never a highlighted option)", () => { + // The bug this guards: with autoSelect, mousing over a suggestion and + // tabbing away committed that highlighted company. Now blur must commit + // exactly what was typed. + let resolveQuery; + queryRegistrationCompanies.mockImplementation((_summitId, _input, cb) => { + resolveQuery = cb; + }); + + const onChange = jest.fn(); + renderControlled({ onChange }); + const input = screen.getByRole("combobox"); + + // Type "Tip"; two suggestions come back (either could get highlighted on hover). + fireEvent.change(input, { target: { value: "Tip" } }); + act(() => { resolveQuery([{ id: 1, name: "Tipit" }, { id: 2, name: "Tipco" }]); }); + + // Tab away without picking anything. + fireEvent.blur(input); + + const committed = onChange.mock.calls.map((c) => c[0].target.value).pop(); + // Exactly what was typed, as a free-text entry — not id 1 or id 2. + expect(committed).toEqual({ id: 0, name: "Tip" }); + }); + it("on blur commits the canonical existing match when the typed text matches case-insensitively", () => { // Capture the API callback so we can resolve it manually. let resolveQuery; @@ -250,4 +275,58 @@ describe("CompanyInputV2 integration", () => { // MUI's clear button uses aria-label="Clear". expect(screen.queryByLabelText("Clear")).not.toBeInTheDocument(); }); + + it("never renders the clear icon, even with a real selected company (disableClearable)", () => { + queryRegistrationCompanies.mockImplementation(() => {}); + render( + {}} + /> + ); + expect(screen.queryByLabelText("Clear")).not.toBeInTheDocument(); + }); + + it("offers a 'Use \"\"' option that commits the typed text as a free-text entry", () => { + let resolveQuery; + queryRegistrationCompanies.mockImplementation((_summitId, _input, cb) => { + resolveQuery = cb; + }); + + const onChange = jest.fn(); + renderControlled({ onChange }); + const input = screen.getByRole("combobox"); + + // Type a name with no existing match; the listbox opens with just the + // synthetic "Use ..." row. + fireEvent.change(input, { target: { value: "Acme" } }); + act(() => { resolveQuery([]); }); + + const useOption = screen.getByText('Use "Acme"'); + fireEvent.click(useOption); + + const committed = onChange.mock.calls + .map((c) => c[0].target.value) + .find((v) => v && typeof v === "object" && v.id === 0); + // Committed clean, without the display-only marker. + expect(committed).toEqual({ id: 0, name: "Acme" }); + }); + + it("does not offer the 'Use' row when the typed text already matches an option", () => { + let resolveQuery; + queryRegistrationCompanies.mockImplementation((_summitId, _input, cb) => { + resolveQuery = cb; + }); + + renderControlled({}); + const input = screen.getByRole("combobox"); + + fireEvent.change(input, { target: { value: "Tipit" } }); + act(() => { resolveQuery([{ id: 1, name: "Tipit" }]); }); + + // The real company shows; no redundant "Use "Tipit"" row. + expect(screen.queryByText('Use "Tipit"')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index e08d9586..2b035c4f 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -110,25 +110,51 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v name={name} options={options} autoComplete - autoSelect freeSolo + // No clear (x) icon: the field commits free text, so an explicit clear + // affordance isn't wanted; users empty it by deleting the text. + disableClearable includeInputInList filterSelectedOptions value={normalizedValue} - onBlur={() => { if (onBlur) onBlur(name) }} + // NOTE: `autoSelect` is intentionally NOT set. With it, blurring the field + // committed the currently *highlighted* option — so merely mousing over a + // suggestion and then tabbing away silently populated the wrong company. + // Selection is now explicit (click / Enter) only; see onBlur for the + // "keep what was typed" fallback. + onBlur={() => { + // On blur with no explicit selection, take the typed text as-is: + // resolve to an existing company only on an exact (case-insensitive) + // name match, otherwise commit it as a free-text { id: 0, name }. Never + // commit a merely-highlighted option. Skip when the text already matches + // the committed value (e.g. right after an explicit selection). + const typed = inputValue.trim(); + const currentName = isCompanyObject(normalizedValue) + ? normalizedValue.name + : (typeof normalizedValue === "string" ? normalizedValue : ""); + if (typed && typed.toLowerCase() !== currentName.trim().toLowerCase()) { + fireChange(findExistingByName(options, typed) || { id: 0, name: typed }); + } + if (onBlur) onBlur(name); + }} getOptionLabel={(option) => { if (typeof option === "string") return option; return option.name; }} onChange={(_, newValue) => { let tmpValue = newValue; - // autoSelect commits the raw typed/autofilled string on blur. If the - // string matches an existing company case-insensitively, pick that - // option (so typing "tipit" tabs out to "Tipit"). Otherwise commit - // the typed value as a free-text {id: 0, name} entry. + // freeSolo commits the raw typed string when the user presses Enter + // without picking an option (reason "createOption"). If the string + // matches an existing company case-insensitively, pick that option (so + // "tipit" + Enter resolves to "Tipit"); otherwise commit it as a + // free-text {id: 0, name} entry. if (typeof tmpValue === "string" && tmpValue.trim()) { const trimmed = tmpValue.trim(); tmpValue = findExistingByName(options, trimmed) || { id: 0, name: trimmed }; + } else if (tmpValue && typeof tmpValue === "object" && tmpValue.isFreeTextOption) { + // The synthetic "Use "…"" row: commit a clean free-text entry, + // dropping the display-only marker. + tmpValue = { id: 0, name: tmpValue.name }; } // Prepend the committed value but drop any existing entry with // the same id; otherwise resolving to an existing company would @@ -147,9 +173,19 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v onInputChange={(_, newInputValue) => { setInputValue(newInputValue); }} - // The API already filters server-side; disable MUI's client-side filtering - // so all returned matches stay visible regardless of substring match. - filterOptions={(opts) => opts} + // The API already filters server-side, so all returned matches stay + // visible (no client-side substring filtering). We only *append* a + // synthetic "Use """ row so the user can explicitly commit their + // free text when it isn't already one of the options. + filterOptions={(opts, params) => { + const trimmed = params.inputValue.trim(); + const alreadyListed = trimmed && opts.some( + (o) => (typeof o === "string" ? o : o?.name)?.trim().toLowerCase() === trimmed.toLowerCase() + ); + return trimmed && !alreadyListed + ? [...opts, { id: 0, name: trimmed, isFreeTextOption: true }] + : opts; + }} renderInput={(params) => ( " so it's clearly a + // commit-what-I-typed action, not a matched company. + const label = option?.isFreeTextOption ? `Use "${optionName}"` : optionName; return ( // eslint-disable-next-line react/jsx-props-no-spreading
  • From 1bc112aca45ed91cffe5ffab244959a2ea54edcc Mon Sep 17 00:00:00 2001 From: JpMaxMan Date: Mon, 13 Jul 2026 18:12:12 -0500 Subject: [PATCH 2/6] fix: read DOM value in onBlur so autofill still propagates (preserve #241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to removing autoSelect. autoSelect (added in #241) committed the input's DOM value on blur, which is how it captured iOS-Chrome browser autofill (autofill writes to the DOM without firing onInputChange). The initial onBlur here read React input state, which would be stale for autofill and reintroduce the #241 required-field-validation bug. - onBlur now reads event.target.value (the DOM value), not React state, so typed AND autofilled values propagate on blur — while still committing the field text, never a highlighted option (the autoSelect-on-hover bug fix stands). - Test: commits a browser-autofilled value on blur even when onInputChange never fired. 26/26 green. Needs a Chrome-iOS device re-test of the #241 autofill path before merge. --- .../inputs/__tests__/company-input-v2.test.js | 20 ++++++++++++++++++ src/components/inputs/company-input-v2.js | 21 ++++++++++++------- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/components/inputs/__tests__/company-input-v2.test.js b/src/components/inputs/__tests__/company-input-v2.test.js index 348b91ad..60f69b4e 100644 --- a/src/components/inputs/__tests__/company-input-v2.test.js +++ b/src/components/inputs/__tests__/company-input-v2.test.js @@ -203,6 +203,26 @@ describe("CompanyInputV2 integration", () => { expect(committed).toEqual({ id: 0, name: "Tip" }); }); + it("commits a browser-autofilled value on blur even when it never fired onInputChange (#241 iOS Chrome)", () => { + // Autofill writes straight to the DOM without React's onInputChange, so + // the component's input state stays empty. The blur handler must read + // the DOM value (event.target.value), not React state, or required-field + // validation regresses — the exact bug #241's autoSelect fixed. + queryRegistrationCompanies.mockImplementation(() => {}); + + const onChange = jest.fn(); + renderControlled({ onChange }); + const input = screen.getByRole("combobox"); + + // Simulate autofill: set the DOM value directly (no fireEvent.change -> + // no onInputChange), then blur. + input.value = "Autofilled Co"; + fireEvent.blur(input); + + const committed = onChange.mock.calls.map((c) => c[0].target.value).pop(); + expect(committed).toEqual({ id: 0, name: "Autofilled Co" }); + }); + it("on blur commits the canonical existing match when the typed text matches case-insensitively", () => { // Capture the API callback so we can resolve it manually. let resolveQuery; diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 2b035c4f..6f7e0e9b 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -122,13 +122,20 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // suggestion and then tabbing away silently populated the wrong company. // Selection is now explicit (click / Enter) only; see onBlur for the // "keep what was typed" fallback. - onBlur={() => { - // On blur with no explicit selection, take the typed text as-is: - // resolve to an existing company only on an exact (case-insensitive) - // name match, otherwise commit it as a free-text { id: 0, name }. Never - // commit a merely-highlighted option. Skip when the text already matches - // the committed value (e.g. right after an explicit selection). - const typed = inputValue.trim(); + onBlur={(event) => { + // On blur with no explicit selection, commit the field's value as-is. + // Read the *DOM* value (event.target.value), NOT React input state: + // browser autofill (notably iOS Chrome) can populate the field without + // firing onInputChange, so the React state would be stale. Reading the + // DOM value is what MUI's `autoSelect` did internally — this preserves + // the typed/autofilled-value-on-blur fix (#241) — but we commit the + // field *text*, never a highlighted option, so hovering a suggestion and + // tabbing away no longer selects the wrong company (why autoSelect was + // removed). Resolve to an existing company only on an exact + // (case-insensitive) name match, else free-text { id: 0, name }. Skip + // when the text already matches the committed value (e.g. right after an + // explicit selection). + const typed = (event?.target?.value ?? inputValue).trim(); const currentName = isCompanyObject(normalizedValue) ? normalizedValue.name : (typeof normalizedValue === "string" ? normalizedValue : ""); From 0f79e52639c7a982424996abb465f9979d3054b2 Mon Sep 17 00:00:00 2001 From: JpMaxMan Date: Tue, 14 Jul 2026 07:12:59 -0500 Subject: [PATCH 3/6] fix: clear the committed company when the field is emptied on blur (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With disableClearable there's no (x), so delete-all-text + blur is the only way to clear the field — but onBlur previously fired nothing for empty input, leaving the prior company committed (a required field still read as filled). - onBlur now propagates null when the field is emptied, guarded so an already-cleared field doesn't fire a redundant change. Non-empty exact-match / free-text behavior unchanged. - Tests: clear-on-empty-and-blur; no redundant change on already-empty blur. 28/28 green. --- .../inputs/__tests__/company-input-v2.test.js | 28 +++++++++++++++++++ src/components/inputs/company-input-v2.js | 7 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/components/inputs/__tests__/company-input-v2.test.js b/src/components/inputs/__tests__/company-input-v2.test.js index 60f69b4e..809785de 100644 --- a/src/components/inputs/__tests__/company-input-v2.test.js +++ b/src/components/inputs/__tests__/company-input-v2.test.js @@ -223,6 +223,34 @@ describe("CompanyInputV2 integration", () => { expect(committed).toEqual({ id: 0, name: "Autofilled Co" }); }); + it("clears the committed company when the field is emptied (delete-all-text) and blurred", () => { + // With disableClearable there's no (x), so deleting the text and blurring + // is the only way to clear — the value must propagate as null. + queryRegistrationCompanies.mockImplementation(() => {}); + + const onChange = jest.fn(); + renderControlled({ initialValue: { id: 1, name: "Tipit" }, onChange }); + const input = screen.getByRole("combobox"); + + fireEvent.change(input, { target: { value: "" } }); + fireEvent.blur(input); + + const committed = onChange.mock.calls.map((c) => c[0].target.value).pop(); + expect(committed).toBeNull(); + }); + + it("does not fire a redundant change when an already-empty field is blurred", () => { + queryRegistrationCompanies.mockImplementation(() => {}); + + const onChange = jest.fn(); + renderControlled({ initialValue: null, onChange }); + const input = screen.getByRole("combobox"); + + fireEvent.blur(input); + + expect(onChange).not.toHaveBeenCalled(); + }); + it("on blur commits the canonical existing match when the typed text matches case-insensitively", () => { // Capture the API callback so we can resolve it manually. let resolveQuery; diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 6f7e0e9b..247b1b21 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -139,7 +139,12 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v const currentName = isCompanyObject(normalizedValue) ? normalizedValue.name : (typeof normalizedValue === "string" ? normalizedValue : ""); - if (typed && typed.toLowerCase() !== currentName.trim().toLowerCase()) { + if (!typed) { + // Field emptied (delete-all-text). With disableClearable there's no + // (x), so this is the only way to clear — propagate null. Skip if + // already cleared to avoid a redundant change. + if (normalizedValue) fireChange(null); + } else if (typed.toLowerCase() !== currentName.trim().toLowerCase()) { fireChange(findExistingByName(options, typed) || { id: 0, name: typed }); } if (onBlur) onBlur(name); From 13b4dc928a1f0c4adb1e535b92afa18537aa7a73 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 14 Jul 2026 12:52:41 -0300 Subject: [PATCH 4/6] fix(company-input-v2): move "Use """ row to the top of the dropdown Prepend the synthetic free-text row instead of appending. Puts the user's typed text as the primary action (arrow-down + Enter commits it without scrolling past API suggestions) and matches the intent that they took the trouble to type. --- src/components/inputs/company-input-v2.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 247b1b21..db3fc2b1 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -186,16 +186,19 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v setInputValue(newInputValue); }} // The API already filters server-side, so all returned matches stay - // visible (no client-side substring filtering). We only *append* a - // synthetic "Use """ row so the user can explicitly commit their - // free text when it isn't already one of the options. + // visible (no client-side substring filtering). We *prepend* a synthetic + // "Use """ row so the user can explicitly commit their free text + // when it isn't already listed. First position makes the typed text the + // primary action (arrow-down + Enter commits without scrolling past + // suggestions) and matches the user's intent that they took the trouble + // to type. filterOptions={(opts, params) => { const trimmed = params.inputValue.trim(); const alreadyListed = trimmed && opts.some( (o) => (typeof o === "string" ? o : o?.name)?.trim().toLowerCase() === trimmed.toLowerCase() ); return trimmed && !alreadyListed - ? [...opts, { id: 0, name: trimmed, isFreeTextOption: true }] + ? [{ id: 0, name: trimmed, isFreeTextOption: true }, ...opts] : opts; }} renderInput={(params) => ( From 26695cf45726f074a0e1bdaefac10cf71e3b057b Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 14 Jul 2026 12:54:50 -0300 Subject: [PATCH 5/6] refactor(company-input-v2): extract getOptionName helper getOptionLabel, filterOptions, and renderOption each computed the option-shape-to-name mapping inline. Extracts a single exported helper so the string / company-object / malformed cases live in one place. --- src/components/inputs/company-input-v2.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index db3fc2b1..a522cdc0 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -49,6 +49,16 @@ export const normalizeCompanyValue = (v) => { return null; }; +// Extract the display name from an option. String options come through when +// the consumer passes value as a plain string; object options carry `name`. +// Returns "" for null/undefined/malformed shapes so callers can chain string +// ops without null guards. +export const getOptionName = (option) => { + if (typeof option === "string") return option; + if (isCompanyObject(option)) return option.name; + return ""; +}; + const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, value, error, helperText, onBlur, placeholder, options2Show, disableShrink, ...rest }) => { const [inputValue, setInputValue] = React.useState(""); const [options, setOptions] = React.useState([]); @@ -149,10 +159,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v } if (onBlur) onBlur(name); }} - getOptionLabel={(option) => { - if (typeof option === "string") return option; - return option.name; - }} + getOptionLabel={getOptionName} onChange={(_, newValue) => { let tmpValue = newValue; // freeSolo commits the raw typed string when the user presses Enter @@ -195,7 +202,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v filterOptions={(opts, params) => { const trimmed = params.inputValue.trim(); const alreadyListed = trimmed && opts.some( - (o) => (typeof o === "string" ? o : o?.name)?.trim().toLowerCase() === trimmed.toLowerCase() + (o) => getOptionName(o).trim().toLowerCase() === trimmed.toLowerCase() ); return trimmed && !alreadyListed ? [{ id: 0, name: trimmed, isFreeTextOption: true }, ...opts] @@ -217,10 +224,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v )} renderOption={(props, option) => { const { key, ...optionProps } = props; - // Mirror getOptionLabel: string options come through when the - // consumer passes value as a plain string. Without this guard - // those rows render empty. - const optionName = typeof option === "string" ? option : option?.name; + const optionName = getOptionName(option); // The synthetic free-text row reads Use "" so it's clearly a // commit-what-I-typed action, not a matched company. const label = option?.isFreeTextOption ? `Use "${optionName}"` : optionName; From c279cc7ede2fc54fdf538a060d249c1a7aa3e504 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 14 Jul 2026 12:55:33 -0300 Subject: [PATCH 6/6] refactor(company-input-v2): rename renderOption's local label to displayLabel The local variable shadowed the outer `label` prop, which is confusing to skim. Same-block only, no behavior change. --- src/components/inputs/company-input-v2.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index a522cdc0..4a58c977 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -227,7 +227,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v const optionName = getOptionName(option); // The synthetic free-text row reads Use "" so it's clearly a // commit-what-I-typed action, not a matched company. - const label = option?.isFreeTextOption ? `Use "${optionName}"` : optionName; + const displayLabel = option?.isFreeTextOption ? `Use "${optionName}"` : optionName; return ( // eslint-disable-next-line react/jsx-props-no-spreading
  • @@ -235,7 +235,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v variant="body2" sx={{ fontSize: "1em", color: "text.secondary", padding: "5px 0" }} > - {label} + {displayLabel}
  • );