Skip to content

feat(gitlab): add server-side search to project picker - #8553

Open
MathiasMonstrey wants to merge 1 commit into
Flagsmith:mainfrom
MathiasMonstrey:fix/gitlab-project-search
Open

MathiasMonstrey wants to merge 1 commit into
Flagsmith:mainfrom
MathiasMonstrey:fix/gitlab-project-search

Conversation

@MathiasMonstrey

@MathiasMonstrey MathiasMonstrey commented Sep 18, 2026

Copy link
Copy Markdown

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.
  • I have added information to docs/ if required so people know about the feature.
  • I have filled in the "Changes" section below.
  • I have filled in the "How did you test this code" section below.

Changes

Closes #8552

The GitLab project picker fetched the first 100 projects once and filtered them client-side, so projects beyond that page were unreachable. Search is now performed by GitLab.

Backend

  • fetch_gitlab_projects takes an optional search_text and forwards it as GitLab's search param. A blank value omits the param, matching search_gitlab_issues and search_gitlab_merge_requests.
  • ProjectSearchQueryParamsSerializer validates search_text on BrowseGitLabProjects.

Frontend

  • GitLabProjectSelect drives a debounced server-side search via useInfiniteScroll. filterOption disables react-select's client-side filtering so server results are not filtered twice.
  • useInfiniteScroll now exposes isError, so consumers do not have to reach into the raw RTK response.
  • On fetch failure the select is no longer isDisabled; the error surfaces through noOptionsMessage instead, keeping the input usable so the user can retry by typing.
  • The selected option is held in state rather than looked up in the result set, which now changes with each search. Otherwise the selected project's label blanked out after a subsequent search while its issues were still listed.

docs/ change is the auto-regenerated structlog events catalogue (a line-number shift from the added import), produced by the generate-docs pre-commit hook.

Paging is intentionally not wired up: server-side search narrows the list, so the picker shows the first page of matches.

How did you test this code?

Unit tests added:

  • test_fetch_gitlab_projects__with_search_text__sends_search_param
  • test_fetch_gitlab_projects__without_search_text__omits_search_param (uses strict_match=True to prove search is absent)
  • test_gitlab_project_list__with_search_text__forwards_search_to_gitlab

Run locally from api/:

  • make test opts='tests/unit/integrations/gitlab -n0': 114 passed, 2 failed (2 tests failed locally that had nothing to do with the changes, and where DNS related)
  • make typecheck: pass
  • make lint: pass

The 2 failures are test_configuration.py::test_update_configuration__valid_data__persists_and_masks_token, which also fail on a clean main and are unrelated to this change. Cause: the fixture URL https://gitlab.updated.com is a real registrable domain that resolves to 0.0.0.0 on filtering/ad-blocking DNS resolvers. ipaddress classifies that as private, so NoSSRFURLField's validate_no_internal_address rejects it with a 400. The sibling tests pass only because gitlab.example.com and gitlab.other.com do not resolve at all, and is_internal_address returns False for unresolvable hosts. Switching that fixture to a reserved domain such as gitlab.updated.example.com would fix it; I left out of this PR as unrelated and maybe I am the only one with that specific problem 😅

Frontend npm run typecheck and npx eslint are clean on the changed files.

Manual: selected a project, typed a different search term, and confirmed the selected label persists and the dropdown shows a loading state while the search is in flight.

Disclaimer: I did use claude code to help me with this PR, because I am not familiar enough with the code of this project yet. It was mainly used for discussing and reviewing the changes.

Thread a `search_text` query param from the project picker through to
GitLab's `GET /projects?search=`, replacing the previous fetch-100-and-
filter-client-side behaviour.

- `fetch_gitlab_projects` accepts optional `search_text`, omitting the
  `search` param when blank
- `ProjectSearchQueryParamsSerializer` validates the param on
  `BrowseGitLabProjects`
- `GitLabProjectSelect` drives a debounced server-side search via
  `useInfiniteScroll`, with `filterOption` disabling react-select's
  client-side filtering
- `useInfiniteScroll` exposes `isError`
- Replace the select's `isDisabled` on fetch failure with
  `noOptionsMessage`, keeping the input usable for retry-by-search
- Hold the selected option rather than deriving it from the mutating
  result set, so the label survives a subsequent search

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MathiasMonstrey
MathiasMonstrey requested review from a team as code owners September 18, 2026 13:21
@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown

@MathiasMonstrey is attempting to deploy a commit to the Flagsmith Team on Vercel.

A member of the Team first needs to authorize it.

@MathiasMonstrey
MathiasMonstrey requested review from emyller and kyle-ssg and removed request for a team September 18, 2026 13:21
@github-actions github-actions Bot added front-end Issue related to the React Front End Dashboard api Issue related to the REST API docs Documentation updates labels Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The backend now accepts optional search_text, validates it, and sends it to GitLab. The frontend sends selector input as search_text, loads project pages through infinite scrolling, and exposes loading and error states. The selector now uses full project options and reports distinct loading, error, and empty-result states. Tests cover request parameters and view forwarding.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to c935c

Closing or selecting in the project picker can replace typed search results with the unfiltered list. The localized callback fix should be applied before merge.

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: cded3326-f122-4661-b40a-de75605ce6f5

📥 Commits

Reviewing files that changed from the base of the PR and between 49d957a and c935c85.

📒 Files selected for processing (10)
  • api/integrations/gitlab/client/api.py
  • api/integrations/gitlab/serializers.py
  • api/integrations/gitlab/views/browse_gitlab.py
  • api/tests/unit/integrations/gitlab/test_client.py
  • api/tests/unit/integrations/gitlab/test_proxy_views.py
  • docs/docs/deployment-self-hosting/observability/_events-catalogue.md
  • frontend/common/services/useGitlab.ts
  • frontend/common/useInfiniteScroll.ts
  • frontend/web/components/GitLabLinkSection.tsx
  • frontend/web/components/GitLabProjectSelect.tsx

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

import GitLabProjectSelect from './GitLabProjectSelect'
import GitLabProjectSelect, {
type GitLabProjectOption,
} from './GitLabProjectSelect'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the components/ import path.

Replace the relative GitLabProjectSelect import with its components/ path. The changed import violates the rule: “Use only common/, components/, and project/ import paths; do not use relative imports.”

Source: Coding guidelines

placeholder={isBusy ? 'Loading...' : 'Select GitLab Project'}
value={value}
onChange={(v: GitLabProjectOption) => onChange(v)}
onInputChange={(e: string) => onInputChange(e)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,130p' frontend/web/components/GitLabProjectSelect.tsx
sed -n '35,125p' frontend/web/components/GitLabLinkSection.tsx
sed -n '1,130p' frontend/common/useInfiniteScroll.ts
rg -n 'onInputChange|input-blur|menu-close|set-value' frontend/node_modules/react-select 2>/dev/null | head -100
cat frontend/package.json | grep -n -A2 -B2 '"react-select"'

Repository: Flagsmith/flagsmith

Length of output: 7333


🌐 Web query:

react-select 5.10.2 source Select.tsx onInputChange input-blur menu-close set-value

💡 Result:

<search_synthesis>
In react-select 5.10.2, the Select component handles input interactions through a series of actions that trigger the onInputChange prop [1][2]. These actions—specifically input-blur, menu-close, and set-value—are emitted by the internal Select.tsx logic to signal state changes in the input field [1]. Key behavioral details for these actions: 1. input-blur: This action is triggered when the input element loses focus [1]. By default, the component cleans up the input state, which often results in the search text being cleared [1][3]. 2. menu-close: This action is triggered when the dropdown menu closes [1]. Like input-blur, this often triggers an internal state update that may clear the input value [1][4]. 3. set-value: This action is triggered when a user selects an option [1]. The internal setValue method updates the component&#39;s internal state and calls onInputChange with this action to notify the controlled input of the change [1]. Because these actions are built-in behaviors of the library, many developers manage controlled input persistence by wrapping the onInputChange handler with logic to ignore or selectively process specific actions [5][3][6][7]. Common implementation pattern: If you want to keep the input text from being cleared when the menu closes or the input blurs, you can check the action metadata within your onInputChange handler [5][7]: onInputChange={(newValue, actionMeta) => { // Only update your component state if the action is a standard input change if (actionMeta.action!== &#39;input-blur&#39; && actionMeta.action!== &#39;menu-close&#39;) { setInputValue(newValue); } }} This pattern ensures that your controlled inputValue only responds to direct user typing (input-change) while ignoring the automatic resets triggered by internal lifecycle events [5][3][6].
</search_synthesis>

<source_evidence>

<title>packages/react-select/src/Select.tsx at master · JedWatson/react-select</title> https://github.com/JedWatson/react-select/blob/master/packages/react-select/src/Select.tsx /** Close the select menu when the user selects an option */ closeMenuOn ... : boolean; ... /** * ... scrolls the document ... body. * ... : * * ... /** Handle blur events on the control */ onBlur?: FocusEventHandler<HTMLInputElement>; ... /** Handle change events on the select */ onChange: ( newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option> ) => void; /** Handle focus events on the control */ onFocus?: FocusEventHandler<HTMLInputElement>; /** Handle change events on the input */ onInputChange: (newValue: string, actionMeta: InputActionMeta) => void; /** Handle key down events on the select */ onKeyDown?: KeyboardEventHandler<HTMLDivElement>; /** Handle the menu opening */ onMenuOpen: () => void; /** Handle the menu closing */ onMenuClose: () => void; /** Fired when the user scrolls to the top of the menu */ onMenu ... ToTop?: ... event: WheelEvent | ... ; ... // ============================== // Consumer Handlers // ============================== onMenuOpen() { this.props.onMenuOpen(); } onMenuClose() { this.onInputChange(&`#39`;&`#39`;, { action: &`#39`;menu-close&`#39`;, prevInputValue: this.props.inputValue, }); this.props.onMenuClose(); } onInputChange(newValue: string, actionMeta: InputActionMeta) { this.props.onInputChange(newValue, actionMeta); } // ============================== // Methods // ============================== focusInput() { if (!this.inputRef) return; this.inputRef.focus(); } blurInput() { if (!this.inputRef) return; this.input ... .blur(); } // ... ased for consumers focus = this.focusInput; blur = this.blurInput; ... } else if (direction ... &`#39`;paged ... else if (direction === &`#39`;last&`#39`;) { nextFocus = options.length - ... 1; } this. ... edOptionId ... onChange = ( newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option> ) => { const { onChange, name } = this.props; actionMeta.name = name; this.ariaOnChange(newValue, actionMeta); onChange(newValue, actionMeta); }; setValue = ( newValue: OnChangeValue<Option, IsMulti>, action: SetValueAction, option?: Option ) => { const { closeMenuOnSelect, isMulti, inputValue } = this.props; this.onInputChange(&`#39`;&`#39`;, { action: &`#39`;set-value&`#39`;, prevInputValue: inputValue }); if (closeMenuOnSelect) { this.setState({ inputIsHiddenAfterUpdate: !isMulti, }); this.onMenuClose(); } // when the select value should change, we should reset focusedValue this.setState({ clearFocusValueOnUpdate: true }); this.onChange(newValue, { action, option }); }; selectOption = (newValue: Option) => { const { blurInputOnSelect, isMulti, name } = this.props; const { selectValue } = this.state; const deselected = isMulti && this.isOptionSelected(newValue, selectValue); const isDisabled = this.isOptionDisabled(newValue, selectValue); if (deselected) { const candidate = this.getOptionValue(newValue); this.setValue( multiValueAsValue( selectValue.filter((i) => this.getOptionValue(i) !== candidate) ), &`#39`;deselect-option&`#39`;, newValue ); } else if (!isDisabled) { // Select option if option is not disabled if (isMulti) { this.setValue( multiValueAsValue([...selectValue, newValue]), &`#39`;select-option&`#39`;, newValue ); } else { this.setValue(singleValueAsValue(newValue), &`#39`;select-option&`#39`;); } } else { this.ariaOnChange(singleValueAsValue(newValue), { action: &`#39`;select-option&`#39`;, option: newValue, name, }); return; } if (blurInputOnSelect) { this.blurInput(); } }; removeValue = (removedValue: Option) => { const { isMulti } = this.props; const { selectValue } = this.state; const candidate = this.getOptionValue(removedValue); const newValueArray = selectValue.filter( (i) => this.getOptionValue(i) !== candidate ); const newValue = valueTernary( isMulti, newValueArray, newValueArray ... 0] || null ); this.onChange(newValue, { action: &`#39`;remove-value&`#39`;, removedValue }); this.focusInput(); }; clearValue = ... // ============================== // Foc…[truncated] <title>Select.d.ts</title> https://cdn.jsdelivr.net/npm/react-select@5.10.2/dist/declarations/src/Select.d.ts , IsMulti, ... >; /** Close the select menu when the user selects an option */ closeMenuOnSelect: boolean; /** * If `true`, close the select menu when the user scrolls the document/body. * * If a function, takes a standard javascript `ScrollEvent` you return a boolean: * * `true` => The menu closes * * `false` => The menu stays open * * This is useful when you have a scrollable modal and want to portal the menu out, * but want to avoid graphical issues ... */ closeMenuOnScroll: boolean | ((event: Event) => boolean); /** * ... inputValue: string; }) => ReactNode; /** Handle blur events on the control */ onBlur?: FocusEventHandler; /** Handle change events on the select */ onChange: (newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta) => void; /** Handle focus events on the control */ onFocus?: FocusEventHandler; /** Handle change events on the input */ onInputChange: (newValue: string, actionMeta: InputActionMeta) => void; /** Handle key down ... on the select */ onKeyDown?: KeyboardEventHandler; /** Handle the menu opening */ onMenuOpen: () => void; /** Handle the menu closing */ onMenuClose: () => void; /** Fired when the user scrolls to the top of the menu */ onMenuScrollToTop?: (event: WheelEvent | TouchEvent) => void; /** Fired when the user scrolls to the bottom of the menu */ onMenuScrollToBottom?: (event: WheelEvent | TouchEvent) => void; ... export default class Select = GroupBase > extends Component<Props<Option, IsMulti, Group>, State<Option, IsMulti, Group>> { static defaultProps: { &`#39`;aria-live&`#39`;: string; backspaceRemovesValue: boolean; blurInputOnSelect: boolean; captureMenuScroll: boolean; classNames: {}; closeMenuOnSelect: boolean; closeMenuOnScroll: boolean; components: {}; control ... boolean; escape ... boolean; ... Input: string) ... boolean; ... <Option_1, ... _1 extends GroupBase<Option ... : Group_1) => string; getOptionLabel: <Option_2>(option: Option_2) => string; getOptionValue: <Option_3>(option: Option_3) => string; ... : boolean; ... ; isOption ... : <Option_ ... >(option: Option_4) => boolean; loadingMessage: ... => string; maxMenu ... : number; minMenuHeight: number; menu ... ; menu ... : string; menu ... : string; menu ... blockOptionHover ... boolean; isComposing: boolean; commonProps: any; initialTouchX: number; initialTouchY: number; openAfterFocus: boolean; scrollToFocusedOptionOnUpdate: boolean; userIsDragging?: boolean; controlRef: HTMLDivElement | null; getControlRef: RefCallback; focusedOptionRef: HTMLDivElement | null; getFocusedOptionRef: RefCallback; menuListRef: HTMLDivElement | null; getMenuListRef: RefCallback; inputRef: HTMLInputElement | null; getInputRef: RefCallback; constructor(props: Props<Option, IsMulti, Group>); static getDerivedStateFromProps(props: Props<unknown, boolean, GroupBase >, state: State<unknown, boolean, GroupBase >): { prevProps: Props<unknown, boolean, GroupBase >; ariaSelection: AriaSelection<unknown, boolean> | null; prevWasFocused: boolean; inputIsHidden: boolean; inputIsHiddenAfterUpdate: undefined; } | { prevProps: Props<unknown, boolean, GroupBase >; ariaSelection: AriaSelection<unknown, boolean> | null; prevWasFocused: boolean; inputIsHidden?: undefined; inputIsHiddenAfterUpdate?: undefined; }; componentDidMount(): void; componentDidUpdate(prevProps: Props<Option, IsMulti, Group>): void; componentWillUnmount(): void; onMenuOpen(): void; onMenuClose(): void; onInputChange(newValue: string, actionMeta: InputActionMeta): void; focusInput(): void; blurInput(): void; focus: () => void; blur: () => void; openMenu(focusOption: &`#39`;first&`#39`; | &`#39`;last&`#39`;): void; focusValue(direction: &`#39`;previous&`#39`; | &`#39`;next&`#39`;): void; focusOption(direction?: FocusDirection): void; onChange: (newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta) => void; setValu…[truncated] <title>Add option to keep incomplete input onBlur · Issue `#3189` · JedWatson/react-select</title> GitHub issue 3189 in JedWatson/react-select (link omitted to avoid creating a cross-reference) > hi `@agonsalves` , > not sure if this is helpful but i was trying to do this as well and i managed to keep the input on mouse blur with this: > > ``` > public handleBlur = (event) => { > const { inputValue } = this.state; > const { value } = this.props; > > if (!_.isEmpty(inputValue)) { > this.setState({ > inputValue: &`#39`;&`#39`; > }); > this.props.onChange([...value,createOption(inputValue)]); > event.preventDefault(); > } > > } > ``` > > and i call it: > > ``` > components={components} > inputValue={inputValue} > isClearable > isMulti > menuIsOpen={false} > onChange={this.handleChange} > onInputChange={this.handleInputChange} > onBlur={this.handleBlur} > onKeyDown={this.handleKeyDown} > value={value} > className={styles.questionInput} > styles={questionStyles} > /> > > ``` ... > This option used to be in react-select v1 `onBlurResetsInput`, but is missing in v2. The line in question is in `Select.js on line 1074`: > > ``` > onInputBlur = (event: SyntheticFocusEvent) => { > if(this.menuListRef && this.menuListRef.contains(document.activeElement)) { > this.inputRef.focus(); > return; > } > if (this.props.onBlur) { > this.props.onBlur(event); > } > this.onInputChange(&`#39`;&`#39`;, { action: &`#39`;input-blur&`#39`; }); // <-- have an option to skip this line > this.onMenuClose(); > this.setState({ > focusedValue: null, > isFocused: false, > }); > }; > ``` > > In react-select v1, `Select.js lines 416-418`, this is how it used to work: > > ``` > if (this.props.onBlurResetsInput) { > onBlurredState.inputValue = this.handleInputValueChange(&`#39`;&`#39`;); > } > ``` > > I&`#39`;m working on a PR to include this option. ... > Another option would be to use the `onInputChange` prop and manage the value of the search input via state. You just have to prevent state updates for the actions `input-blur` and `menu-close`. > > ```jsx > class KeepSearchOnBlurSelect extends Component { > state = { > inputValue: "" > }; > > handleInputChange(inputValue, action) { > if (action.action !== "input-blur" && action.action !== "menu-close") { > this.setState({ inputValue }); > } > } > > render() { > const { inputValue } = this.state; > return ( > inputValue={inputValue} > defaultValue={colourOptions[0]} > name="color" > options={colourOptions} > onInputChange={this.handleInputChange.bind(this)} > /> > ); > } > } > ``` > > [CodeSandbox](https://codesandbox.io/s/p381p2pz3m) ... > Nope, this is not good enough: > > If you type a value and blur the field the value is saved. But if you remove the value the empty value cannot be saved: onInputChange receives a value of &`#39`;&`#39`; when you choose a value from a list (why???), see also https://github.com/JedWatson/react-select/issues/3440. So inputValue is set to null when you choose values from the list. And onBlur and onInputChange cannot know whether the empty value they receive comes from deleting a value in the input or choosing a value from the list. > > Additionally the x symbol that you could clear values with is not shown when you type a value that is not an option. > > :-( ... > `@barbalex` If you want to save the typed value, you should consider using `Creatable`. There you have the possibility to either select a value from the option list or create a new value. The new value gets assigned an attribute (`__isNew__`) which determines it has been created by the user. > > Based on your use case: IMO the value in a selection component should never be created just on input, but the user should choose if he wants to create a new value. This library respects this idea in its `Creatable` component, having the user do an extra se…[truncated] <title>How to keep value in textbox search after choosing it · Issue `#1826` · JedWatson/react-select</title> GitHub issue 1826 in JedWatson/react-select (link omitted to avoid creating a cross-reference) option onBlurResetsInput ... set whether to clear input on ... or not. ... > `@DaveOdden` (and others) For V2- I was able to enable editable text by setting the `inputValue` prop on the `Select` to be whatever text I wanted to show in the input, which works, except for when I select a value in the dropdown, which appears to clear the input, but in actuality sets the opacity to 0. For that, I had to set `opacity: 1 !important;` in my own CSS for `.myContainer input`. It took days to figure this out but the input now behaves much better as you can click on the text, highlight it, the cursor appears at the end of the input like a normal text field. > > The other thing I had to do was to make sure not to set blank text on the input when the input is focused- the current behavior just clears the input when you click on it, because there is a `menu-close` action happening which unintentionally sets a blank string on the input. For that, in my `onInputChange` handler, I do not set my local react state value when `!value && action === &`#39`;menu-close`, and simply `return` out of the callback so my component is not setting the inputValue to be an empty String. ... > I fixed it by this way > > 1- onInputChange: > > ``` > handleSearch = (value, {action}) => { > if (action === &`#39`;menu-close&`#39`; || action === &`#39`;input-blur&`#39`; || action === &`#39`;set-value&`#39`;) {return} > else { this.setState({searchValue: value})} > }; > ``` > > 2- onChange: (so it sets the value of your input same as you selected) > > ``` > handleSelect = ({label, value, ...rest}) => { > this.setState({ searchValue: e.value }) > } > ``` > > and as `@lusa` mentioned we need to set `opacity: 1 !important` of the input field. > set `inputId` on props to easily change the opacity. > > - i also set `display: none` to selectValue. so i can see just my normal input. ... > `@TITAN9389` I sort of solved it. The problem was that no option actually was selected: I had to modify the handleSearchSelection function, to set searchSelection to the selected object, not just its value. See https://codesandbox.io/s/346918zzp1 > So once an option is selected, the clear button will appear. This is a partial solution to what I wanted, but to make the clear button appear when just something is entered (but nothing actually selected in the menu) will demand hacking the Select component code, so I&`#39`;ll leave that for now. ... > Greetings all, I have addressed this in several other places and [started a discussion](https://github.com/JedWatson/react-select/discussions/4302) about making this potentially easier but the approach is fairly straight forward to roll on your own. > > 1. Create a custom Input to ensure isHidden is always false > > ```JS > const Input = props => <components.Input {...props, isHidden: false } /> > ``` > > 1. Make the input controlled so we can override the inputValue when an option is selected or the input is blurred. > > ```JS > const [value, setValue] = useState(); > const [inputValue, setInputValue] = useState(""); > > const onInputChange = (inputValue, { action }) => { > // onBlur => setInputValue to last selected value > if (action === "input-blur") { > setInputValue(value ? value.label : ""); > } > > // onInputChange => update inputValue > else if (action === "input-change") { > setInputValue(inputValue); > } > }; > > const onChange = (option) => { > setValue(option); > setInputValue(option ? option.label : ""); > }; > ``` > > 1. Hide the rendered value component with the prop `controlShouldRenderValue` > > Putting this all together looks like this... > > ```JS > const Input = props => <components.Input {...props, isHidden: false } /> > > const [value, setValu…[truncated] <title>Don&`#39`;t clear input value after selecting option</title> GitHub issue 3210 in JedWatson/react-select (link omitted to avoid creating a cross-reference) > If I get this right, you want to keep the search string after selecting an option? > > You first have to pass a function to the `onInputChange` prop to handle the search string. Inside the function you save the string into state only if the corresponding action is not `set-value`. > You also have to pass the value from state to the `inputValue` prop. > The props `closeMenuOnSelect` and `blurInputOnSelect` prevent the Select from closing the menu and blurring our input if a value has been selected. > > ```jsx > /* ... */ > > onInputChange (query, { action }) => { > // Prevents resetting our input after option has been selected > if (action !== "set-value") this.setState({inputValue: query}); > } > > /* ... */ > > isMulti > inputValue={inputValue} > defaultValue={[colourOptions[0]]} > options={colourOptions} > blurInputOnSelect={false} //set by default, but to be sure > closeMenuOnSelect={false} //prevents menu close after select, which would also result in input blur > onInputChange={this.onInputChange} //the function to handle our search input > /> > ``` ... > I ended up doing this with manipulating the inputValue onFocus for our use-case ... > ``` > import React from "react"; > import ReactDOM from "react-dom"; > import Select from "react-select"; > > import "./styles.css"; > > const options = [ > { value: "chocolate", label: "Chocolate" }, > { value: "strawberry", label: "Strawberry" }, > { value: "vanilla", label: "Vanilla" } > ]; > > class App extends React.Component { > state = { > selectedOption: null, > inputValue: "" > }; ... > handleInputChange = inputValue => { > this.setState({ inputValue }); > }; > handleChange = selectedOption => { > this.setState({ selectedOption }); > }; > handleFocus = () => { > const { selectedOption } = this.state; > if (selectedOption && selectedOption.label) { > this.setState({ inputValue: selectedOption.label }); > } > }; > > render() { > const { selectedOption, inputValue } = this.state; > > return ( > value={selectedOption} > onChange={this.handleChange} > inputValue={inputValue} > onInputChange={this.handleInputChange} > onFocus={this.handleFocus} > options={options} > /> > ); > } > } > > const rootElement = document.getElementById("root"); > ReactDOM.render(, rootElement); > ``` ... > `@dlinch` I don&`#39`;t know whether you&`#39`;ve found the working solution already or need a one yet, here&`#39`;s my approach which derives from `@Rall3n` answer. > > ```js > _handleInputChange(inputValue: string, { action }) { > if (action !== "set-value") { > this.setState({ inputValue }); > > return inputValue; > } > > return this.state.inputValue; > } > ``` > > You always have to return `inputValue`. If you check the source code of the `react-select` async part (`Async.js`) you can notice that it tries to utilize `inputValue` in the following line: ... > `const inputValue = handleInputChange(newValue, actionMeta, onInputChange);` > `onInputChange` is your handler function. So basically all further operations will be based on that value, and when `!inputValue === true` it updates the state with an empty array of options. > > I haven&`#39`;t done much testing yet, so don&`#39`;t rely much on this. ... > I tried the above solutions but they weren&`#39`;t ideal for me, so I added a bit of extra logic here(has implementation example) > > ```javascript > const App = () => { > const [input, setInput] = useState(""); > const [inputSave, setSave] = useState(""); > > return ( > placeholder={inputSave} // when blurred …[truncated]

Citations:


🏁 Script executed:

sed -n '1,70p' frontend/web/components/GitLabProjectSelect.tsx
sed -n '1,65p' frontend/web/components/experiments/EventNameSelect/EventNameSelect.tsx
sed -n '75,90p' frontend/package-lock.json
sed -n '19815,19830p' frontend/package-lock.json

Repository: Flagsmith/flagsmith

Length of output: 5280


Filter non-typing onInputChange actions.

React Select 5.10.2 emits an empty value with actions such as input-blur, menu-close, and set-value. This handler forwards every value to searchProjects. The debounced useInfiniteScroll search stores the empty value in q, which sends an unfiltered request and can replace the typed project results. Selecting an option emits set-value before the menu-close configuration is checked, so the current configuration does not prevent this path.

Pass the action metadata and handle only input-change.

import type { InputActionMeta } from 'react-select'

// ...

onInputChange={(
  value: string,
  { action }: InputActionMeta,
) => {
  if (action === 'input-change') onInputChange(value)
}}

@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docs Ready Ready Preview Sep 18, 2026 3:02pm UTC
flagsmith-frontend-preview Ready Ready Preview Sep 18, 2026 3:02pm UTC
flagsmith-frontend-staging Ready Ready Preview Sep 18, 2026 3:02pm UTC

Request Review

@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.67%. Comparing base (49d957a) to head (c935c85).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8553      +/-   ##
==========================================
- Coverage   98.81%   98.67%   -0.14%     
==========================================
  Files        1620     1620              
  Lines       66234    66251      +17     
==========================================
- Hits        65449    65376      -73     
- Misses        785      875      +90     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Issue related to the REST API docs Documentation updates front-end Issue related to the React Front End Dashboard

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitLab integration: project picker has no search, so only the first 100 projects are selectable

1 participant