From b50d5085b63189055b4ea42f6e96772102cd72e7 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:27:17 -0300 Subject: [PATCH 01/11] fix(company-input-v2): keep Use row when a committed free-text value exists The Use "" row disappeared after the user committed a free-text entry and re-focused the field, because filterOptions treated its own prior {id:0,name} option as "already listed". Two fixes to filterOptions: - alreadyListed only counts real (id > 0) companies, not free-text. - When params.inputValue is empty (passive refocus with no typing) and the current value is a committed free-text, fall back to its name so the Use row still appears. Adds a regression test for the retype-same-text case. --- .../inputs/__tests__/company-input-v2.test.js | 26 +++++++++++++++++++ src/components/inputs/company-input-v2.js | 15 +++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/components/inputs/__tests__/company-input-v2.test.js b/src/components/inputs/__tests__/company-input-v2.test.js index 809785de..5bec25d3 100644 --- a/src/components/inputs/__tests__/company-input-v2.test.js +++ b/src/components/inputs/__tests__/company-input-v2.test.js @@ -377,4 +377,30 @@ describe("CompanyInputV2 integration", () => { // The real company shows; no redundant "Use "Tipit"" row. expect(screen.queryByText('Use "Tipit"')).not.toBeInTheDocument(); }); + + it("still offers 'Use \"\"' when the same text is already committed as a free-text value", () => { + // Repro: type "ti", blur (commits {id:0,name:"ti"}), refocus, retype + // "ti". Previously the Use row was suppressed because the committed + // free-text option was treated as "already listed" — only *real* (id>0) + // companies should suppress the Use row. + let resolveQuery; + queryRegistrationCompanies.mockImplementation((_summitId, _input, cb) => { + resolveQuery = cb; + }); + + renderControlled({}); + const input = screen.getByRole("combobox"); + + // Round 1: type, get results, blur — commits free-text. + fireEvent.change(input, { target: { value: "ti" } }); + act(() => { resolveQuery([{ id: 1, name: "Tipit" }, { id: 2, name: "Tipco" }]); }); + fireEvent.blur(input); + + // Round 2: clear + retype so MUI treats the input as a real onInputChange. + fireEvent.change(input, { target: { value: "" } }); + fireEvent.change(input, { target: { value: "ti" } }); + act(() => { resolveQuery([{ id: 1, name: "Tipit" }, { id: 2, name: "Tipco" }]); }); + + expect(screen.getByText('Use "ti"')).toBeInTheDocument(); + }); }); diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 4a58c977..463a212f 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -200,9 +200,20 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // suggestions) and matches the user's intent that they took the trouble // to type. filterOptions={(opts, params) => { - const trimmed = params.inputValue.trim(); + // Prefer MUI's active-typing inputValue; fall back to the + // committed free-text value's name so the Use row keeps + // appearing after the user tabs away and re-focuses without + // typing. Without the fallback, `params.inputValue` is empty + // on passive refocus and the Use row would disappear. + let trimmed = params.inputValue.trim(); + if (!trimmed && isNewCompany(normalizedValue)) { + trimmed = normalizedValue.name.trim(); + } + // Only real (id > 0) companies count as "already listed" — a + // previously-committed free-text option ({id: 0}) shouldn't + // suppress a fresh Use row for the same typed text. const alreadyListed = trimmed && opts.some( - (o) => getOptionName(o).trim().toLowerCase() === trimmed.toLowerCase() + (o) => isExistingCompany(o) && o.name.trim().toLowerCase() === trimmed.toLowerCase() ); return trimmed && !alreadyListed ? [{ id: 0, name: trimmed, isFreeTextOption: true }, ...opts] From 9e3c3cae69303a655724f8e00961f45caab46f0a Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:28:51 -0300 Subject: [PATCH 02/11] fix(company-input-v2): purge stale free-text option before API responds After the user commits a free-text value via blur and starts a new query, the previously-committed {id: 0} option lingered in the options list until the debounced API response replaced them. In the meantime a refocus would briefly show the stale entry ("ti" flashing while the user was typing "tip"). The effect now purges any leftover free-text options synchronously the moment normalizedValue or inputValue changes, before firing the request. Adds a regression test that catches the stale entry mid-request. --- .../inputs/__tests__/company-input-v2.test.js | 37 +++++++++++++++++++ src/components/inputs/company-input-v2.js | 10 +++++ 2 files changed, 47 insertions(+) diff --git a/src/components/inputs/__tests__/company-input-v2.test.js b/src/components/inputs/__tests__/company-input-v2.test.js index 5bec25d3..3265f32a 100644 --- a/src/components/inputs/__tests__/company-input-v2.test.js +++ b/src/components/inputs/__tests__/company-input-v2.test.js @@ -378,6 +378,43 @@ describe("CompanyInputV2 integration", () => { expect(screen.queryByText('Use "Tipit"')).not.toBeInTheDocument(); }); + it("purges a stale free-text option when a new value is committed via blur (no flash before API responds)", () => { + // Repro: type "ti", blur → commits {id:0,name:"ti"}. Type "tip", blur + // → commits {id:0,name:"tip"}. On refocus, the stale "ti" briefly + // flashed in the dropdown until the new API response arrived. The + // effect must purge any leftover free-text (id: 0) synchronously the + // moment normalizedValue changes — before the API can respond again. + let resolveQuery; + queryRegistrationCompanies.mockImplementation((_summitId, _input, cb) => { + resolveQuery = cb; + }); + + renderControlled({}); + const input = screen.getByRole("combobox"); + + // Round 1: type "ti", get results, blur → free-text commit. + fireEvent.change(input, { target: { value: "ti" } }); + act(() => { resolveQuery([{ id: 1, name: "Tipit" }, { id: 2, name: "Tipco" }]); }); + fireEvent.blur(input); + + // Round 2: type "tip", get results, blur → new free-text commit. + fireEvent.change(input, { target: { value: "tip" } }); + act(() => { resolveQuery([{ id: 1, name: "Tipit" }, { id: 2, name: "Tipco" }]); }); + fireEvent.blur(input); + + // The 2nd blur triggered a fresh effect run whose API request is + // still pending. Reopen the listbox WITHOUT resolving the new API — + // the purge in the effect must have already removed the stale entry + // synchronously. + fireEvent.mouseDown(input); + + const optionTexts = screen + .queryAllByRole("option") + .map((o) => o.textContent.trim()); + expect(optionTexts).not.toContain("ti"); + expect(optionTexts).not.toContain('Use "ti"'); + }); + it("still offers 'Use \"\"' when the same text is already committed as a free-text value", () => { // Repro: type "ti", blur (commits {id:0,name:"ti"}), refocus, retype // "ti". Previously the Use row was suppressed because the committed diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 463a212f..5b6221a5 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -80,6 +80,16 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v return undefined; } + // Purge stale free-text (id: 0) options from a prior blur/commit before + // the API responds. Prevents a just-committed free-text ("ti") from + // flashing in the dropdown once the user starts a new query ("tip"), + // and stops the "already listed" check in filterOptions from being + // fooled by its own previous entry. + setOptions((prev) => { + const real = prev.filter(isExistingCompany); + return normalizedValue ? [normalizedValue, ...real] : real; + }); + // Guard against the in-flight callback firing after the user clears the // field (or types something else): without this, a late response would // call onChange with the previous typed value and clobber the clear. From de805258397c08048640ec2d4c11b521b8ce4755 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:29:28 -0300 Subject: [PATCH 03/11] refactor(company-input-v2): rename findExistingByName to findExistingCompany MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the isExistingCompany predicate — pair reads more naturally as "predicate + finder for the same shape". Pure rename. --- .../inputs/__tests__/company-input-v2.test.js | 26 +++++++++---------- src/components/inputs/company-input-v2.js | 8 +++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/inputs/__tests__/company-input-v2.test.js b/src/components/inputs/__tests__/company-input-v2.test.js index 3265f32a..c1370c93 100644 --- a/src/components/inputs/__tests__/company-input-v2.test.js +++ b/src/components/inputs/__tests__/company-input-v2.test.js @@ -16,7 +16,7 @@ import CompanyInputV2, { isCompanyObject, isExistingCompany, isNewCompany, - findExistingByName, + findExistingCompany, normalizeCompanyValue } from "../company-input-v2"; @@ -85,7 +85,7 @@ describe("isNewCompany", () => { }); }); -describe("findExistingByName", () => { +describe("findExistingCompany", () => { const existing = [ { id: 1, name: "Tipit" }, { id: 2, name: "Tipco" }, @@ -93,20 +93,20 @@ describe("findExistingByName", () => { ]; it("returns the matching existing company when name matches case-insensitively", () => { - expect(findExistingByName(existing, "tipit")).toEqual({ id: 1, name: "Tipit" }); - expect(findExistingByName(existing, "TIPCO")).toEqual({ id: 2, name: "Tipco" }); - expect(findExistingByName(existing, " acme corp ")).toEqual({ id: 3, name: "ACME Corp" }); + expect(findExistingCompany(existing, "tipit")).toEqual({ id: 1, name: "Tipit" }); + expect(findExistingCompany(existing, "TIPCO")).toEqual({ id: 2, name: "Tipco" }); + expect(findExistingCompany(existing, " acme corp ")).toEqual({ id: 3, name: "ACME Corp" }); }); it("returns null when no existing company matches", () => { - expect(findExistingByName(existing, "Nonexistent")).toBeNull(); + expect(findExistingCompany(existing, "Nonexistent")).toBeNull(); }); it("returns null for empty/missing inputs", () => { - expect(findExistingByName(existing, "")).toBeNull(); - expect(findExistingByName(existing, " ")).toBeNull(); - expect(findExistingByName(existing, undefined)).toBeNull(); - expect(findExistingByName(null, "Tipit")).toBeNull(); + expect(findExistingCompany(existing, "")).toBeNull(); + expect(findExistingCompany(existing, " ")).toBeNull(); + expect(findExistingCompany(existing, undefined)).toBeNull(); + expect(findExistingCompany(null, "Tipit")).toBeNull(); }); it("ignores free-text entries (id === 0) when searching", () => { @@ -115,12 +115,12 @@ describe("findExistingByName", () => { { id: 1, name: "Tipit" } // real company ]; // Should pick the real one even though the free-text comes first - expect(findExistingByName(mixed, "tipit")).toEqual({ id: 1, name: "Tipit" }); + expect(findExistingCompany(mixed, "tipit")).toEqual({ id: 1, name: "Tipit" }); }); it("returns null when only free-text entries are present", () => { const freeTextOnly = [{ id: 0, name: "Acme" }]; - expect(findExistingByName(freeTextOnly, "Acme")).toBeNull(); + expect(findExistingCompany(freeTextOnly, "Acme")).toBeNull(); }); }); @@ -268,7 +268,7 @@ describe("CompanyInputV2 integration", () => { act(() => { resolveQuery([{ id: 1, name: "Tipit" }]); }); // Blur: autoSelect commits the typed string; our onChange handler maps - // it to the canonical option via findExistingByName. + // it to the canonical option via findExistingCompany. fireEvent.blur(input); // Find the call where the canonical value landed. diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 5b6221a5..2d0bc4b6 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -30,7 +30,7 @@ export const isNewCompany = (o) => isCompanyObject(o) && o.id === 0 && !!o.name. // Find an existing company in `candidates` whose name matches `name` // case-insensitively. Returns null if `name` is empty or no match found. -export const findExistingByName = (candidates, name) => { +export const findExistingCompany = (candidates, name) => { const trimmed = name?.trim().toLowerCase(); if (!trimmed) return null; return (candidates || []).find( @@ -114,7 +114,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // there is a case-insensitive existing match, replace the free-text // value with the canonical option. if (isNewCompany(normalizedValue)) { - const match = findExistingByName(results, normalizedValue.name); + const match = findExistingCompany(results, normalizedValue.name); if (match) { fireChange(match); } @@ -165,7 +165,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // 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 }); + fireChange(findExistingCompany(options, typed) || { id: 0, name: typed }); } if (onBlur) onBlur(name); }} @@ -179,7 +179,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // free-text {id: 0, name} entry. if (typeof tmpValue === "string" && tmpValue.trim()) { const trimmed = tmpValue.trim(); - tmpValue = findExistingByName(options, trimmed) || { id: 0, name: trimmed }; + tmpValue = findExistingCompany(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. From aae12d46fa0ec3aec74b5a4335e0fe8439bf5088 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:31:38 -0300 Subject: [PATCH 04/11] refactor(company-input-v2): extract namesMatch helper The X.trim().toLowerCase() comparison for company names appeared in three places (findExistingCompany, filterOptions, onBlur). Extracting a single namesMatch helper removes the duplication and gives the intent a name. --- src/components/inputs/company-input-v2.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 2d0bc4b6..0b9ca6ce 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -17,6 +17,11 @@ import { TextField, Autocomplete, Typography } from "@mui/material"; import { queryRegistrationCompanies } from "../../utils/query-actions"; import useEventCallback from "../../utils/use-event-callback"; +// Case-insensitive, whitespace-tolerant name comparison. Used everywhere +// we treat two names as referring to the same company. +export const namesMatch = (a, b) => + (a || "").trim().toLowerCase() === (b || "").trim().toLowerCase(); + // Any well-formed company object (has a name string). export const isCompanyObject = (o) => !!o && typeof o === "object" && typeof o.name === "string"; @@ -31,10 +36,9 @@ export const isNewCompany = (o) => isCompanyObject(o) && o.id === 0 && !!o.name. // Find an existing company in `candidates` whose name matches `name` // case-insensitively. Returns null if `name` is empty or no match found. export const findExistingCompany = (candidates, name) => { - const trimmed = name?.trim().toLowerCase(); - if (!trimmed) return null; + if (!name?.trim()) return null; return (candidates || []).find( - (c) => isExistingCompany(c) && c.name.toLowerCase() === trimmed + (c) => isExistingCompany(c) && namesMatch(c.name, name) ) || null; }; @@ -164,7 +168,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // (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()) { + } else if (!namesMatch(typed, currentName)) { fireChange(findExistingCompany(options, typed) || { id: 0, name: typed }); } if (onBlur) onBlur(name); @@ -223,7 +227,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // previously-committed free-text option ({id: 0}) shouldn't // suppress a fresh Use row for the same typed text. const alreadyListed = trimmed && opts.some( - (o) => isExistingCompany(o) && o.name.trim().toLowerCase() === trimmed.toLowerCase() + (o) => isExistingCompany(o) && namesMatch(o.name, trimmed) ); return trimmed && !alreadyListed ? [{ id: 0, name: trimmed, isFreeTextOption: true }, ...opts] From 26522198716b84301ad67df343a6cbb63db097fb Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:34:49 -0300 Subject: [PATCH 05/11] refactor(company-input-v2): extract shouldOfferUseRow helper Moves the Use-row eligibility predicate out of the filterOptions callback for unit-testability. filterOptions now composes cleanly with the (later) getUseRowText helper. Adds four unit tests, including the id:0-doesnt-suppress-Use case that pins the recently-fixed regression. --- .../inputs/__tests__/company-input-v2.test.js | 23 ++++++++++++++++++- src/components/inputs/company-input-v2.js | 19 +++++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/components/inputs/__tests__/company-input-v2.test.js b/src/components/inputs/__tests__/company-input-v2.test.js index c1370c93..2f5ab3e7 100644 --- a/src/components/inputs/__tests__/company-input-v2.test.js +++ b/src/components/inputs/__tests__/company-input-v2.test.js @@ -17,7 +17,8 @@ import CompanyInputV2, { isExistingCompany, isNewCompany, findExistingCompany, - normalizeCompanyValue + normalizeCompanyValue, + shouldOfferUseRow } from "../company-input-v2"; // Mock the API helper so tests can drive the callback synchronously. @@ -124,6 +125,26 @@ describe("findExistingCompany", () => { }); }); +describe("shouldOfferUseRow", () => { + it("returns false when the typed text is empty", () => { + expect(shouldOfferUseRow("", [{ id: 1, name: "Tipit" }])).toBe(false); + }); + it("returns true when no real company matches the typed text", () => { + expect(shouldOfferUseRow("Acme", [])).toBe(true); + expect(shouldOfferUseRow("Acme", [{ id: 1, name: "Tipit" }])).toBe(true); + }); + it("returns false when a real company matches the typed text case-insensitively", () => { + expect(shouldOfferUseRow("tipit", [{ id: 1, name: "Tipit" }])).toBe(false); + expect(shouldOfferUseRow("TIPIT", [{ id: 1, name: "Tipit" }])).toBe(false); + }); + it("still returns true even if a previously-committed free-text option matches the typed text (id: 0 doesn't suppress the Use row)", () => { + // Pins the bug where the Use row disappeared after the user committed + // free-text via blur and then refocused with the same text. + expect(shouldOfferUseRow("ti", [{ id: 0, name: "ti" }])).toBe(true); + expect(shouldOfferUseRow("ti", [{ id: 0, name: "ti" }, { id: 1, name: "Tipit" }])).toBe(true); + }); +}); + describe("normalizeCompanyValue", () => { it("returns the value unchanged when it has a real name", () => { const v = { id: 1, name: "Tipit" }; diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 0b9ca6ce..215258ad 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -63,6 +63,17 @@ export const getOptionName = (option) => { return ""; }; +// Should the synthetic Use "" row be prepended to the dropdown? +// Only *real* (id > 0) companies count as "already listed" — a previously- +// committed free-text option ({id: 0}) shouldn't suppress a fresh Use row +// for the same typed text. +export const shouldOfferUseRow = (trimmed, opts) => { + if (!trimmed) return false; + return !opts.some( + (o) => isExistingCompany(o) && namesMatch(o.name, trimmed) + ); +}; + 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([]); @@ -223,13 +234,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v if (!trimmed && isNewCompany(normalizedValue)) { trimmed = normalizedValue.name.trim(); } - // Only real (id > 0) companies count as "already listed" — a - // previously-committed free-text option ({id: 0}) shouldn't - // suppress a fresh Use row for the same typed text. - const alreadyListed = trimmed && opts.some( - (o) => isExistingCompany(o) && namesMatch(o.name, trimmed) - ); - return trimmed && !alreadyListed + return shouldOfferUseRow(trimmed, opts) ? [{ id: 0, name: trimmed, isFreeTextOption: true }, ...opts] : opts; }} From b185747fbb545b94d97e3039554ea81c5287c35e Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:38:09 -0300 Subject: [PATCH 06/11] refactor(company-input-v2): extract resolveTypedCompany helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedupes the findExistingCompany(opts, X) || { id: 0, name: X } pattern that appeared in both onBlur and onChange. Both sites are answering the same question — turn a typed string into either the canonical existing company or a fresh free-text entry — and should agree on the answer. --- src/components/inputs/company-input-v2.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 215258ad..45c0f72d 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -74,6 +74,13 @@ export const shouldOfferUseRow = (trimmed, opts) => { ); }; +// Resolve the user's typed string to either the canonical existing company +// (case-insensitive match against `opts`) or a fresh free-text entry +// ({id: 0, name}). Used by onBlur and onChange when the user commits raw +// text — same intent, both sites should agree on what the value becomes. +export const resolveTypedCompany = (opts, typed) => + findExistingCompany(opts, typed) || { id: 0, name: typed.trim() }; + 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([]); @@ -180,7 +187,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // already cleared to avoid a redundant change. if (normalizedValue) fireChange(null); } else if (!namesMatch(typed, currentName)) { - fireChange(findExistingCompany(options, typed) || { id: 0, name: typed }); + fireChange(resolveTypedCompany(options, typed)); } if (onBlur) onBlur(name); }} @@ -193,8 +200,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // "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 = findExistingCompany(options, trimmed) || { id: 0, name: trimmed }; + tmpValue = resolveTypedCompany(options, tmpValue); } else if (tmpValue && typeof tmpValue === "object" && tmpValue.isFreeTextOption) { // The synthetic "Use "…"" row: commit a clean free-text entry, // dropping the display-only marker. From 5d4b1e6d32119c7ef17a57d88ae1a79c50340aa5 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:38:44 -0300 Subject: [PATCH 07/11] refactor(company-input-v2): simplify onBlur currentName via getOptionName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ternary derivation exactly duplicates what getOptionName already does (handles string, company object, other → ""). --- src/components/inputs/company-input-v2.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 45c0f72d..48a5b285 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -178,9 +178,7 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // 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 : ""); + const currentName = getOptionName(normalizedValue); 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 From 7d87d8aecf55ec7bb3439025af0c751976e8fb5f Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:39:46 -0300 Subject: [PATCH 08/11] refactor(company-input-v2): extract getUseRowText helper Formalises the two-source resolution (active typing + committed free-text fallback) that filterOptions already needed. Reduces filterOptions to its two-line essence. --- src/components/inputs/company-input-v2.js | 25 +++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 48a5b285..3f7b8d80 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -81,6 +81,17 @@ export const shouldOfferUseRow = (trimmed, opts) => { export const resolveTypedCompany = (opts, typed) => findExistingCompany(opts, typed) || { id: 0, name: typed.trim() }; +// Text the synthetic Use row should reflect. Prefers what the user is +// actively typing (MUI's params.inputValue), falling back to the committed +// free-text value's name so the Use row survives a passive refocus (tab +// away, click back in without typing) — in that state params.inputValue +// is empty even though the field still displays the committed text. +export const getUseRowText = (params, normalizedValue) => { + const typed = params.inputValue.trim(); + if (typed) return typed; + return isNewCompany(normalizedValue) ? normalizedValue.name.trim() : ""; +}; + 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([]); @@ -229,17 +240,9 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v // suggestions) and matches the user's intent that they took the trouble // to type. filterOptions={(opts, params) => { - // Prefer MUI's active-typing inputValue; fall back to the - // committed free-text value's name so the Use row keeps - // appearing after the user tabs away and re-focuses without - // typing. Without the fallback, `params.inputValue` is empty - // on passive refocus and the Use row would disappear. - let trimmed = params.inputValue.trim(); - if (!trimmed && isNewCompany(normalizedValue)) { - trimmed = normalizedValue.name.trim(); - } - return shouldOfferUseRow(trimmed, opts) - ? [{ id: 0, name: trimmed, isFreeTextOption: true }, ...opts] + const text = getUseRowText(params, normalizedValue); + return shouldOfferUseRow(text, opts) + ? [{ id: 0, name: text, isFreeTextOption: true }, ...opts] : opts; }} renderInput={(params) => ( From 064add41bd4569c4865d3b03aa4e2cbd5a1fdffa Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:41:02 -0300 Subject: [PATCH 09/11] refactor(company-input-v2): extract resolveCommittedCompany and use fireChange in onChange The onChange handler's three-branch normalization (string, synthetic Use row, pass-through) is now a single helper call. The handler also uses the existing fireChange helper instead of hand-building the onChange envelope; matches the rest of the component and removes the onChange prop/handler name shadow. --- src/components/inputs/company-input-v2.js | 40 ++++++++++------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 3f7b8d80..0f871215 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -92,6 +92,20 @@ export const getUseRowText = (params, normalizedValue) => { return isNewCompany(normalizedValue) ? normalizedValue.name.trim() : ""; }; +// Resolve whatever MUI hands us in onChange into a canonical Company entry. +// - String (freeSolo Enter with raw text) → existing match, else free-text +// - Synthetic Use row (isFreeTextOption) → clean free-text (marker stripped) +// - Anything else (picked option, null) → passed through unchanged +export const resolveCommittedCompany = (input, opts) => { + if (typeof input === "string" && input.trim()) { + return resolveTypedCompany(opts, input); + } + if (input?.isFreeTextOption) { + return { id: 0, name: input.name }; + } + return input; +}; + 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([]); @@ -202,32 +216,14 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v }} getOptionLabel={getOptionName} onChange={(_, newValue) => { - let tmpValue = newValue; - // 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()) { - tmpValue = resolveTypedCompany(options, tmpValue); - } 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 }; - } + const nextValue = resolveCommittedCompany(newValue, options); // Prepend the committed value but drop any existing entry with // the same id; otherwise resolving to an existing company would // produce a duplicate row when the dropdown next opens. - setOptions(tmpValue - ? [tmpValue, ...options.filter((o) => o?.id !== tmpValue?.id)] + setOptions(nextValue + ? [nextValue, ...options.filter((o) => o?.id !== nextValue?.id)] : options); - onChange({ - target: { - id: name, - value: tmpValue, - type: "companyinput" - } - }); + fireChange(nextValue); }} onInputChange={(_, newInputValue) => { setInputValue(newInputValue); From 701f30957f459514058695b41baf37448559920b Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:41:46 -0300 Subject: [PATCH 10/11] refactor(company-input-v2): extract findCanonicalUpgrade and simplify effect The effect callback now reads as three coherent steps: reset options, try to upgrade a stale free-text to its canonical version, done. The "is this a free-text? then look up the canonical" logic is now a named helper (findCanonicalUpgrade) instead of an inline conditional. Also collapses the six-line newOptions builder into one composed array. --- src/components/inputs/company-input-v2.js | 37 ++++++++++------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 0f871215..41d53fdf 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -106,6 +106,14 @@ export const resolveCommittedCompany = (input, opts) => { return input; }; +// After the API responds, if the user's already-committed free-text has a +// canonical match in the results, return that so the value can be upgraded +// to the existing company. Returns null when there's nothing to upgrade. +export const findCanonicalUpgrade = (value, results) => { + if (!isNewCompany(value)) return null; + return findExistingCompany(results, value.name); +}; + 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([]); @@ -143,29 +151,16 @@ const CompanyInputV2 = ({ summitId, isRequired, sx, onChange, id, name, label, v let cancelled = false; queryRegistrationCompanies(summitId, inputValue, (results) => { if (cancelled) return; - - let newOptions = []; - - if (normalizedValue) { - newOptions = [normalizedValue]; - } - - if (results) { - newOptions = [...newOptions, ...results]; - } - - setOptions(newOptions); - + setOptions([ + ...(normalizedValue ? [normalizedValue] : []), + ...(results || []) + ]); // If the user typed and blurred faster than the API responded, the // free-text commit already happened. Once the response arrives, if - // there is a case-insensitive existing match, replace the free-text - // value with the canonical option. - if (isNewCompany(normalizedValue)) { - const match = findExistingCompany(results, normalizedValue.name); - if (match) { - fireChange(match); - } - } + // there is a case-insensitive existing match, upgrade the value to + // the canonical option. + const upgrade = findCanonicalUpgrade(normalizedValue, results); + if (upgrade) fireChange(upgrade); }, options2Show); return () => { cancelled = true; }; }, [normalizedValue, inputValue, summitId, options2Show, fireChange]); From 3cbf01da9f876c3cd90246806f65b81ee4da4cd0 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 13:08:19 -0300 Subject: [PATCH 11/11] refactor(company-input-v2): address CodeRabbit review comments Two small hardening changes: - `resolveCommittedCompany` now normalises blank/whitespace-only strings to `null` instead of returning the raw string. Guarantees fireChange only ever receives a Company or null. - The flash-prevention test now asserts the listbox is open and the new Use "tip" row is present before checking that the stale "ti" is absent. Prevents the absence assertions from passing vacuously if the popup ever fails to reopen. --- src/components/inputs/__tests__/company-input-v2.test.js | 6 ++++++ src/components/inputs/company-input-v2.js | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/inputs/__tests__/company-input-v2.test.js b/src/components/inputs/__tests__/company-input-v2.test.js index 2f5ab3e7..350c99a2 100644 --- a/src/components/inputs/__tests__/company-input-v2.test.js +++ b/src/components/inputs/__tests__/company-input-v2.test.js @@ -429,6 +429,12 @@ describe("CompanyInputV2 integration", () => { // synchronously. fireEvent.mouseDown(input); + // Guard against the whole test passing vacuously if the popup never + // reopens — queryAllByRole("option") would return [] and both + // absence assertions below would trivially pass. + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.getByText('Use "tip"')).toBeInTheDocument(); + const optionTexts = screen .queryAllByRole("option") .map((o) => o.textContent.trim()); diff --git a/src/components/inputs/company-input-v2.js b/src/components/inputs/company-input-v2.js index 41d53fdf..f8b20159 100644 --- a/src/components/inputs/company-input-v2.js +++ b/src/components/inputs/company-input-v2.js @@ -97,8 +97,10 @@ export const getUseRowText = (params, normalizedValue) => { // - Synthetic Use row (isFreeTextOption) → clean free-text (marker stripped) // - Anything else (picked option, null) → passed through unchanged export const resolveCommittedCompany = (input, opts) => { - if (typeof input === "string" && input.trim()) { - return resolveTypedCompany(opts, input); + if (typeof input === "string") { + // Blank / whitespace-only strings normalise to null so consumers never + // receive a raw string in place of a Company | null value. + return input.trim() ? resolveTypedCompany(opts, input) : null; } if (input?.isFreeTextOption) { return { id: 0, name: input.name };