@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.
- package/.turbo/turbo-build.log +7 -7
- package/.turbo/turbo-lint.log +1 -1
- package/.turbo/turbo-test.log +262 -19
- package/CHANGELOG.md +28 -0
- package/dist/index.d.ts +2082 -2505
- package/dist/index.js +2045 -2190
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/WfoError/WfoError.tsx +2 -2
- package/src/components/WfoInlineNoteEdit/WfoSubscriptionDetailNoteEdit.tsx +4 -8
- package/src/components/WfoInlineNoteEdit/WfoSubscriptionNoteEdit.tsx +6 -7
- package/src/components/WfoKeyValueTable/styles.ts +24 -0
- package/src/components/WfoPageTemplate/paths.ts +0 -1
- package/src/components/WfoPydanticForm/fields/WfoLabel.tsx +5 -1
- package/src/components/WfoPydanticForm/fields/styles.ts +12 -3
- package/src/components/WfoSettingsModal/WfoInformationModal.tsx +3 -2
- package/src/components/WfoSubscriptionsList/index.ts +1 -2
- package/src/components/WfoSubscriptionsList/subscriptionListItem.ts +14 -0
- package/src/components/WfoSubscriptionsList/subscriptionListTabs.ts +1 -20
- package/src/components/WfoSummary/WfoLatestActiveSubscriptionsSummaryCard.tsx +17 -13
- package/src/components/WfoSummary/WfoLatestOutOfSyncSubscriptionSummaryCard.tsx +28 -15
- package/src/components/WfoTable/WfoAdvancedTable/WfoAdvancedTable.tsx +5 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoApplyFilterButton.tsx +37 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoDebounceCountdown.tsx +58 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.spec.tsx +180 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +66 -51
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +33 -98
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoRangeEditor.tsx +0 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoRestoreLoop.spec.tsx +2 -2
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +5 -7
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +9 -13
- package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +7 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/useSearchWithDebouncedCallback.spec.tsx +147 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/utils.spec.ts +60 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +133 -1
- package/src/configuration/version.ts +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDebouncedCallback.ts +48 -0
- package/src/hooks/usePathAutoComplete.spec.tsx +94 -9
- package/src/hooks/usePathAutoComplete.ts +10 -15
- package/src/hooks/useSearchPagination.ts +1 -1
- package/src/messages/en-GB.json +4 -2
- package/src/messages/nl-NL.json +4 -2
- package/src/pages/index.ts +0 -1
- package/src/pages/startPage/index.ts +1 -0
- package/src/pages/startPage/mappers.ts +15 -8
- package/src/pages/startPage/queryVariables.ts +0 -29
- package/src/pages/startPage/searchPayloads.ts +22 -0
- package/src/pages/subscriptions/WfoSubscriptionsListPage.tsx +511 -39
- package/src/rtk/endpoints/index.ts +0 -1
- package/src/rtk/endpoints/search.ts +3 -0
- package/src/rtk/endpoints/subscriptionListMutation.spec.ts +80 -0
- package/src/rtk/endpoints/subscriptionListMutation.ts +71 -52
- package/src/rtk/utils.spec.ts +30 -1
- package/src/rtk/utils.ts +2 -1
- package/src/types/search.ts +1 -3
- package/src/types/types.ts +0 -2
- package/src/utils/getDefaultTableConfig.ts +1 -6
- package/src/utils/getQueryParams.ts +1 -0
- package/src/components/WfoSubscriptionsList/WfoSubscriptionsList.tsx +0 -243
- package/src/components/WfoSubscriptionsList/subscriptionResultMappers.ts +0 -49
- package/src/pages/WfoSearchPocPage.tsx +0 -575
- 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 {
|
|
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,
|
|
40
|
-
const [
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
76
|
-
|
|
70
|
+
|
|
71
|
+
const startTypingHintOption: EuiComboBoxOptionOption<string> = {
|
|
72
|
+
label: t('startTypingToLoadOptions'),
|
|
77
73
|
disabled: true,
|
|
78
74
|
};
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
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>(
|
|
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
|
|
129
|
-
|
|
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
|
|
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={
|
|
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
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
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
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|