From 7f6377073911e886442abc8c875403703e6e662b Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:09:54 -0300 Subject: [PATCH 01/10] 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 6fec35eb..4e65d516 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 d502934163ebc3dde160155c096f5c63c2dd87d8 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:11:35 -0300 Subject: [PATCH 02/10] 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 4e65d516..b3508e0d 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 f3a6b3131621986becc976eee830da38ce534d99 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:12:23 -0300 Subject: [PATCH 03/10] 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 b3508e0d..eec13b98 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 e653f63b3d5d212a9f34eafb5722f37e848ce4ff Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:13:37 -0300 Subject: [PATCH 04/10] 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 eec13b98..4f730f8c 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 ad1b87a5593fcbcd177240f38a055fa895e4ee6e Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:14:51 -0300 Subject: [PATCH 05/10] 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 4f730f8c..a853129e 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 4c49bbea3aee465fd6198747673db335d2e96295 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:16:20 -0300 Subject: [PATCH 06/10] 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 a853129e..53d5eae6 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 c2e88e75a8227df3c09c4e92a0641e4f2be5d98d Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:16:50 -0300 Subject: [PATCH 07/10] 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 53d5eae6..d90c9528 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 63b6d15d0bfb4ce1da40c2aa62d85b18c838a44b Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:17:59 -0300 Subject: [PATCH 08/10] 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 d90c9528..89a77adf 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 7ba69e71412c81d1657a5bd4a47cdf229b9fa771 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:19:11 -0300 Subject: [PATCH 09/10] 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 89a77adf..2b32a4fa 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 c734687fd5a9de98a157241c14660a23b6c47315 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 15 Jul 2026 12:20:53 -0300 Subject: [PATCH 10/10] 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 2b32a4fa..5f8bcfb1 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]);