Skip to content

fix(company-input-v2): dropdown UX cleanup, clear-icon, canonical-case fixes#246

Merged
smarcet merged 8 commits into
v4.xfrom
fix/company-input-v2-dropdown-ux
May 21, 2026
Merged

fix(company-input-v2): dropdown UX cleanup, clear-icon, canonical-case fixes#246
smarcet merged 8 commits into
v4.xfrom
fix/company-input-v2-dropdown-ux

Conversation

@gcutrini
Copy link
Copy Markdown
Contributor

ref: https://app.clickup.com/t/86b9rnr4r

Summary

Reworks the CompanyInputV2 field UX (after #241 enabled autoSelect on blur).

  • 96abac9 show plain options, pick canonical case on blur. Removes the extra "no match / use typed value" row from the dropdown, and makes blur commit the canonical company when the typed text matches one regardless of case (e.g. typing "tipit" picks "Tipit").

  • 243150c match canonical case post-API when blur beats the response. If the user types and blurs before the search request comes back, the typed text gets accepted as-is. Once the response arrives, if it included a matching company, the field swaps to that company automatically.

  • e3759dc hide clear icon when value is effectively empty. Treats blank values (empty string, value object with no name) as no selection so MUI stops showing the clear (x) icon on hover of a field that looks empty.

gcutrini added 3 commits May 20, 2026 22:02
Drops the synthetic no-match dropdown row and the i18n keys backing it,
restores the explicit 1em font size, and makes autoSelect commit the
real API-matched option when the typed text matches case-insensitively
(typing "tipit" tabs out to "Tipit"). Unrecognized values still
commit as free-text. Adds unit tests for the new helpers.
Normalizes the incoming value (empty string, empty-name object) to null
via a useMemo'd helper so MUI's Autocomplete no longer treats it as a
selection and keeps the clear (x) icon visible on hover of an empty
field. Adds unit tests for normalizeCompanyValue.
…the response

If the user types and blurs faster than the debounced API call, the
free-text value is already committed by the time the response arrives.
The effect now checks for a case-insensitive existing match and replaces
the free-text entry with the canonical option. Adds the isNewCompany
helper and its tests.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad3502cd-cdfb-4015-923b-48c56e0502fb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/company-input-v2-dropdown-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gcutrini gcutrini requested a review from smarcet May 21, 2026 01:27
* http://www.apache.org/licenses/LICENSE-2.0
*/

import {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini
Missing coverage:
- Blur triggers canonical-case swap through the real component (the core PR feature , tested nowhere).
- Post-API response swap when blur precedes the response (the race-condition fix , tested nowhere).
- Clear icon hidden when value is an empty-name object (the third headline fix , tested nowhere).

return undefined;
}

queryRegistrationCompanies(summitId, inputValue, (results) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini
Stale-closure race: late API callback fires onChange after user clears the field
No cleanup in the effect; the in-flight queryRegistrationCompanies callback closes over the old normalizedValue ({id:0, name:"tipit"}), so if the user clears the field before the response arrives, the callback still fires isNewCompany(normalizedValue) as true and calls onChange with the canonical company , overwriting the user's deliberate clear action.

suggested fix
Add a cancellation flag and return a cleanup function:

 React.useEffect(() => {                                                                                                                                                                           
      if (inputValue === "") { ... return undefined; }                                                                                                                                              
                                                                                                                                                                                                    
      let cancelled = false; // ← flag                                                                                                                                                              
                                                                                                                                                                                                    
      queryRegistrationCompanies(summitId, inputValue, (results) => {                                                                                                                               
          if (cancelled) return; // ← bail if effect was re-run or component unmounted                                                                                                              
                                                                                                                                                                                                    
          ...                                                                                                                                                                                       
          if (isNewCompany(normalizedValue)) {                                                                                                                                                      
              const match = findExistingByName(results, normalizedValue.name);                                                                                                                      
              if (match) {                                                                                                                                                                          
                  onChange({ target: { id: name, value: match, ... } });                                                                                                                            
              }                                                                                                                                                                                     
          }                                                                                                                                                                                         
      }, options2Show);                                                                                                                                                                             
                                                                                                                                                                                                    
      return () => { cancelled = true; }; // ← cleanup: runs before next effect or on unmount                                                                                                       
  }, [normalizedValue, inputValue, summitId, options2Show, onChange, name]);

}, options2Show);
return undefined;
}, [value, inputValue, summitId, options2Show]);
}, [normalizedValue, inputValue, summitId, options2Show, onChange, name]);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini if u add the onChange on a deps array this will trigger an infinite loop on the effect
generally speaking most of the clients pass and inline arrow function and that arrow function object is a new one on each re render
so remove the onChange deps to avoid the infinite loop or use a a ref to hold the latest version of the onChange callback

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini please review

gcutrini added 4 commits May 21, 2026 11:08
Latest-ref wrapper that returns a stable-identity callback always invoking
the latest version of the wrapped function. Lets effects call prop
callbacks without listing them as deps. Equivalent to React's still-
experimental useEffectEvent; replace when the native hook stabilizes.
The debounced queryRegistrationCompanies callback closes over the value
of normalizedValue captured at effect run time. Without cleanup, a late
response could fire onChange with the previous typed value after the
user had already cleared the field, clobbering their action.
Consumers commonly pass onChange as an inline arrow function, which gets
a new identity on every parent render. Listing it in the effect's deps
would re-run the effect (and the debounced API call) every render. Wrap
onChange via useEventCallback so the effect can call it safely without
tracking its identity.
…pty-name clear icon

Adds three integration tests through the real component:
- on blur, typing a case-insensitive match commits the canonical option
- when blur beats the API response, the free-text commit is replaced
  with the canonical match once results arrive
- the clear icon is hidden when value is an empty-name object

Pins @testing-library/react@^11 for React 16 compatibility.
@gcutrini
Copy link
Copy Markdown
Contributor Author

@smarcet — pushed the changes:

  • Added a useEventCallback util and used it to wrap the parent's onChange, so the effect no longer re-runs on inline arrow identities; onChange and name are out of the deps array.
  • Cancellation flag + cleanup in the effect so a late API response can no longer overwrite a user's clear/change.
  • 3 integration tests through the real component: blur canonical-case swap, post-API replacement when blur beats the response, clear icon hidden for empty-name value.

Ready for another look.

@gcutrini gcutrini requested a review from smarcet May 21, 2026 14:17
<Typography
variant="body2"
sx={{ fontSize: "1em", color: "text.secondary", padding: "5px 0" }}
>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] renderOption renders blank for string-valued options

option.name is undefined when option is a string. normalizeCompanyValue preserves non-empty strings, so the options array can contain plain string entries (e.g. when the consumer passes value="Tipit"). MUI calls renderOption for those entries and <Typography> renders nothing.

getOptionLabel already handles this correctly — renderOption needs the same guard:

renderOption={(props, option) => {
    const { key, ...optionProps } = props;
    const label = typeof option === "string" ? option : option.name;
    return (
        <li key={key} {...optionProps}>
            <Typography variant="body2" sx={{ fontSize: "1em", color: "text.secondary", padding: "5px 0" }}>
                {label}
            </Typography>
        </li>
    );
}}

const trimmed = tmpValue.trim();
tmpValue = findExistingByName(options, trimmed) || { id: 0, name: trimmed };
}
setOptions(tmpValue ? [tmpValue, ...options] : options);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Blur-committed company is duplicated in the options list

When findExistingByName resolves tmpValue to an existing company, that company is already present in options (it came from the prior API call). Prepending it unconditionally produces a duplicate entry the next time the dropdown opens.

// before: may duplicate an existing entry
setOptions(tmpValue ? [tmpValue, ...options] : options);

// after: filter out the same id before prepending
setOptions(tmpValue
    ? [tmpValue, ...options.filter(o => o?.id !== tmpValue?.id)]
    : options
);

This is visual-only and resolves itself on the next onInputChange, but it is noticeable when the user re-opens the dropdown immediately after blur.

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini please re review

… string options

- The blur path could prepend a canonical company that was already in options, leaving a visible duplicate the next time the dropdown opens. Filter out the same id before prepending.
- renderOption mirrored getOptionLabel's handling of string options incompletely, rendering an empty row when the consumer passed value as a plain string. Resolve the label the same way getOptionLabel does.
@gcutrini gcutrini requested a review from smarcet May 21, 2026 16:43
Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@smarcet smarcet merged commit e3b0d12 into v4.x May 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants