Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions api/integrations/gitlab/client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,21 @@ def fetch_gitlab_projects(
*,
page: int,
page_size: int,
search_text: str | None = None,
) -> GitLabPage[GitLabProject]:
params = {
"membership": "true",
"per_page": str(page_size),
"page": str(page),
}
if search_text:
params["search"] = search_text

response = _get_from_gitlab_api(
instance_url,
access_token,
path="projects",
params={
"membership": "true",
"per_page": str(page_size),
"page": str(page),
},
params=params,
)

results: list[GitLabProject] = [
Expand Down
4 changes: 4 additions & 0 deletions api/integrations/gitlab/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class PaginatedQueryParamsSerializer(serializers.Serializer[None]):
page_size = serializers.IntegerField(default=100, min_value=1, max_value=100)


class ProjectSearchQueryParamsSerializer(PaginatedQueryParamsSerializer):
search_text = serializers.CharField(required=False, allow_blank=True)


class SearchQueryParamsSerializer(PaginatedQueryParamsSerializer):
gitlab_project_id = serializers.IntegerField()
search_text = serializers.CharField(required=False, allow_blank=True)
Expand Down
4 changes: 4 additions & 0 deletions api/integrations/gitlab/views/browse_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from integrations.gitlab.models import GitLabConfiguration
from integrations.gitlab.serializers import (
PaginatedQueryParamsSerializer,
ProjectSearchQueryParamsSerializer,
SearchQueryParamsSerializer,
)
from projects.permissions import NestedProjectPermissions
Expand Down Expand Up @@ -100,6 +101,8 @@ def page_url(page: int) -> str:


class BrowseGitLabProjects(_GitLabListView[GitLabProject]):
serializer_class = ProjectSearchQueryParamsSerializer

def fetch_page(
self,
config: GitLabConfiguration,
Expand All @@ -110,6 +113,7 @@ def fetch_page(
access_token=config.access_token,
page=validated_data["page"],
page_size=validated_data["page_size"],
search_text=validated_data.get("search_text"),
)

self._log_for(config).info("projects.fetched")
Expand Down
61 changes: 61 additions & 0 deletions api/tests/unit/integrations/gitlab/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,67 @@ def test_search_gitlab_issues__default_params__returns_issues() -> None:
]


@responses.activate
def test_fetch_gitlab_projects__with_search_text__sends_search_param() -> None:
# Given
responses.get(
f"{INSTANCE_URL}/api/v4/projects",
json=[],
headers={"x-page": "1", "x-total-pages": "1", "x-total": "0"},
match=[
responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN}),
responses.matchers.query_param_matcher(
{
"membership": "true",
"per_page": "100",
"page": "1",
"search": "my-project",
},
strict_match=False,
),
],
)

# When
result = fetch_gitlab_projects(
instance_url=INSTANCE_URL,
access_token=ACCESS_TOKEN,
page=1,
page_size=100,
search_text="my-project",
)

# Then
assert result["results"] == []


@responses.activate
def test_fetch_gitlab_projects__without_search_text__omits_search_param() -> None:
# Given
responses.get(
f"{INSTANCE_URL}/api/v4/projects",
json=[],
headers={"x-page": "1", "x-total-pages": "1", "x-total": "0"},
match=[
responses.matchers.query_param_matcher(
{"membership": "true", "per_page": "100", "page": "1"},
strict_match=True,
),
],
)

# When
result = fetch_gitlab_projects(
instance_url=INSTANCE_URL,
access_token=ACCESS_TOKEN,
page=1,
page_size=100,
)

# Then
assert result["results"] == []


@responses.activate
def test_search_gitlab_issues__with_search_text__sends_search_param() -> None:
# Given
Expand Down
28 changes: 28 additions & 0 deletions api/tests/unit/integrations/gitlab/test_proxy_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ def test_gitlab_project_list__valid_config__returns_paginated_response(
]


@pytest.mark.usefixtures("gitlab_config")
def test_gitlab_project_list__with_search_text__forwards_search_to_gitlab(
admin_client: APIClient,
project: Project,
mocker: MockerFixture,
) -> None:
# Given
mocked_fetch = mocker.patch(
"integrations.gitlab.views.browse_gitlab.fetch_gitlab_projects",
return_value={
"results": [],
"current_page": 1,
"total_pages": 1,
"total_count": 0,
},
)

# When
response = admin_client.get(
f"/api/v1/projects/{project.id}/gitlab/projects/",
{"page": "1", "page_size": "100", "search_text": "my-project"},
)

# Then
assert response.status_code == status.HTTP_200_OK
assert mocked_fetch.call_args.kwargs["search_text"] == "my-project"


def test_gitlab_project_list__no_gitlab_config__returns_400(
admin_client: APIClient,
project: Project,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ Attributes:
### `gitlab.api_call.failed`

Logged at `error` from:
- `api/integrations/gitlab/views/browse_gitlab.py:59`
- `api/integrations/gitlab/views/browse_gitlab.py:60`

Attributes:
- `exc_info`
Expand Down
1 change: 1 addition & 0 deletions frontend/common/services/useGitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const gitlabService = service
url: `projects/${query.project_id}/gitlab/projects/?${Utils.toParam({
page: query.page ?? 1,
page_size: query.page_size ?? 100,
search_text: query.q || undefined,
})}`,
}),
}),
Expand Down
1 change: 1 addition & 0 deletions frontend/common/useInfiniteScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const useInfiniteScroll = <

return {
data: combinedData,
isError: queryResponse.isError,
isFetching: queryResponse.isFetching,
isLoading: queryResponse.isLoading,
loadMore,
Expand Down
39 changes: 28 additions & 11 deletions frontend/web/components/GitLabLinkSection.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import React, { FC, useState } from 'react'
import AppActions from 'common/dispatcher/app-actions'
import ErrorMessage from './ErrorMessage'
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

import GitLabSearchSelect from './GitLabSearchSelect'
import { useCreateExternalResourceMutation } from 'common/services/useExternalResource'
import useInfiniteScroll from 'common/useInfiniteScroll'
import { Req } from 'common/types/requests'
import { useGetGitLabProjectsQuery } from 'common/services/useGitlab'
import type { GitLabIssue, GitLabMergeRequest } from 'common/types/responses'
import {
Res,
type GitLabIssue,
type GitLabMergeRequest,
} from 'common/types/responses'

type GitLabLinkSectionProps = {
projectId: number
Expand All @@ -30,7 +38,9 @@ const GitLabLinkSection: FC<GitLabLinkSectionProps> = ({
projectId,
}) => {
const [createExternalResource] = useCreateExternalResourceMutation()
const [gitlabProjectId, setGitlabProjectId] = useState<number | null>(null)
const [selectedProject, setSelectedProject] =
useState<GitLabProjectOption | null>(null)
const gitlabProjectId = selectedProject?.value ?? null
const [linkType, setLinkType] = useState<GitLabLinkType>('GITLAB_ISSUE')
const [selectedItem, setSelectedItem] = useState<
GitLabIssue | GitLabMergeRequest | null
Expand All @@ -39,12 +49,17 @@ const GitLabLinkSection: FC<GitLabLinkSectionProps> = ({
const {
data: projectsData,
isError: isProjectsError,
isFetching: isProjectsFetching,
isLoading: isProjectsLoading,
} = useGetGitLabProjectsQuery({
page: 1,
page_size: 100,
project_id: projectId,
})
searchItems: searchProjects,
} = useInfiniteScroll<Req['getGitLabProjects'], Res['gitlabProjects']>(
useGetGitLabProjectsQuery,
{
page_size: 100,
project_id: projectId,
},
100,
)
const projects = projectsData?.results ?? []

const linkSelectedItem = async () => {
Expand Down Expand Up @@ -84,9 +99,11 @@ const GitLabLinkSection: FC<GitLabLinkSectionProps> = ({
<GitLabProjectSelect
projects={projects}
isLoading={isProjectsLoading}
isDisabled={isProjectsError}
value={gitlabProjectId}
onChange={setGitlabProjectId}
isFetching={isProjectsFetching}
isError={isProjectsError}
value={selectedProject}
onChange={setSelectedProject}
onInputChange={searchProjects}
/>
<div style={{ width: 200 }}>
<Select
Expand Down
37 changes: 27 additions & 10 deletions frontend/web/components/GitLabProjectSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,54 @@
import React, { FC } from 'react'
import type { GitLabProject } from 'common/types/responses'

export type GitLabProjectOption = {
label: string
value: number
}

type GitLabProjectSelectProps = {
projects: GitLabProject[]
isLoading: boolean
isDisabled: boolean
value: number | null
onChange: (id: number) => void
isFetching: boolean
isError: boolean
value: GitLabProjectOption | null
onChange: (project: GitLabProjectOption) => void
onInputChange: (search: string) => void
}

const GitLabProjectSelect: FC<GitLabProjectSelectProps> = ({
isDisabled,
isError,
isFetching,
isLoading,
onChange,
onInputChange,
projects,
value,
}) => {
const options = projects.map((p) => ({
const options: GitLabProjectOption[] = projects.map((p) => ({
label: p.path_with_namespace,
value: p.id,
}))
const isBusy = isLoading || isFetching

return (
<div style={{ minWidth: 250 }}>
<Select
filterOption={(options: any[]) => options}
className='w-100 react-select'
size='select-md'
placeholder={isLoading ? 'Loading...' : 'Select GitLab Project'}
value={options.find((o) => o.value === value) ?? null}
onChange={(v: { value: number }) => onChange(v.value)}
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)
}}

options={options}
isLoading={isLoading}
isDisabled={isDisabled}
isLoading={isBusy}
noOptionsMessage={() => {
if (isBusy) return 'Loading...'
return isError
? 'Failed to load GitLab projects'
: 'No projects found'
}}
/>
</div>
)
Expand Down
Loading