Skip to content

Commit b434b91

Browse files
[8.19] [APM] Improve environment combobox auto-complete (#218935) (#219976)
# Backport This will backport the following commits from `main` to `8.19`: - [[APM] Improve environment combobox auto-complete (#218935)](#218935) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Carlos Crespo","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-04-24T15:40:29Z","message":"[APM] Improve environment combobox auto-complete (#218935)\n\nfixes https://github.com/elastic/kibana/issues/216974\n## Summary\n\nImprove the environment combobox typing behaviour\n\n\n![env_dropdown](https://github.com/user-attachments/assets/f545f43a-e04e-4a22-b48b-ea68e277af60)\n\n\n\n### How to test\n\n- Navigate to Service Inventory\n- Interact with the Environment dropdown\n- It will fetch from the server when: backspace is hit or when he text\ntyped can't be matched with any items from the options list\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>\nCo-authored-by: jennypavlova <[email protected]>","sha":"34b14170c8c830c64710be266310016423d72bf7","branchLabelMapping":{"^v9.1.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:skip","Team:obs-ux-infra_services","v9.1.0","v8.19.0"],"title":"[APM] Improve environment combobox auto-complete","number":218935,"url":"https://github.com/elastic/kibana/pull/218935","mergeCommit":{"message":"[APM] Improve environment combobox auto-complete (#218935)\n\nfixes https://github.com/elastic/kibana/issues/216974\n## Summary\n\nImprove the environment combobox typing behaviour\n\n\n![env_dropdown](https://github.com/user-attachments/assets/f545f43a-e04e-4a22-b48b-ea68e277af60)\n\n\n\n### How to test\n\n- Navigate to Service Inventory\n- Interact with the Environment dropdown\n- It will fetch from the server when: backspace is hit or when he text\ntyped can't be matched with any items from the options list\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>\nCo-authored-by: jennypavlova <[email protected]>","sha":"34b14170c8c830c64710be266310016423d72bf7"}},"sourceBranch":"main","suggestedTargetBranches":["8.19"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/218935","number":218935,"mergeCommit":{"message":"[APM] Improve environment combobox auto-complete (#218935)\n\nfixes https://github.com/elastic/kibana/issues/216974\n## Summary\n\nImprove the environment combobox typing behaviour\n\n\n![env_dropdown](https://github.com/user-attachments/assets/f545f43a-e04e-4a22-b48b-ea68e277af60)\n\n\n\n### How to test\n\n- Navigate to Service Inventory\n- Interact with the Environment dropdown\n- It will fetch from the server when: backspace is hit or when he text\ntyped can't be matched with any items from the options list\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>\nCo-authored-by: jennypavlova <[email protected]>","sha":"34b14170c8c830c64710be266310016423d72bf7"}},{"branch":"8.19","label":"v8.19.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Elastic Machine <[email protected]>
1 parent 7ebc3b8 commit b434b91

File tree

3 files changed

+208
-40
lines changed

3 files changed

+208
-40
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
import React from 'react';
8+
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
9+
import { EnvironmentSelect } from '.';
10+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
11+
import userEvent from '@testing-library/user-event';
12+
13+
const DEFAULT_ENVIRONMENT = 'production';
14+
15+
const mockOnSearchChange = jest.fn();
16+
jest.mock('./use_environment_select', () => ({
17+
useEnvironmentSelect: jest.fn(() => ({
18+
data: { terms: [] },
19+
searchStatus: 'success',
20+
onSearchChange: mockOnSearchChange,
21+
})),
22+
}));
23+
24+
describe('EnvironmentSelect', () => {
25+
async function clearInputValue(input: HTMLInputElement) {
26+
input.setSelectionRange(0, input.value.length);
27+
await act(async () =>
28+
userEvent.type(input, '{backspace}', {
29+
initialSelectionEnd: 0,
30+
})
31+
);
32+
fireEvent.input(input, { target: { value: '' } });
33+
}
34+
35+
const defaultProps = {
36+
environment: DEFAULT_ENVIRONMENT,
37+
availableEnvironments: ['production', 'staging', 'development'],
38+
status: FETCH_STATUS.SUCCESS,
39+
serviceName: 'test-service',
40+
rangeFrom: 'now-15m',
41+
rangeTo: 'now',
42+
onChange: jest.fn(),
43+
};
44+
45+
afterEach(() => {
46+
jest.clearAllMocks();
47+
cleanup();
48+
});
49+
50+
it('resets to the current environment on blur if no valid selection is made', async () => {
51+
const { getByRole } = render(<EnvironmentSelect {...defaultProps} />);
52+
const combobox = getByRole('combobox') as HTMLInputElement;
53+
54+
await clearInputValue(combobox);
55+
56+
expect(combobox).toHaveValue('');
57+
58+
act(() => {
59+
fireEvent.blur(combobox);
60+
});
61+
62+
expect(combobox).toHaveValue('production');
63+
});
64+
65+
it('Should not call onSearchChange if item is already listed', async () => {
66+
const { getByRole } = render(<EnvironmentSelect {...defaultProps} />);
67+
const combobox = getByRole('combobox') as HTMLInputElement;
68+
69+
expect(mockOnSearchChange.mock.calls.length).toBe(0);
70+
71+
await clearInputValue(combobox);
72+
73+
expect(combobox).toHaveValue('');
74+
75+
expect(mockOnSearchChange.mock.calls.length).toBe(1);
76+
77+
act(() => {
78+
fireEvent.input(combobox, { target: { value: 'dev' } });
79+
});
80+
81+
expect(combobox).toHaveValue('dev');
82+
expect(screen.getByText('development')).toBeInTheDocument();
83+
expect(mockOnSearchChange.mock.calls.length).toBe(1);
84+
});
85+
});

x-pack/solutions/observability/plugins/apm/public/components/shared/environment_select/index.tsx

Lines changed: 76 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,20 @@
44
* 2.0; you may not use this file except in compliance with the Elastic License
55
* 2.0.
66
*/
7-
import { isEmpty } from 'lodash';
7+
88
import { i18n } from '@kbn/i18n';
9-
import React, { useMemo, useState } from 'react';
10-
import { debounce } from 'lodash';
9+
import React, { useCallback, useMemo, useState } from 'react';
1110
import type { EuiComboBoxOptionOption } from '@elastic/eui';
1211
import { EuiComboBox, EuiFormRow } from '@elastic/eui';
1312
import {
1413
getEnvironmentLabel,
1514
ENVIRONMENT_NOT_DEFINED,
1615
ENVIRONMENT_ALL,
1716
} from '../../../../common/environment_filter_values';
18-
import { SERVICE_ENVIRONMENT } from '../../../../common/es_fields/apm';
19-
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
17+
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
2018
import { useTimeRange } from '../../../hooks/use_time_range';
2119
import type { Environment } from '../../../../common/environment_rt';
20+
import { useEnvironmentSelect } from './use_environment_select';
2221

2322
function getEnvironmentOptions(environments: Environment[]) {
2423
const environmentOptions = environments
@@ -35,6 +34,22 @@ function getEnvironmentOptions(environments: Environment[]) {
3534
];
3635
}
3736

37+
function shouldFetch({
38+
newValue,
39+
oldValue,
40+
optionList,
41+
}: {
42+
newValue: string;
43+
oldValue: string;
44+
optionList: string[];
45+
}) {
46+
return (
47+
newValue !== '' &&
48+
(!optionList.some((option) => option.toLowerCase().includes(newValue.toLowerCase())) ||
49+
!newValue.toLowerCase().includes(oldValue.toLowerCase()))
50+
);
51+
}
52+
3853
export function EnvironmentSelect({
3954
environment,
4055
availableEnvironments,
@@ -53,60 +68,80 @@ export function EnvironmentSelect({
5368
onChange: (value: string) => void;
5469
}) {
5570
const [searchValue, setSearchValue] = useState('');
56-
57-
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
58-
59-
const selectedOptions: Array<EuiComboBoxOptionOption<string>> = [
71+
const [selectedOption, setSelectedOption] = useState<Array<EuiComboBoxOptionOption<string>>>([
6072
{
6173
value: environment,
6274
label: getEnvironmentLabel(environment),
6375
},
64-
];
76+
]);
77+
78+
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
79+
const { data, onSearchChange, searchStatus } = useEnvironmentSelect({ serviceName, start, end });
6580

6681
const onSelect = (changedOptions: Array<EuiComboBoxOptionOption<string>>) => {
6782
if (changedOptions.length === 1 && changedOptions[0].value) {
6883
onChange(changedOptions[0].value);
6984
}
85+
setSelectedOption(changedOptions);
7086
};
7187

72-
const { data, status: searchStatus } = useFetcher(
73-
(callApmApi) => {
74-
return isEmpty(searchValue)
75-
? Promise.resolve({ terms: [] })
76-
: callApmApi('GET /internal/apm/suggestions', {
77-
params: {
78-
query: {
79-
fieldName: SERVICE_ENVIRONMENT,
80-
fieldValue: searchValue,
81-
serviceName,
82-
start,
83-
end,
84-
},
85-
},
86-
});
88+
const terms = useMemo(() => {
89+
if (searchValue.trim() === '') {
90+
return availableEnvironments;
91+
}
92+
93+
return [...new Set(data?.terms.concat(...availableEnvironments))];
94+
}, [availableEnvironments, data?.terms, searchValue]);
95+
96+
const options = useMemo<Array<EuiComboBoxOptionOption<string>>>(() => {
97+
const environmentOptions = getEnvironmentOptions(terms);
98+
99+
return searchValue.trim() === ''
100+
? environmentOptions
101+
: environmentOptions.filter((term) =>
102+
term.value.toLowerCase().includes(searchValue.toLowerCase())
103+
);
104+
}, [terms, searchValue]);
105+
106+
const isInvalid = options.length === 0 && searchValue !== '';
107+
108+
const onSearch = useCallback(
109+
(value: string) => {
110+
setSearchValue(value);
111+
if (
112+
shouldFetch({
113+
newValue: value,
114+
oldValue: searchValue,
115+
optionList: terms,
116+
})
117+
) {
118+
onSearchChange(value);
119+
}
87120
},
88-
[searchValue, start, end, serviceName]
121+
[onSearchChange, terms, searchValue]
89122
);
90-
const terms = data?.terms ?? [];
91-
const isInvalid = terms.length === 0 && searchValue !== '';
92-
93-
const options: Array<EuiComboBoxOptionOption<string>> = [
94-
...(searchValue === ''
95-
? getEnvironmentOptions(availableEnvironments)
96-
: terms.map((name) => {
97-
return { label: name, value: name };
98-
})),
99-
];
100123

101-
const onSearch = useMemo(() => debounce(setSearchValue, 300), []);
124+
// in case the combobox is left empty, returns the current selected environment stored in the URL state
125+
const onBlur = useCallback(() => {
126+
setSelectedOption([
127+
{
128+
value: environment,
129+
label: getEnvironmentLabel(environment),
130+
},
131+
]);
132+
}, [environment]);
102133

103134
return (
104135
<EuiFormRow
136+
aria-label={i18n.translate(
137+
'xpack.apm.environmentSelect.selectenvironmentComboBox.ariaLabel',
138+
{ defaultMessage: 'Select environment' }
139+
)}
105140
error={i18n.translate('xpack.apm.filter.environment.error', {
106141
defaultMessage: '{value} is not a valid environment',
107142
values: { value: searchValue },
108143
})}
109-
style={{ minWidth: '256px' }}
144+
css={{ minWidth: '256px' }}
110145
isInvalid={isInvalid}
111146
>
112147
<EuiComboBox
@@ -122,9 +157,10 @@ export function EnvironmentSelect({
122157
})}
123158
singleSelection={{ asPlainText: true }}
124159
options={options}
125-
selectedOptions={selectedOptions}
126-
onChange={(changedOptions) => onSelect(changedOptions)}
160+
selectedOptions={selectedOption}
161+
onChange={onSelect}
127162
onSearchChange={onSearch}
163+
onBlur={onBlur}
128164
isLoading={status === FETCH_STATUS.LOADING || searchStatus === FETCH_STATUS.LOADING}
129165
/>
130166
</EuiFormRow>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { SERVICE_ENVIRONMENT } from '@kbn/apm-types';
9+
import { useStateDebounced } from '../../../hooks/use_debounce';
10+
import { useFetcher } from '../../../hooks/use_fetcher';
11+
12+
export function useEnvironmentSelect({
13+
serviceName,
14+
start,
15+
end,
16+
}: {
17+
serviceName?: string;
18+
start: string;
19+
end: string;
20+
}) {
21+
const [debouncedSearchValue, setDebouncedSearchValue] = useStateDebounced('');
22+
23+
const { data, status: searchStatus } = useFetcher(
24+
(callApmApi) => {
25+
return debouncedSearchValue.trim() === ''
26+
? Promise.resolve({ terms: [] })
27+
: callApmApi('GET /internal/apm/suggestions', {
28+
params: {
29+
query: {
30+
fieldName: SERVICE_ENVIRONMENT,
31+
fieldValue: debouncedSearchValue,
32+
serviceName,
33+
start,
34+
end,
35+
},
36+
},
37+
});
38+
},
39+
[debouncedSearchValue, start, end, serviceName]
40+
);
41+
42+
const onSearchChange = (value: string) => {
43+
setDebouncedSearchValue(value);
44+
};
45+
46+
return { data, searchStatus, onSearchChange };
47+
}

0 commit comments

Comments
 (0)