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

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 (63) hide show
  1. package/.turbo/turbo-build.log +7 -7
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/.turbo/turbo-test.log +262 -19
  4. package/CHANGELOG.md +28 -0
  5. package/dist/index.d.ts +2082 -2505
  6. package/dist/index.js +2045 -2190
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/WfoError/WfoError.tsx +2 -2
  10. package/src/components/WfoInlineNoteEdit/WfoSubscriptionDetailNoteEdit.tsx +4 -8
  11. package/src/components/WfoInlineNoteEdit/WfoSubscriptionNoteEdit.tsx +6 -7
  12. package/src/components/WfoKeyValueTable/styles.ts +24 -0
  13. package/src/components/WfoPageTemplate/paths.ts +0 -1
  14. package/src/components/WfoPydanticForm/fields/WfoLabel.tsx +5 -1
  15. package/src/components/WfoPydanticForm/fields/styles.ts +12 -3
  16. package/src/components/WfoSettingsModal/WfoInformationModal.tsx +3 -2
  17. package/src/components/WfoSubscriptionsList/index.ts +1 -2
  18. package/src/components/WfoSubscriptionsList/subscriptionListItem.ts +14 -0
  19. package/src/components/WfoSubscriptionsList/subscriptionListTabs.ts +1 -20
  20. package/src/components/WfoSummary/WfoLatestActiveSubscriptionsSummaryCard.tsx +17 -13
  21. package/src/components/WfoSummary/WfoLatestOutOfSyncSubscriptionSummaryCard.tsx +28 -15
  22. package/src/components/WfoTable/WfoAdvancedTable/WfoAdvancedTable.tsx +5 -1
  23. package/src/components/WfoTable/WfoStructuredSearchTable/WfoApplyFilterButton.tsx +37 -0
  24. package/src/components/WfoTable/WfoStructuredSearchTable/WfoDebounceCountdown.tsx +58 -0
  25. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.spec.tsx +180 -0
  26. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +66 -51
  27. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +33 -98
  28. package/src/components/WfoTable/WfoStructuredSearchTable/WfoRangeEditor.tsx +0 -1
  29. package/src/components/WfoTable/WfoStructuredSearchTable/WfoRestoreLoop.spec.tsx +2 -2
  30. package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +5 -7
  31. package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +9 -13
  32. package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +7 -0
  33. package/src/components/WfoTable/WfoStructuredSearchTable/useSearchWithDebouncedCallback.spec.tsx +147 -0
  34. package/src/components/WfoTable/WfoStructuredSearchTable/utils.spec.ts +60 -1
  35. package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +133 -1
  36. package/src/configuration/version.ts +1 -1
  37. package/src/hooks/index.ts +1 -0
  38. package/src/hooks/useDebouncedCallback.ts +48 -0
  39. package/src/hooks/usePathAutoComplete.spec.tsx +94 -9
  40. package/src/hooks/usePathAutoComplete.ts +10 -15
  41. package/src/hooks/useSearchPagination.ts +1 -1
  42. package/src/messages/en-GB.json +4 -2
  43. package/src/messages/nl-NL.json +4 -2
  44. package/src/pages/index.ts +0 -1
  45. package/src/pages/startPage/index.ts +1 -0
  46. package/src/pages/startPage/mappers.ts +15 -8
  47. package/src/pages/startPage/queryVariables.ts +0 -29
  48. package/src/pages/startPage/searchPayloads.ts +22 -0
  49. package/src/pages/subscriptions/WfoSubscriptionsListPage.tsx +511 -39
  50. package/src/rtk/endpoints/index.ts +0 -1
  51. package/src/rtk/endpoints/search.ts +3 -0
  52. package/src/rtk/endpoints/subscriptionListMutation.spec.ts +80 -0
  53. package/src/rtk/endpoints/subscriptionListMutation.ts +71 -52
  54. package/src/rtk/utils.spec.ts +30 -1
  55. package/src/rtk/utils.ts +2 -1
  56. package/src/types/search.ts +1 -3
  57. package/src/types/types.ts +0 -2
  58. package/src/utils/getDefaultTableConfig.ts +1 -6
  59. package/src/utils/getQueryParams.ts +1 -0
  60. package/src/components/WfoSubscriptionsList/WfoSubscriptionsList.tsx +0 -243
  61. package/src/components/WfoSubscriptionsList/subscriptionResultMappers.ts +0 -49
  62. package/src/pages/WfoSearchPocPage.tsx +0 -575
  63. package/src/rtk/endpoints/subscriptionListSummary.ts +0 -66
@@ -0,0 +1,180 @@
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
+ // The mocked backend only knows saps, returned for any prefix of it.
42
+ const mockAutocomplete = (loading = false) =>
43
+ jest.mocked(usePathAutocomplete).mockImplementation((prefix) => ({
44
+ paths: prefix && 'saps'.startsWith(prefix) ? [SAPS_PATH_INFO] : [],
45
+ loading,
46
+ error: null,
47
+ }));
48
+
49
+ const renderFieldSelector = (field: string = defaultPlaceholderFieldName) => {
50
+ const handleOnChange = jest.fn();
51
+ const onFieldSelected = jest.fn();
52
+ const context: WfoQueryBuilderContext = {
53
+ onFieldSelected,
54
+ fieldPathInfoMap: new Map(),
55
+ useAdvancedNestedSearch: true,
56
+ };
57
+ const props = {
58
+ handleOnChange,
59
+ rule: { field, operator: '=', value: '' },
60
+ context,
61
+ } as unknown as ComponentProps<typeof WfoFieldSelector>;
62
+
63
+ render(<WfoFieldSelector {...props} />);
64
+ return { handleOnChange, onFieldSelected };
65
+ };
66
+
67
+ const getSearchInput = () => screen.getByRole('combobox');
68
+ const typeSearchTerm = (searchTerm: string) => fireEvent.change(getSearchInput(), { target: { value: searchTerm } });
69
+ const requestedPrefixes = () => jest.mocked(usePathAutocomplete).mock.calls.map(([prefix]) => prefix);
70
+ const listedOptions = () => screen.queryAllByRole('option').map((option) => option.textContent);
71
+
72
+ describe('WfoFieldSelector', () => {
73
+ beforeEach(() => mockAutocomplete());
74
+
75
+ it('does not look up the placeholder field and only shows a typing hint before typing', () => {
76
+ renderFieldSelector();
77
+
78
+ fireEvent.focus(getSearchInput());
79
+
80
+ expect(requestedPrefixes().every((prefix) => prefix === '')).toBe(true);
81
+ expect(listedOptions()).toEqual(['startTypingToLoadOptions']);
82
+ expect(screen.getByRole('option', { name: 'startTypingToLoadOptions' })).toHaveAttribute('aria-disabled', 'true');
83
+ });
84
+
85
+ it('shows the loading state instead of a no-match message while nothing is listed yet', () => {
86
+ mockAutocomplete(true);
87
+ renderFieldSelector();
88
+
89
+ typeSearchTerm('unknown');
90
+
91
+ expect(screen.getByText('Loading options')).toBeInTheDocument();
92
+ expect(listedOptions()).toEqual([]);
93
+ expect(screen.queryByText(/doesn't match any options/)).not.toBeInTheDocument();
94
+ });
95
+
96
+ it('shows the loading state when the previous paths do not match the new text', () => {
97
+ // The hook keeps the previous lookup's paths until the new ones arrive.
98
+ jest
99
+ .mocked(usePathAutocomplete)
100
+ .mockImplementation(() => ({ paths: [SAPS_PATH_INFO], loading: true, error: null }));
101
+ renderFieldSelector();
102
+
103
+ typeSearchTerm('sax');
104
+
105
+ expect(listedOptions()).toEqual([]);
106
+ expect(screen.getByText('Loading options')).toBeInTheDocument();
107
+ expect(screen.queryByText(/doesn't match any options/)).not.toBeInTheDocument();
108
+ });
109
+
110
+ it('keeps listed options visible while the next lookup runs and spins inside the field', () => {
111
+ mockAutocomplete(true);
112
+ renderFieldSelector();
113
+
114
+ typeSearchTerm('sa');
115
+
116
+ expect(listedOptions()).toEqual(['saps', 'saps.port']);
117
+ expect(screen.queryByText('Loading options')).not.toBeInTheDocument();
118
+ expect(document.querySelector('.euiFormControlLayoutIcons [role="progressbar"]')).toBeInTheDocument();
119
+ });
120
+
121
+ it('looks up the typed term and lists the returned paths', () => {
122
+ renderFieldSelector();
123
+
124
+ typeSearchTerm('sa');
125
+
126
+ expect(requestedPrefixes()).toContain('sa');
127
+ expect(listedOptions()).toEqual(['saps', 'saps.port']);
128
+ });
129
+
130
+ it('selects a listed path and reports its operators and path info', () => {
131
+ const { handleOnChange, onFieldSelected } = renderFieldSelector();
132
+
133
+ typeSearchTerm('sa');
134
+ fireEvent.click(screen.getByRole('option', { name: 'saps' }));
135
+
136
+ expect(handleOnChange).toHaveBeenCalledWith('saps');
137
+ expect(onFieldSelected).toHaveBeenCalledWith('saps', SAPS_PATH_INFO.operators, SAPS_PATH_INFO);
138
+ });
139
+
140
+ it('drops the options again as soon as the input is cleared', () => {
141
+ renderFieldSelector();
142
+
143
+ typeSearchTerm('sa');
144
+ expect(listedOptions()).toEqual(['saps', 'saps.port']);
145
+
146
+ typeSearchTerm('');
147
+
148
+ expect(listedOptions()).toEqual(['startTypingToLoadOptions']);
149
+ });
150
+
151
+ it('does not offer the raw typed text as an option and reports no match once loaded', () => {
152
+ renderFieldSelector();
153
+
154
+ typeSearchTerm('unknown');
155
+
156
+ expect(listedOptions()).toEqual([]);
157
+ expect(screen.getByText(/doesn't match any options/)).toBeInTheDocument();
158
+ });
159
+
160
+ it('shows a restored field as selected without an autocomplete request', () => {
161
+ renderFieldSelector('subscription.insync');
162
+
163
+ // A plain-text single selection is shown as the value of the search input.
164
+ expect(getSearchInput()).toHaveValue('subscription.insync');
165
+ expect(requestedPrefixes().every((prefix) => prefix === '')).toBe(true);
166
+ });
167
+
168
+ it('lists options for the selected field as soon as the selector is opened', () => {
169
+ renderFieldSelector('saps');
170
+
171
+ fireEvent.focus(getSearchInput());
172
+
173
+ expect(requestedPrefixes()).toContain('saps');
174
+ expect(listedOptions()).toEqual(['saps', 'saps.port']);
175
+
176
+ fireEvent.blur(getSearchInput());
177
+
178
+ expect(jest.mocked(usePathAutocomplete).mock.lastCall?.[0]).toBe('');
179
+ });
180
+ });
@@ -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';
@@ -36,8 +37,11 @@ interface WfoFieldSelectorProps extends Omit<FieldSelectorProps, 'context'> {
36
37
 
37
38
  export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, disabled, rule, context }) => {
38
39
  const { field } = rule;
39
- const { useAdvancedNestedSearch, prefilledFieldOptions, onFieldSelected } = context;
40
- const [selectedValue, setSelectedValue] = useState<string>(field);
40
+ const { useAdvancedNestedSearch, onFieldSelected } = context;
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,45 +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);
72
68
 
73
- const prefilledOptions: EuiComboBoxOptionOption<string>[] = Array.from(prefilledFieldOptions.keys()).map(getOption);
74
69
  const autocompleteOptions = getOptionsFromPathInfo(paths);
75
- const placeholderOption: EuiComboBoxOptionOption<string> = {
76
- label: '──────',
70
+
71
+ const startTypingHintOption: EuiComboBoxOptionOption<string> = {
72
+ label: t('startTypingToLoadOptions'),
77
73
  disabled: true,
78
74
  };
79
- const showPlaceholder = prefilledOptions.length > 0 && autocompleteOptions.length > 0;
80
- const options: EuiComboBoxOptionOption<string>[] = [
81
- ...prefilledOptions,
82
- ...(showPlaceholder ? [placeholderOption] : []),
83
- ...autocompleteOptions,
84
- ];
75
+ const options: EuiComboBoxOptionOption<string>[] = autocompletePrefix ? autocompleteOptions : [startTypingHintOption];
76
+
77
+ const renderHintOption = (option: EuiComboBoxOptionOption<string>) => (
78
+ <EuiText size="xs" color="default">
79
+ {option.label}
80
+ </EuiText>
81
+ );
82
+ const normalizedSearchTerm = trimmedSearchTerm.toLowerCase();
83
+ const hasListedOptions =
84
+ autocompletePrefix !== ''
85
+ && options.some(
86
+ (option) =>
87
+ !option.disabled && option.value !== selectedField && option.label.toLowerCase().includes(normalizedSearchTerm),
88
+ );
89
+ const showLoadingState = isLoading && !hasListedOptions;
90
+ const showFieldSpinner = isLoading && hasListedOptions;
91
+ const fieldIconsContainer = useMemo(
92
+ () => searchInput?.closest('.euiFormControlLayout')?.querySelector('.euiFormControlLayoutIcons') ?? null,
93
+ [searchInput],
94
+ );
85
95
 
86
96
  const storeFieldOperators = (selectedValue: string) => {
87
97
  const matchingPath =
88
98
  paths.find((path) => path.path === selectedValue)
89
99
  ?? paths.find((path) => path.availablePaths?.includes(selectedValue));
90
- const operators = matchingPath?.operators ?? prefilledFieldOptions.get(selectedValue) ?? [];
100
+ const operators = matchingPath?.operators ?? [];
91
101
 
92
102
  onFieldSelected(selectedValue, operators, matchingPath);
93
103
  };
@@ -95,7 +105,7 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
95
105
  const handleFieldSelection = (selectedOptions: EuiComboBoxOptionOption<string>[]) => {
96
106
  const selectedOption = selectedOptions[0];
97
107
  const selectedValue = selectedOption?.value || '';
98
- setSelectedValue(selectedValue);
108
+ setSearchTerm('');
99
109
  storeFieldOperators(selectedValue);
100
110
 
101
111
  handleOnChange(selectedValue);
@@ -143,26 +153,31 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
143
153
  }, [searchInput]);
144
154
 
145
155
  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
- />
156
+ <>
157
+ {showFieldSpinner
158
+ && fieldIconsContainer
159
+ && createPortal(<EuiLoadingSpinner size="m" aria-label={t('loadingOptions')} />, fieldIconsContainer)}
160
+ <EuiComboBox
161
+ placeholder={t('searchFieldsPlaceholder')}
162
+ options={options}
163
+ renderOption={autocompletePrefix ? undefined : renderHintOption}
164
+ fullWidth={true}
165
+ selectedOptions={selectedField ? [getOption(selectedField)] : []}
166
+ onChange={(selectedOptions) => {
167
+ handleFieldSelection(selectedOptions);
168
+ }}
169
+ onSearchChange={setSearchTerm}
170
+ onFocus={() => setHasFocus(true)}
171
+ onBlur={() => setHasFocus(false)}
172
+ inputRef={setSearchInput}
173
+ autoFocus={autoFocus}
174
+ singleSelection={{ asPlainText: true }}
175
+ isLoading={showLoadingState}
176
+ isClearable
177
+ isInvalid={!!errorMessage}
178
+ isDisabled={disabled}
179
+ rowHeight={30}
180
+ />
181
+ </>
167
182
  );
168
183
  };
@@ -1,22 +1,23 @@
1
1
  import React, { type ComponentType, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  type FieldSelectorProps,
4
- FullOperator,
5
4
  QueryBuilder,
6
5
  type RuleGroupType,
7
- generateID,
6
+ defaultPlaceholderFieldName,
8
7
  } from 'react-querybuilder';
9
8
  import 'react-querybuilder/dist/query-builder.css';
10
9
 
11
10
  import { useTranslations } from 'next-intl';
12
11
 
13
- import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
12
+ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
14
13
 
15
- import { SearchParams, WfoAutoExpandableTextArea, WfoTextAnchor } from '@/components';
14
+ import { SearchParams, WfoAutoExpandableTextArea, WfoErrorWithMessage, WfoTextAnchor } from '@/components';
15
+ import { WfoApplyFilterButton } from '@/components/WfoTable/WfoStructuredSearchTable/WfoApplyFilterButton';
16
16
  import { WfoCombinatorSelector } from '@/components/WfoTable/WfoStructuredSearchTable/WfoCombinatorSelector';
17
17
  import { useFieldsPathInfo, useWithOrchestratorTheme } from '@/hooks';
18
- import { EntityKind, OperatorDisplay } from '@/types';
18
+ import type { WfoGraphqlError } from '@/rtk';
19
19
  import type { FieldToOperatorMap, PathInfo, WfoQueryBuilderContext } from '@/types';
20
+ import { EntityKind } from '@/types';
20
21
 
21
22
  import { WfoFieldSelector } from './WfoFieldSelector';
22
23
  import { WfoInlineCombinator } from './WfoInlineCombinator';
@@ -26,43 +27,13 @@ import { WfoRule } from './WfoRule';
26
27
  import { WfoRuleGroup } from './WfoRuleGroup';
27
28
  import { WfoValueEditor } from './WfoValueEditor';
28
29
  import { getWfoStructuredSearchTableStyles } from './styles';
29
- import { collectRuleFields } from './utils';
30
-
31
- // Maps PathInfo operator names to react-querybuilder's native operator names,
32
- // which is what parseCEL produces and formatQuery(cel) expects.
33
- // has_component/not_has_component ride on notNull/null: they survive the CEL round trip
34
- // (`field != null` / `field == null`) and formatQuery(elasticsearch) turns them into
35
- // exists / must_not-exists, which the backend translates to component-presence filters.
36
- const SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP: Record<string, string> = {
37
- eq: '=',
38
- neq: '!=',
39
- lt: '<',
40
- lte: '<=',
41
- gt: '>',
42
- gte: '>=',
43
- between: 'between',
44
- like: 'contains',
45
- not_regexp: 'doesNotContain',
46
- has_component: 'notNull',
47
- not_has_component: 'null',
48
- };
49
-
50
- // Operators without a value; marking them unary makes react-querybuilder's Rule hide the value editor.
51
- const RQB_UNARY_OPERATORS = ['null', 'notNull'];
52
-
53
- const OPERATOR_MAP: Record<string, OperatorDisplay> = {
54
- eq: { symbol: '=', description: 'equals' },
55
- neq: { symbol: '≠', description: 'not equals' },
56
- lt: { symbol: '<', description: 'less than' },
57
- lte: { symbol: '≤', description: 'less than or equal to' },
58
- gt: { symbol: '>', description: 'greater than' },
59
- gte: { symbol: '≥', description: 'greater than or equal to' },
60
- between: { symbol: '⟷', description: 'between (range)' },
61
- has_component: { symbol: '✓', description: 'has component' },
62
- not_has_component: { symbol: '✗', description: 'does not have component' },
63
- like: { symbol: '∋', description: 'contains' },
64
- not_regexp: { symbol: '∌', description: 'does not contain' },
65
- };
30
+ import {
31
+ collectRuleFields,
32
+ hasNestedRuleWithEmptyValue,
33
+ onAddGroupHandler,
34
+ operatorsToRQBOperatorOptionsMapper,
35
+ useSearchWithDebouncedCallback,
36
+ } from './utils';
66
37
 
67
38
  interface WfoFilterBuilderProps {
68
39
  filterString?: string;
@@ -72,21 +43,16 @@ interface WfoFilterBuilderProps {
72
43
  onUpdateQueryBuilder: (ruleGroup: RuleGroupType | false) => void;
73
44
  handleSearch: (searchParams?: SearchParams) => void;
74
45
  onToggleFilterBuilder: (isVisible: boolean) => void;
75
- prefilledFieldOptions: FieldToOperatorMap;
76
46
  useAdvancedNestedSearch?: boolean;
47
+ error?: WfoGraphqlError[];
77
48
  }
78
49
 
79
50
  const initialRuleGroup: RuleGroupType = {
80
51
  id: 'root',
81
- rules: [{ id: 'rule-0', field: '~', operator: '=', value: '' }],
52
+ rules: [{ id: 'rule-0', field: defaultPlaceholderFieldName, operator: '=', value: '' }],
82
53
  combinator: 'and',
83
54
  };
84
55
 
85
- const onAddGroupHandler = (ruleGroup: RuleGroupType): RuleGroupType => {
86
- const [firstRule] = ruleGroup.rules;
87
- return firstRule ? { ...ruleGroup, rules: [...ruleGroup.rules, { ...firstRule, id: generateID() }] } : ruleGroup;
88
- };
89
-
90
56
  export const WfoFilterBuilder = ({
91
57
  filterString,
92
58
  onUpdateFilterString,
@@ -94,28 +60,13 @@ export const WfoFilterBuilder = ({
94
60
  queryBuilderRuleGroup = initialRuleGroup,
95
61
  onUpdateQueryBuilder,
96
62
  handleSearch,
97
- prefilledFieldOptions,
98
63
  onToggleFilterBuilder,
99
64
  useAdvancedNestedSearch = true,
65
+ error,
100
66
  }: WfoFilterBuilderProps) => {
101
- const mapOperatorsToRQBOperatorOptions = (operators?: string[]): FullOperator[] => {
102
- return (operators ?? []).map((operator) => {
103
- const { symbol, description } = OPERATOR_MAP[operator] || { symbol: operator, description: operator };
104
- const rqbOperator = SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP[operator] ?? operator;
105
- return {
106
- name: rqbOperator,
107
- label: `${symbol} ${description}`,
108
- value: rqbOperator,
109
- ...(RQB_UNARY_OPERATORS.includes(rqbOperator) && { arity: 'unary' }),
110
- };
111
- });
112
- };
113
-
114
67
  const t = useTranslations('common');
115
68
  const { queryBuilderContainerStyles } = useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
116
- const [fieldToOperatorMap, setFieldToOperatorMap] = useState<FieldToOperatorMap>(prefilledFieldOptions);
117
- // Path info per selected field, so the value editor can pick a typed editor (date picker,
118
- // number input, boolean toggle or range editor)
69
+ const [fieldToOperatorMap, setFieldToOperatorMap] = useState<FieldToOperatorMap>(new Map());
119
70
  const [fieldPathInfoMap, setFieldPathInfoMap] = useState<Map<string, PathInfo>>(new Map());
120
71
 
121
72
  // Enter in a value editor commits its value on blur, and that state update has not
@@ -125,9 +76,12 @@ export const WfoFilterBuilder = ({
125
76
  const latestRuleGroupRef = useRef<RuleGroupType | undefined>(queryBuilderRuleGroup);
126
77
  latestRuleGroupRef.current = queryBuilderRuleGroup;
127
78
 
128
- const handleValueEditorEnter = () => {
129
- handleSearch({ ruleGroup: latestRuleGroupRef.current });
130
- };
79
+ const { handleSubmitSearchOnClick, pendingSearchRun, handleSubmitSearchOnEnter } = useSearchWithDebouncedCallback({
80
+ filterString,
81
+ isValidFilterString,
82
+ hasEmptyRuleValue: hasNestedRuleWithEmptyValue(queryBuilderRuleGroup),
83
+ searchCallback: () => handleSearch({ ruleGroup: latestRuleGroupRef.current }),
84
+ });
131
85
 
132
86
  const handleFieldSelected = (field: string, operators: string[], pathInfo?: PathInfo) => {
133
87
  setFieldToOperatorMap((previousMap) => {
@@ -155,9 +109,7 @@ export const WfoFilterBuilder = ({
155
109
 
156
110
  const queryBuilderContext: WfoQueryBuilderContext = {
157
111
  onFieldSelected: handleFieldSelected,
158
- prefilledFieldOptions,
159
112
  fieldPathInfoMap,
160
- onValueEditorEnter: handleValueEditorEnter,
161
113
  useAdvancedNestedSearch,
162
114
  };
163
115
 
@@ -172,7 +124,7 @@ export const WfoFilterBuilder = ({
172
124
  return (
173
125
  <EuiFlexGroup css={queryBuilderContainerStyles}>
174
126
  <EuiFlexGroup direction={'column'}>
175
- <EuiFlexItem>
127
+ <EuiFlexItem onKeyDown={handleSubmitSearchOnEnter}>
176
128
  <QueryBuilder
177
129
  query={queryBuilderRuleGroup}
178
130
  enableMountQueryChange={false}
@@ -183,11 +135,9 @@ export const WfoFilterBuilder = ({
183
135
  context={queryBuilderContext}
184
136
  getOperators={(field) => {
185
137
  const operators = fieldToOperatorMap.get(field);
186
- return mapOperatorsToRQBOperatorOptions(operators);
138
+ return operatorsToRQBOperatorOptionsMapper(operators);
187
139
  }}
188
140
  controlElements={{
189
- // WfoFieldSelector requires the context this QueryBuilder always provides,
190
- // while react-querybuilder declares it optional — hence the cast.
191
141
  fieldSelector: WfoFieldSelector as ComponentType<FieldSelectorProps>,
192
142
  operatorSelector: WfoOperatorSelector,
193
143
  valueEditor: WfoValueEditor,
@@ -220,33 +170,17 @@ export const WfoFilterBuilder = ({
220
170
  const filterString = e.target.value;
221
171
  onUpdateFilterString(filterString);
222
172
  }}
223
- onKeyDown={(event) => {
224
- // Enter applies the filter like the Apply button (and like it, does nothing
225
- // while the filter string is invalid); Shift+Enter inserts a newline.
226
- if (event.key !== 'Enter' || event.shiftKey) return;
227
- event.preventDefault();
228
- if (isValidFilterString) {
229
- handleSearch();
230
- }
231
- }}
173
+ onKeyDown={handleSubmitSearchOnEnter}
232
174
  isInvalid={!isValidFilterString}
233
175
  />
234
176
  </EuiFlexItem>
235
177
 
236
- <EuiFlexGroup direction={'rowReverse'} alignItems={'center'}>
237
- <EuiButton
238
- onClick={() => {
239
- handleSearch();
240
- }}
241
- id={'button-apply-filter'}
242
- data-test-id={'button-apply-filter'}
243
- fill
244
- type="submit"
245
- aria-label={t('applyFilter')}
246
- disabled={!isValidFilterString}
247
- >
248
- {t('applyFilter')}
249
- </EuiButton>
178
+ <EuiFlexGroup direction={'rowReverse'} alignItems={'center'} gutterSize={'l'}>
179
+ <WfoApplyFilterButton
180
+ isDisabled={!isValidFilterString}
181
+ pendingSearchRun={pendingSearchRun}
182
+ onClick={handleSubmitSearchOnClick}
183
+ />
250
184
  <WfoTextAnchor
251
185
  text={t('removeFilter')}
252
186
  onClick={() => {
@@ -257,6 +191,7 @@ export const WfoFilterBuilder = ({
257
191
  onToggleFilterBuilder(false);
258
192
  }}
259
193
  />
194
+ <EuiFlexItem>{error && <WfoErrorWithMessage error={error} />}</EuiFlexItem>
260
195
  </EuiFlexGroup>
261
196
  </EuiFlexGroup>
262
197
  </EuiFlexGroup>
@@ -24,7 +24,6 @@ export const WfoRangeEditor = ({ handleOnChange, InputElement, value: currentVal
24
24
  });
25
25
  };
26
26
 
27
- // Notify the parent only when both ends of the range hold a value
28
27
  useEffect(() => {
29
28
  if (value[0] !== undefined && value[1] !== undefined) {
30
29
  handleOnChange(`${value[0]},${value[1]}`);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Regression test for the URL-restore render loop: a controlled QueryBuilder wired like
3
- * WfoSearchPocPage + WfoFilterBuilder, restoring `lldp == true` from CEL while the path
3
+ * WfoSubscriptionsListPage + WfoFilterBuilder, restoring `lldp == true` from CEL while the path
4
4
  * info of `lldp` resolves asynchronously (boolean ui type + operator list).
5
5
  *
6
6
  * The rule group is deliberately created with parseCEL directly — without the rule ids
@@ -80,7 +80,7 @@ const Harness = ({ initialCel = 'lldp == true' }: { initialCel?: string }) => {
80
80
  onQueryChange={(ruleGroup: RuleGroupType) => {
81
81
  queryChangeLog.push(JSON.stringify(ruleGroup));
82
82
  if (queryChangeLog.length > 20) return;
83
- // Mirrors WfoSearchPocPage.onUpdateQueryBuilder
83
+ // Mirrors WfoSubscriptionsListPage.onUpdateQueryBuilder
84
84
  setQuery({ ...ruleGroup });
85
85
  setFilterString(formatQuery({ ...ruleGroup }, { format: 'cel', fallbackExpression: '' }));
86
86
  }}
@@ -30,7 +30,7 @@ import {
30
30
  import { useOrchestratorTheme, useWithOrchestratorTheme } from '@/hooks';
31
31
  import { WfoArrowsExpand } from '@/icons';
32
32
  import { WfoGraphqlError } from '@/rtk';
33
- import { FieldToOperatorMap, RetrieverType } from '@/types';
33
+ import { RetrieverType } from '@/types';
34
34
  import { getDefaultTableConfig } from '@/utils';
35
35
 
36
36
  import { ColumnType, WfoTable, WfoTableProps } from '../WfoTable';
@@ -97,7 +97,6 @@ export type WfoStructuredSearchTableProps<T extends object> = Omit<
97
97
  setPageSize: (updatedPageSize: number) => void;
98
98
  totalItems: number | false;
99
99
  hasNextPage: boolean;
100
- prefilledFieldOptions: FieldToOperatorMap;
101
100
  };
102
101
 
103
102
  export const WfoStructuredSearchTable = <T extends object>({
@@ -131,7 +130,6 @@ export const WfoStructuredSearchTable = <T extends object>({
131
130
  hasNextPage,
132
131
  data,
133
132
  isLoading,
134
- prefilledFieldOptions,
135
133
  ...tableProps
136
134
  }: WfoStructuredSearchTableProps<T>) => {
137
135
  const { theme } = useOrchestratorTheme();
@@ -283,13 +281,13 @@ export const WfoStructuredSearchTable = <T extends object>({
283
281
  onUpdateQueryBuilder={onUpdateQueryBuilder}
284
282
  handleSearch={handleSearch}
285
283
  onToggleFilterBuilder={setIsFilterBuilderVisible}
286
- prefilledFieldOptions={prefilledFieldOptions}
287
284
  useAdvancedNestedSearch={advancedNestedSearch}
285
+ error={error}
288
286
  />
289
287
  </>
290
288
  )}
291
289
 
292
- {error && <WfoErrorWithMessage error={error} />}
290
+ {error && !isFilterBuilderVisible && <WfoErrorWithMessage error={error} />}
293
291
 
294
292
  <EuiSpacer size="m" />
295
293
 
@@ -307,12 +305,12 @@ export const WfoStructuredSearchTable = <T extends object>({
307
305
  {...tableProps}
308
306
  />
309
307
 
310
- {totalItems && (
308
+ {(totalItems || data.length > 0) && (
311
309
  <EuiFlexGroup alignItems={'center'} justifyContent={'center'} css={{ padding: theme.base }}>
312
310
  <EuiButton onClick={() => onShowMore()} disabled={!hasNextPage || isLoading}>
313
311
  {t('loadMore')}
314
312
  </EuiButton>
315
- <div>{`${data.length}/${totalItems} records`}</div>
313
+ <div>{totalItems ? `${data.length}/${totalItems} records` : `${data.length} records`}</div>
316
314
  </EuiFlexGroup>
317
315
  )}
318
316