@orchestrator-ui/orchestrator-ui-components 8.9.2 → 8.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +7 -7
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/.turbo/turbo-test.log +294 -18
  4. package/CHANGELOG.md +9 -0
  5. package/dist/index.d.ts +17 -4
  6. package/dist/index.js +1637 -1450
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/WfoKeyValueTable/styles.ts +24 -0
  10. package/src/components/WfoPydanticForm/fields/WfoLabel.tsx +5 -1
  11. package/src/components/WfoPydanticForm/fields/styles.ts +12 -3
  12. package/src/components/WfoSettingsModal/WfoInformationModal.tsx +3 -2
  13. package/src/components/WfoTable/WfoAdvancedTable/WfoAdvancedTable.tsx +5 -1
  14. package/src/components/WfoTable/WfoStructuredSearchTable/WfoApplyFilterButton.tsx +37 -0
  15. package/src/components/WfoTable/WfoStructuredSearchTable/WfoDebounceCountdown.tsx +58 -0
  16. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.spec.tsx +196 -0
  17. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +71 -50
  18. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +24 -92
  19. package/src/components/WfoTable/WfoStructuredSearchTable/WfoRangeEditor.tsx +0 -1
  20. package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +2 -2
  21. package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +9 -13
  22. package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +7 -0
  23. package/src/components/WfoTable/WfoStructuredSearchTable/useSearchWithDebouncedCallback.spec.tsx +94 -0
  24. package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +109 -1
  25. package/src/configuration/version.ts +1 -1
  26. package/src/hooks/index.ts +1 -0
  27. package/src/hooks/useDebouncedCallback.ts +48 -0
  28. package/src/hooks/usePathAutoComplete.spec.tsx +94 -9
  29. package/src/hooks/usePathAutoComplete.ts +10 -15
  30. package/src/hooks/useSearchPagination.ts +1 -1
  31. package/src/messages/en-GB.json +3 -1
  32. package/src/messages/nl-NL.json +3 -1
  33. package/src/types/search.ts +1 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchestrator-ui/orchestrator-ui-components",
3
- "version": "8.9.2",
3
+ "version": "8.9.3",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Library of UI Components used to display the workflow orchestrator frontend",
6
6
  "author": {
@@ -63,8 +63,32 @@ export const getStyles = ({ theme }: WfoThemeHelpers) => {
63
63
  color: theme.colors.textParagraph,
64
64
  display: 'flex',
65
65
  alignItems: 'center',
66
+ flexWrap: 'wrap',
67
+ gap: theme.size.s,
66
68
  overflowWrap: 'anywhere',
67
69
  whiteSpace: 'pre-wrap',
70
+
71
+ '& > *': {
72
+ maxWidth: '100%',
73
+ flexGrow: 1,
74
+ },
75
+
76
+ '& > .euiBadge + .euiBadge': {
77
+ marginInlineStart: 0,
78
+ },
79
+
80
+ '&:has(.euiInlineEdit), & > :has(.euiInlineEdit)': {
81
+ flexGrow: 1,
82
+ },
83
+
84
+ '.euiInlineEditText .euiButtonEmpty': {
85
+ blockSize: 'auto',
86
+ whiteSpace: 'normal',
87
+ textAlign: 'start',
88
+ },
89
+ '.euiInlineEditText .eui-textTruncate': {
90
+ whiteSpace: 'pre-wrap !important',
91
+ },
68
92
  });
69
93
 
70
94
  const clipboardIconStyle = css({
@@ -2,6 +2,8 @@ import React from 'react';
2
2
 
3
3
  import { PydanticFormElement } from 'pydantic-forms';
4
4
 
5
+ import { euiFontSizeFromScale } from '@elastic/eui';
6
+
5
7
  import { useOrchestratorTheme } from '@/hooks';
6
8
 
7
9
  export const WfoLabel: PydanticFormElement = ({ pydanticFormField }) => {
@@ -11,7 +13,9 @@ export const WfoLabel: PydanticFormElement = ({ pydanticFormField }) => {
11
13
  <div data-testid={pydanticFormField.id}>
12
14
  <label
13
15
  css={{
14
- color: theme.colors.textParagraph,
16
+ fontSize: euiFontSizeFromScale('m', theme),
17
+ fontWeight: theme.font.weight.semiBold,
18
+ color: theme.colors.link,
15
19
  display: 'block',
16
20
  }}
17
21
  id={pydanticFormField.id}
@@ -1,3 +1,4 @@
1
+ import { euiFontSizeFromScale, euiLineHeightFromBaseline } from '@elastic/eui';
1
2
  import { css } from '@emotion/react';
2
3
 
3
4
  import type { WfoThemeHelpers } from '@/hooks';
@@ -114,15 +115,23 @@ export const getCommonFormFieldStyles = ({ theme }: WfoThemeHelpers) => {
114
115
  color: theme.colors.textParagraph,
115
116
  },
116
117
  '.euiFormLabel': {
117
- color: theme.colors.textParagraph,
118
+ fontSize: euiFontSizeFromScale('m', theme),
119
+ lineHeight: euiLineHeightFromBaseline('m', theme),
120
+ color: theme.colors.link,
118
121
  cursor: 'text',
119
- '&.euiFormLabel-isFocused': {
120
- color: theme.colors.textPrimary,
122
+ '&.euiFormLabel-isInvalid': {
123
+ color: theme.colors.textDanger,
121
124
  },
122
125
  },
123
126
  '.euiFormRow__labelWrapper': {
124
127
  display: 'flex',
125
128
  flexDirection: 'column',
129
+ '> .euiText': {
130
+ fontSize: euiFontSizeFromScale('s', theme),
131
+ lineHeight: euiLineHeightFromBaseline('s', theme),
132
+ fontWeight: theme.font.weight.regular,
133
+ color: theme.colors.textParagraph,
134
+ },
126
135
  },
127
136
  });
128
137
 
@@ -14,11 +14,12 @@ export type WfoInformationModalProps = {
14
14
  title: string;
15
15
  onClose: () => void;
16
16
  children: ReactNode;
17
+ maxWidth?: number;
17
18
  };
18
19
 
19
- export const WfoInformationModal: FC<WfoInformationModalProps> = ({ title, onClose, children }) => {
20
+ export const WfoInformationModal: FC<WfoInformationModalProps> = ({ title, onClose, children, maxWidth }) => {
20
21
  return (
21
- <EuiModal onClose={onClose}>
22
+ <EuiModal onClose={onClose} maxWidth={maxWidth}>
22
23
  <EuiModalHeader>
23
24
  <EuiModalHeaderTitle size="xs">{title}</EuiModalHeaderTitle>
24
25
  </EuiModalHeader>
@@ -190,7 +190,11 @@ export const WfoAdvancedTable = <T extends object>({
190
190
  )}
191
191
 
192
192
  {rowDetailData && (
193
- <WfoInformationModal title={detailModalTitle} onClose={() => setSelectedDataForDetailModal(undefined)}>
193
+ <WfoInformationModal
194
+ title={detailModalTitle}
195
+ onClose={() => setSelectedDataForDetailModal(undefined)}
196
+ maxWidth={theme.breakpoint.m}
197
+ >
194
198
  <WfoKeyValueTable keyValues={rowDetailData} showCopyToClipboardIcon />
195
199
  </WfoInformationModal>
196
200
  )}
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+
5
+ import { EuiButton } from '@elastic/eui';
6
+
7
+ import { WfoDebounceCountdown } from '@/components/WfoTable/WfoStructuredSearchTable/WfoDebounceCountdown';
8
+ import { getWfoStructuredSearchTableStyles } from '@/components/WfoTable/WfoStructuredSearchTable/styles';
9
+ import { DebouncedPendingRun, useWithOrchestratorTheme } from '@/hooks';
10
+
11
+ interface ApplyFilterButtonProps {
12
+ isDisabled: boolean;
13
+ pendingSearchRun?: DebouncedPendingRun;
14
+ onClick: () => void;
15
+ }
16
+
17
+ export const WfoApplyFilterButton = ({ isDisabled, pendingSearchRun, onClick }: ApplyFilterButtonProps) => {
18
+ const t = useTranslations('common');
19
+ const { applyFilterContentStyles } = useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
20
+
21
+ return (
22
+ <EuiButton
23
+ onClick={onClick}
24
+ id={'button-apply-filter'}
25
+ data-test-id={'button-apply-filter'}
26
+ fill
27
+ type="submit"
28
+ aria-label={t('applyFilter')}
29
+ disabled={isDisabled}
30
+ >
31
+ <span css={applyFilterContentStyles}>
32
+ {pendingSearchRun && <WfoDebounceCountdown key={pendingSearchRun.id} durationMs={pendingSearchRun.delay} />}
33
+ {t('applyFilter')}
34
+ </span>
35
+ </EuiButton>
36
+ );
37
+ };
@@ -0,0 +1,58 @@
1
+ import React from 'react';
2
+
3
+ import { keyframes } from '@emotion/react';
4
+
5
+ const SIZE = 14;
6
+ const STROKE_WIDTH = 2;
7
+ const CENTER = SIZE / 2;
8
+ const RADIUS = (SIZE - STROKE_WIDTH) / 2;
9
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
10
+
11
+ const sweep = keyframes({
12
+ from: { strokeDashoffset: CIRCUMFERENCE },
13
+ to: { strokeDashoffset: 0 },
14
+ });
15
+
16
+ interface WfoDebounceCountdownProps {
17
+ durationMs: number;
18
+ }
19
+
20
+ /**
21
+ * Ring that fills over `durationMs`, showing how long is left before the pending search runs.
22
+ * Render it with a key that changes per scheduled run, so the sweep restarts from empty every
23
+ * time an edit pushes the debounce back.
24
+ */
25
+ export const WfoDebounceCountdown = ({ durationMs }: WfoDebounceCountdownProps) => (
26
+ <svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} aria-hidden="true" css={{ flex: 'none' }}>
27
+ <circle
28
+ cx={CENTER}
29
+ cy={CENTER}
30
+ r={RADIUS}
31
+ fill="none"
32
+ stroke="currentColor"
33
+ strokeWidth={STROKE_WIDTH}
34
+ opacity={0.3}
35
+ />
36
+ <circle
37
+ cx={CENTER}
38
+ cy={CENTER}
39
+ r={RADIUS}
40
+ fill="none"
41
+ stroke="currentColor"
42
+ strokeWidth={STROKE_WIDTH}
43
+ strokeLinecap="round"
44
+ strokeDasharray={CIRCUMFERENCE}
45
+ // Start the sweep at 12 o'clock instead of 3 o'clock.
46
+ transform={`rotate(-90 ${CENTER} ${CENTER})`}
47
+ css={{
48
+ strokeDashoffset: CIRCUMFERENCE,
49
+ animation: `${sweep} ${durationMs}ms linear forwards`,
50
+ // A ring that jumps straight to full is still an honest "a search is queued" marker.
51
+ '@media (prefers-reduced-motion: reduce)': {
52
+ animation: 'none',
53
+ strokeDashoffset: 0,
54
+ },
55
+ }}
56
+ />
57
+ </svg>
58
+ );
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Renders the filter builder's field selector on its own with the autocomplete hook mocked,
3
+ * so the tests control which paths the "backend" returns for a typed prefix.
4
+ */
5
+ import React, { type ComponentProps } from 'react';
6
+ import { defaultPlaceholderFieldName } from 'react-querybuilder';
7
+
8
+ import '@testing-library/jest-dom';
9
+ import { fireEvent, render, screen } from '@testing-library/react';
10
+
11
+ import { usePathAutocomplete } from '@/hooks';
12
+ import type { PathInfo, WfoQueryBuilderContext } from '@/types';
13
+
14
+ import { WfoFieldSelector } from './WfoFieldSelector';
15
+
16
+ jest.mock('@/hooks', () => ({
17
+ usePathAutocomplete: jest.fn(),
18
+ }));
19
+
20
+ jest.mock('next-intl', () => ({
21
+ useTranslations: () => (key: string) => key,
22
+ }));
23
+
24
+ // EuiComboBox sizes its search input by measuring text on a canvas, which jsdom does not implement.
25
+ beforeEach(() => {
26
+ jest
27
+ .spyOn(HTMLCanvasElement.prototype, 'getContext')
28
+ .mockReturnValue({ font: '', measureText: () => ({ width: 0 }) } as unknown as CanvasRenderingContext2D);
29
+ });
30
+
31
+ const SAPS_PATH_INFO = {
32
+ path: 'saps',
33
+ type: 'component',
34
+ operators: ['has_component', 'not_has_component'],
35
+ value_schema: {},
36
+ group: 'component',
37
+ ui_types: ['component'],
38
+ availablePaths: ['saps.port'],
39
+ } as unknown as PathInfo;
40
+
41
+ const PREFILLED_FIELD_OPTIONS = new Map([
42
+ ['subscription.insync', ['eq', 'neq']],
43
+ ['subscription.status', ['eq', 'neq', 'like']],
44
+ ]);
45
+
46
+ // The mocked backend only knows saps, returned for any prefix of it.
47
+ const mockAutocomplete = (loading = false) =>
48
+ jest.mocked(usePathAutocomplete).mockImplementation((prefix) => ({
49
+ paths: prefix && 'saps'.startsWith(prefix) ? [SAPS_PATH_INFO] : [],
50
+ loading,
51
+ error: null,
52
+ }));
53
+
54
+ const renderFieldSelector = (field: string = defaultPlaceholderFieldName) => {
55
+ const handleOnChange = jest.fn();
56
+ const onFieldSelected = jest.fn();
57
+ const context: WfoQueryBuilderContext = {
58
+ onFieldSelected,
59
+ prefilledFieldOptions: PREFILLED_FIELD_OPTIONS,
60
+ fieldPathInfoMap: new Map(),
61
+ useAdvancedNestedSearch: true,
62
+ };
63
+ const props = {
64
+ handleOnChange,
65
+ rule: { field, operator: '=', value: '' },
66
+ context,
67
+ } as unknown as ComponentProps<typeof WfoFieldSelector>;
68
+
69
+ render(<WfoFieldSelector {...props} />);
70
+ return { handleOnChange, onFieldSelected };
71
+ };
72
+
73
+ const getSearchInput = () => screen.getByRole('combobox');
74
+ const typeSearchTerm = (searchTerm: string) => fireEvent.change(getSearchInput(), { target: { value: searchTerm } });
75
+ const requestedPrefixes = () => jest.mocked(usePathAutocomplete).mock.calls.map(([prefix]) => prefix);
76
+ const listedOptions = () => screen.queryAllByRole('option').map((option) => option.textContent);
77
+
78
+ describe('WfoFieldSelector', () => {
79
+ beforeEach(() => mockAutocomplete());
80
+
81
+ it('does not look up the placeholder field and only shows a typing hint before typing', () => {
82
+ renderFieldSelector();
83
+
84
+ fireEvent.focus(getSearchInput());
85
+
86
+ expect(requestedPrefixes().every((prefix) => prefix === '')).toBe(true);
87
+ expect(listedOptions()).toEqual(['startTypingToLoadOptions']);
88
+ expect(screen.getByRole('option', { name: 'startTypingToLoadOptions' })).toHaveAttribute('aria-disabled', 'true');
89
+ });
90
+
91
+ it('shows the loading state instead of a no-match message while nothing is listed yet', () => {
92
+ mockAutocomplete(true);
93
+ renderFieldSelector();
94
+
95
+ typeSearchTerm('unknown');
96
+
97
+ expect(screen.getByText('Loading options')).toBeInTheDocument();
98
+ expect(listedOptions()).toEqual([]);
99
+ expect(screen.queryByText(/doesn't match any options/)).not.toBeInTheDocument();
100
+ });
101
+
102
+ it('shows the loading state when the previous paths do not match the new text', () => {
103
+ // The hook keeps the previous lookup's paths until the new ones arrive.
104
+ jest
105
+ .mocked(usePathAutocomplete)
106
+ .mockImplementation(() => ({ paths: [SAPS_PATH_INFO], loading: true, error: null }));
107
+ renderFieldSelector();
108
+
109
+ typeSearchTerm('sax');
110
+
111
+ expect(listedOptions()).toEqual([]);
112
+ expect(screen.getByText('Loading options')).toBeInTheDocument();
113
+ expect(screen.queryByText(/doesn't match any options/)).not.toBeInTheDocument();
114
+ });
115
+
116
+ it('keeps listed options visible while the next lookup runs and spins inside the field', () => {
117
+ mockAutocomplete(true);
118
+ renderFieldSelector();
119
+
120
+ typeSearchTerm('sa');
121
+
122
+ expect(listedOptions()).toEqual(['saps', 'saps.port']);
123
+ expect(screen.queryByText('Loading options')).not.toBeInTheDocument();
124
+ expect(document.querySelector('.euiFormControlLayoutIcons [role="progressbar"]')).toBeInTheDocument();
125
+ });
126
+
127
+ it('looks up the typed term and lists the returned paths', () => {
128
+ renderFieldSelector();
129
+
130
+ typeSearchTerm('sa');
131
+
132
+ expect(requestedPrefixes()).toContain('sa');
133
+ expect(listedOptions()).toEqual(['saps', 'saps.port']);
134
+ });
135
+
136
+ it('selects a listed path and reports its operators and path info', () => {
137
+ const { handleOnChange, onFieldSelected } = renderFieldSelector();
138
+
139
+ typeSearchTerm('sa');
140
+ fireEvent.click(screen.getByRole('option', { name: 'saps' }));
141
+
142
+ expect(handleOnChange).toHaveBeenCalledWith('saps');
143
+ expect(onFieldSelected).toHaveBeenCalledWith('saps', SAPS_PATH_INFO.operators, SAPS_PATH_INFO);
144
+ });
145
+
146
+ it('drops the options again as soon as the input is cleared', () => {
147
+ renderFieldSelector();
148
+
149
+ typeSearchTerm('sa');
150
+ expect(listedOptions()).toEqual(['saps', 'saps.port']);
151
+
152
+ typeSearchTerm('');
153
+
154
+ expect(listedOptions()).toEqual(['startTypingToLoadOptions']);
155
+ });
156
+
157
+ it('lists matching prefilled fields alongside the backend paths', () => {
158
+ renderFieldSelector();
159
+
160
+ typeSearchTerm('ins');
161
+ expect(listedOptions()).toEqual(['subscription.insync']);
162
+
163
+ typeSearchTerm('s');
164
+ expect(listedOptions()).toEqual(['subscription.insync', 'subscription.status', 'saps', 'saps.port']);
165
+ });
166
+
167
+ it('does not offer the raw typed text as an option and reports no match once loaded', () => {
168
+ renderFieldSelector();
169
+
170
+ typeSearchTerm('unknown');
171
+
172
+ expect(listedOptions()).toEqual([]);
173
+ expect(screen.getByText(/doesn't match any options/)).toBeInTheDocument();
174
+ });
175
+
176
+ it('shows a restored field as selected without an autocomplete request', () => {
177
+ renderFieldSelector('subscription.insync');
178
+
179
+ // A plain-text single selection is shown as the value of the search input.
180
+ expect(getSearchInput()).toHaveValue('subscription.insync');
181
+ expect(requestedPrefixes().every((prefix) => prefix === '')).toBe(true);
182
+ });
183
+
184
+ it('lists options for the selected field as soon as the selector is opened', () => {
185
+ renderFieldSelector('saps');
186
+
187
+ fireEvent.focus(getSearchInput());
188
+
189
+ expect(requestedPrefixes()).toContain('saps');
190
+ expect(listedOptions()).toEqual(['saps', 'saps.port']);
191
+
192
+ fireEvent.blur(getSearchInput());
193
+
194
+ expect(jest.mocked(usePathAutocomplete).mock.lastCall?.[0]).toBe('');
195
+ });
196
+ });
@@ -1,10 +1,11 @@
1
- import React, { FC, useEffect, useRef, useState } from 'react';
2
- import { FieldSelectorProps } from 'react-querybuilder';
1
+ import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { FieldSelectorProps, defaultPlaceholderFieldName } from 'react-querybuilder';
3
4
 
4
5
  import { useTranslations } from 'next-intl';
5
6
 
6
7
  import type { EuiComboBoxOptionOption } from '@elastic/eui';
7
- import { EuiComboBox } from '@elastic/eui';
8
+ import { EuiComboBox, EuiLoadingSpinner, EuiText } from '@elastic/eui';
8
9
 
9
10
  import { usePathAutocomplete } from '@/hooks';
10
11
  import { EntityKind, PathInfo, WfoQueryBuilderContext } from '@/types';
@@ -37,7 +38,10 @@ interface WfoFieldSelectorProps extends Omit<FieldSelectorProps, 'context'> {
37
38
  export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, disabled, rule, context }) => {
38
39
  const { field } = rule;
39
40
  const { useAdvancedNestedSearch, prefilledFieldOptions, onFieldSelected } = context;
40
- const [selectedValue, setSelectedValue] = useState<string>(field);
41
+ const [autoFocus] = useState(field === defaultPlaceholderFieldName);
42
+ const selectedField = field === defaultPlaceholderFieldName ? '' : field;
43
+ const [searchTerm, setSearchTerm] = useState('');
44
+ const [hasFocus, setHasFocus] = useState(false);
41
45
  const [searchInput, setSearchInput] = useState<HTMLInputElement | null>(null);
42
46
  const optionsRef = useRef<EuiComboBoxOptionOption<string>[]>([]);
43
47
  const handleFieldSelectionRef = useRef<(selected: EuiComboBoxOptionOption<string>[]) => void>(() => {});
@@ -49,39 +53,51 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
49
53
 
50
54
  const isSelectablePath = (path: string) => useAdvancedNestedSearch || !path.includes('.');
51
55
 
52
- const getOptionsFromPathInfo = (pathInfos: PathInfo[]): EuiComboBoxOptionOption<string>[] => {
53
- const pathOptions: EuiComboBoxOptionOption<string>[] = [];
54
-
55
- pathInfos.forEach((pathInfo) => {
56
- [pathInfo.path, ...(pathInfo.availablePaths ?? [])].filter(isSelectablePath).forEach((path) => {
57
- pathOptions.push(getOption(path));
58
- });
59
- });
60
- return (
61
- pathOptions.length > 0 ? pathOptions
62
- : selectedValue ? [getOption(selectedValue)]
63
- : []
56
+ const getOptionsFromPathInfo = (pathInfos: PathInfo[]): EuiComboBoxOptionOption<string>[] =>
57
+ pathInfos.flatMap((pathInfo) =>
58
+ [pathInfo.path, ...(pathInfo.availablePaths ?? [])].filter(isSelectablePath).map(getOption),
64
59
  );
65
- };
66
60
 
61
+ const trimmedSearchTerm = searchTerm.trim();
62
+ const autocompletePrefix = trimmedSearchTerm || (hasFocus ? selectedField : '');
67
63
  const {
68
64
  paths,
69
65
  loading: isLoading,
70
66
  error: errorMessage,
71
- } = usePathAutocomplete(selectedValue, EntityKind.SUBSCRIPTION);
67
+ } = usePathAutocomplete(autocompletePrefix, EntityKind.SUBSCRIPTION);
68
+
69
+ const matchesPrefix = (path: string) => path.toLowerCase().includes(autocompletePrefix.toLowerCase());
70
+
71
+ const prefilledFields = Array.from(prefilledFieldOptions.keys()).filter(matchesPrefix);
72
+ const autocompleteOptions = getOptionsFromPathInfo(paths).filter(
73
+ (option) => !prefilledFields.includes(option.value ?? ''),
74
+ );
72
75
 
73
- const prefilledOptions: EuiComboBoxOptionOption<string>[] = Array.from(prefilledFieldOptions.keys()).map(getOption);
74
- const autocompleteOptions = getOptionsFromPathInfo(paths);
75
- const placeholderOption: EuiComboBoxOptionOption<string> = {
76
- label: '──────',
76
+ const startTypingHintOption: EuiComboBoxOptionOption<string> = {
77
+ label: t('startTypingToLoadOptions'),
77
78
  disabled: true,
78
79
  };
79
- const showPlaceholder = prefilledOptions.length > 0 && autocompleteOptions.length > 0;
80
- const options: EuiComboBoxOptionOption<string>[] = [
81
- ...prefilledOptions,
82
- ...(showPlaceholder ? [placeholderOption] : []),
83
- ...autocompleteOptions,
84
- ];
80
+ const options: EuiComboBoxOptionOption<string>[] =
81
+ autocompletePrefix ? [...prefilledFields.map(getOption), ...autocompleteOptions] : [startTypingHintOption];
82
+
83
+ const renderHintOption = (option: EuiComboBoxOptionOption<string>) => (
84
+ <EuiText size="xs" color="default">
85
+ {option.label}
86
+ </EuiText>
87
+ );
88
+ const normalizedSearchTerm = trimmedSearchTerm.toLowerCase();
89
+ const hasListedOptions =
90
+ autocompletePrefix !== ''
91
+ && options.some(
92
+ (option) =>
93
+ !option.disabled && option.value !== selectedField && option.label.toLowerCase().includes(normalizedSearchTerm),
94
+ );
95
+ const showLoadingState = isLoading && !hasListedOptions;
96
+ const showFieldSpinner = isLoading && hasListedOptions;
97
+ const fieldIconsContainer = useMemo(
98
+ () => searchInput?.closest('.euiFormControlLayout')?.querySelector('.euiFormControlLayoutIcons') ?? null,
99
+ [searchInput],
100
+ );
85
101
 
86
102
  const storeFieldOperators = (selectedValue: string) => {
87
103
  const matchingPath =
@@ -95,7 +111,7 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
95
111
  const handleFieldSelection = (selectedOptions: EuiComboBoxOptionOption<string>[]) => {
96
112
  const selectedOption = selectedOptions[0];
97
113
  const selectedValue = selectedOption?.value || '';
98
- setSelectedValue(selectedValue);
114
+ setSearchTerm('');
99
115
  storeFieldOperators(selectedValue);
100
116
 
101
117
  handleOnChange(selectedValue);
@@ -143,26 +159,31 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
143
159
  }, [searchInput]);
144
160
 
145
161
  return (
146
- <EuiComboBox
147
- placeholder={t('searchFieldsPlaceholder')}
148
- options={options}
149
- fullWidth={true}
150
- selectedOptions={options.filter((option) => option.value === selectedValue)}
151
- onChange={(selectedOptions) => {
152
- handleFieldSelection(selectedOptions);
153
- }}
154
- onSearchChange={(inputValue) => {
155
- if (inputValue.length > 0) {
156
- setSelectedValue(inputValue);
157
- }
158
- }}
159
- inputRef={setSearchInput}
160
- singleSelection={{ asPlainText: true }}
161
- isLoading={isLoading}
162
- isClearable
163
- isInvalid={!!errorMessage}
164
- isDisabled={disabled}
165
- rowHeight={30}
166
- />
162
+ <>
163
+ {showFieldSpinner
164
+ && fieldIconsContainer
165
+ && createPortal(<EuiLoadingSpinner size="m" aria-label={t('loadingOptions')} />, fieldIconsContainer)}
166
+ <EuiComboBox
167
+ placeholder={t('searchFieldsPlaceholder')}
168
+ options={options}
169
+ renderOption={autocompletePrefix ? undefined : renderHintOption}
170
+ fullWidth={true}
171
+ selectedOptions={selectedField ? [getOption(selectedField)] : []}
172
+ onChange={(selectedOptions) => {
173
+ handleFieldSelection(selectedOptions);
174
+ }}
175
+ onSearchChange={setSearchTerm}
176
+ onFocus={() => setHasFocus(true)}
177
+ onBlur={() => setHasFocus(false)}
178
+ inputRef={setSearchInput}
179
+ autoFocus={autoFocus}
180
+ singleSelection={{ asPlainText: true }}
181
+ isLoading={showLoadingState}
182
+ isClearable
183
+ isInvalid={!!errorMessage}
184
+ isDisabled={disabled}
185
+ rowHeight={30}
186
+ />
187
+ </>
167
188
  );
168
189
  };