@orchestrator-ui/orchestrator-ui-components 8.9.1 → 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.
- package/.turbo/turbo-build.log +7 -7
- package/.turbo/turbo-lint.log +1 -1
- package/.turbo/turbo-test.log +291 -15
- package/CHANGELOG.md +16 -0
- package/dist/index.d.ts +17 -4
- package/dist/index.js +1662 -1463
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/WfoKeyValueTable/styles.ts +24 -0
- package/src/components/WfoPydanticForm/fields/WfoLabel.tsx +5 -1
- package/src/components/WfoPydanticForm/fields/styles.ts +12 -3
- package/src/components/WfoSearchPage/utils.ts +1 -0
- package/src/components/WfoSettingsModal/WfoInformationModal.tsx +3 -2
- 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 +196 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +71 -50
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +24 -90
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoRangeEditor.tsx +0 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +2 -2
- 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 +94 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +109 -1
- package/src/components/WfoTable/WfoTableSettingsModal/WfoTableSettingsModal.tsx +3 -2
- package/src/components/WfoTable/WfoTableSettingsModal/styles.ts +10 -0
- package/src/configuration/version.ts +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDebouncedCallback.ts +48 -0
- package/src/hooks/useGetPydanticFormsConfig.tsx +2 -1
- 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 +3 -1
- package/src/messages/nl-NL.json +3 -1
- package/src/pages/WfoSearchPocPage.tsx +8 -8
- package/src/types/search.ts +1 -2
|
@@ -1,22 +1,22 @@
|
|
|
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
14
|
import { SearchParams, WfoAutoExpandableTextArea, 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';
|
|
19
18
|
import type { FieldToOperatorMap, PathInfo, WfoQueryBuilderContext } from '@/types';
|
|
19
|
+
import { EntityKind } from '@/types';
|
|
20
20
|
|
|
21
21
|
import { WfoFieldSelector } from './WfoFieldSelector';
|
|
22
22
|
import { WfoInlineCombinator } from './WfoInlineCombinator';
|
|
@@ -26,41 +26,12 @@ import { WfoRule } from './WfoRule';
|
|
|
26
26
|
import { WfoRuleGroup } from './WfoRuleGroup';
|
|
27
27
|
import { WfoValueEditor } from './WfoValueEditor';
|
|
28
28
|
import { getWfoStructuredSearchTableStyles } from './styles';
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
has_component: 'notNull',
|
|
46
|
-
not_has_component: 'null',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// Operators without a value; marking them unary makes react-querybuilder's Rule hide the value editor.
|
|
50
|
-
const RQB_UNARY_OPERATORS = ['null', 'notNull'];
|
|
51
|
-
|
|
52
|
-
const OPERATOR_MAP: Record<string, OperatorDisplay> = {
|
|
53
|
-
eq: { symbol: '=', description: 'equals' },
|
|
54
|
-
neq: { symbol: '≠', description: 'not equals' },
|
|
55
|
-
lt: { symbol: '<', description: 'less than' },
|
|
56
|
-
lte: { symbol: '≤', description: 'less than or equal to' },
|
|
57
|
-
gt: { symbol: '>', description: 'greater than' },
|
|
58
|
-
gte: { symbol: '≥', description: 'greater than or equal to' },
|
|
59
|
-
between: { symbol: '⟷', description: 'between (range)' },
|
|
60
|
-
has_component: { symbol: '✓', description: 'has component' },
|
|
61
|
-
not_has_component: { symbol: '✗', description: 'does not have component' },
|
|
62
|
-
like: { symbol: '∋', description: 'contains' },
|
|
63
|
-
};
|
|
29
|
+
import {
|
|
30
|
+
collectRuleFields,
|
|
31
|
+
onAddGroupHandler,
|
|
32
|
+
operatorsToRQBOperatorOptionsMapper,
|
|
33
|
+
useSearchWithDebouncedCallback,
|
|
34
|
+
} from './utils';
|
|
64
35
|
|
|
65
36
|
interface WfoFilterBuilderProps {
|
|
66
37
|
filterString?: string;
|
|
@@ -76,15 +47,10 @@ interface WfoFilterBuilderProps {
|
|
|
76
47
|
|
|
77
48
|
const initialRuleGroup: RuleGroupType = {
|
|
78
49
|
id: 'root',
|
|
79
|
-
rules: [{ id: 'rule-0', field:
|
|
50
|
+
rules: [{ id: 'rule-0', field: defaultPlaceholderFieldName, operator: '=', value: '' }],
|
|
80
51
|
combinator: 'and',
|
|
81
52
|
};
|
|
82
53
|
|
|
83
|
-
const onAddGroupHandler = (ruleGroup: RuleGroupType): RuleGroupType => {
|
|
84
|
-
const [firstRule] = ruleGroup.rules;
|
|
85
|
-
return firstRule ? { ...ruleGroup, rules: [...ruleGroup.rules, { ...firstRule, id: generateID() }] } : ruleGroup;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
54
|
export const WfoFilterBuilder = ({
|
|
89
55
|
filterString,
|
|
90
56
|
onUpdateFilterString,
|
|
@@ -96,24 +62,9 @@ export const WfoFilterBuilder = ({
|
|
|
96
62
|
onToggleFilterBuilder,
|
|
97
63
|
useAdvancedNestedSearch = true,
|
|
98
64
|
}: WfoFilterBuilderProps) => {
|
|
99
|
-
const mapOperatorsToRQBOperatorOptions = (operators?: string[]): FullOperator[] => {
|
|
100
|
-
return (operators ?? []).map((operator) => {
|
|
101
|
-
const { symbol, description } = OPERATOR_MAP[operator] || { symbol: operator, description: operator };
|
|
102
|
-
const rqbOperator = SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP[operator] ?? operator;
|
|
103
|
-
return {
|
|
104
|
-
name: rqbOperator,
|
|
105
|
-
label: `${symbol} ${description}`,
|
|
106
|
-
value: rqbOperator,
|
|
107
|
-
...(RQB_UNARY_OPERATORS.includes(rqbOperator) && { arity: 'unary' }),
|
|
108
|
-
};
|
|
109
|
-
});
|
|
110
|
-
};
|
|
111
|
-
|
|
112
65
|
const t = useTranslations('common');
|
|
113
66
|
const { queryBuilderContainerStyles } = useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
|
|
114
67
|
const [fieldToOperatorMap, setFieldToOperatorMap] = useState<FieldToOperatorMap>(prefilledFieldOptions);
|
|
115
|
-
// Path info per selected field, so the value editor can pick a typed editor (date picker,
|
|
116
|
-
// number input, boolean toggle or range editor)
|
|
117
68
|
const [fieldPathInfoMap, setFieldPathInfoMap] = useState<Map<string, PathInfo>>(new Map());
|
|
118
69
|
|
|
119
70
|
// Enter in a value editor commits its value on blur, and that state update has not
|
|
@@ -123,9 +74,11 @@ export const WfoFilterBuilder = ({
|
|
|
123
74
|
const latestRuleGroupRef = useRef<RuleGroupType | undefined>(queryBuilderRuleGroup);
|
|
124
75
|
latestRuleGroupRef.current = queryBuilderRuleGroup;
|
|
125
76
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
77
|
+
const { handleSubmitSearchOnClick, pendingSearchRun, handleSubmitSearchOnEnter } = useSearchWithDebouncedCallback({
|
|
78
|
+
filterString,
|
|
79
|
+
isValidFilterString,
|
|
80
|
+
searchCallback: () => handleSearch({ ruleGroup: latestRuleGroupRef.current }),
|
|
81
|
+
});
|
|
129
82
|
|
|
130
83
|
const handleFieldSelected = (field: string, operators: string[], pathInfo?: PathInfo) => {
|
|
131
84
|
setFieldToOperatorMap((previousMap) => {
|
|
@@ -155,7 +108,6 @@ export const WfoFilterBuilder = ({
|
|
|
155
108
|
onFieldSelected: handleFieldSelected,
|
|
156
109
|
prefilledFieldOptions,
|
|
157
110
|
fieldPathInfoMap,
|
|
158
|
-
onValueEditorEnter: handleValueEditorEnter,
|
|
159
111
|
useAdvancedNestedSearch,
|
|
160
112
|
};
|
|
161
113
|
|
|
@@ -170,7 +122,7 @@ export const WfoFilterBuilder = ({
|
|
|
170
122
|
return (
|
|
171
123
|
<EuiFlexGroup css={queryBuilderContainerStyles}>
|
|
172
124
|
<EuiFlexGroup direction={'column'}>
|
|
173
|
-
<EuiFlexItem>
|
|
125
|
+
<EuiFlexItem onKeyDown={handleSubmitSearchOnEnter}>
|
|
174
126
|
<QueryBuilder
|
|
175
127
|
query={queryBuilderRuleGroup}
|
|
176
128
|
enableMountQueryChange={false}
|
|
@@ -181,11 +133,9 @@ export const WfoFilterBuilder = ({
|
|
|
181
133
|
context={queryBuilderContext}
|
|
182
134
|
getOperators={(field) => {
|
|
183
135
|
const operators = fieldToOperatorMap.get(field);
|
|
184
|
-
return
|
|
136
|
+
return operatorsToRQBOperatorOptionsMapper(operators);
|
|
185
137
|
}}
|
|
186
138
|
controlElements={{
|
|
187
|
-
// WfoFieldSelector requires the context this QueryBuilder always provides,
|
|
188
|
-
// while react-querybuilder declares it optional — hence the cast.
|
|
189
139
|
fieldSelector: WfoFieldSelector as ComponentType<FieldSelectorProps>,
|
|
190
140
|
operatorSelector: WfoOperatorSelector,
|
|
191
141
|
valueEditor: WfoValueEditor,
|
|
@@ -218,33 +168,17 @@ export const WfoFilterBuilder = ({
|
|
|
218
168
|
const filterString = e.target.value;
|
|
219
169
|
onUpdateFilterString(filterString);
|
|
220
170
|
}}
|
|
221
|
-
onKeyDown={
|
|
222
|
-
// Enter applies the filter like the Apply button (and like it, does nothing
|
|
223
|
-
// while the filter string is invalid); Shift+Enter inserts a newline.
|
|
224
|
-
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
225
|
-
event.preventDefault();
|
|
226
|
-
if (isValidFilterString) {
|
|
227
|
-
handleSearch();
|
|
228
|
-
}
|
|
229
|
-
}}
|
|
171
|
+
onKeyDown={handleSubmitSearchOnEnter}
|
|
230
172
|
isInvalid={!isValidFilterString}
|
|
231
173
|
/>
|
|
232
174
|
</EuiFlexItem>
|
|
233
175
|
|
|
234
176
|
<EuiFlexGroup direction={'rowReverse'} alignItems={'center'}>
|
|
235
|
-
<
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
data-test-id={'button-apply-filter'}
|
|
241
|
-
fill
|
|
242
|
-
type="submit"
|
|
243
|
-
aria-label={t('applyFilter')}
|
|
244
|
-
disabled={!isValidFilterString}
|
|
245
|
-
>
|
|
246
|
-
{t('applyFilter')}
|
|
247
|
-
</EuiButton>
|
|
177
|
+
<WfoApplyFilterButton
|
|
178
|
+
isDisabled={!isValidFilterString}
|
|
179
|
+
pendingSearchRun={pendingSearchRun}
|
|
180
|
+
onClick={handleSubmitSearchOnClick}
|
|
181
|
+
/>
|
|
248
182
|
<WfoTextAnchor
|
|
249
183
|
text={t('removeFilter')}
|
|
250
184
|
onClick={() => {
|
|
@@ -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]}`);
|
|
@@ -307,12 +307,12 @@ export const WfoStructuredSearchTable = <T extends object>({
|
|
|
307
307
|
{...tableProps}
|
|
308
308
|
/>
|
|
309
309
|
|
|
310
|
-
{totalItems && (
|
|
310
|
+
{(totalItems || data.length > 0) && (
|
|
311
311
|
<EuiFlexGroup alignItems={'center'} justifyContent={'center'} css={{ padding: theme.base }}>
|
|
312
312
|
<EuiButton onClick={() => onShowMore()} disabled={!hasNextPage || isLoading}>
|
|
313
313
|
{t('loadMore')}
|
|
314
314
|
</EuiButton>
|
|
315
|
-
<div>{`${data.length}/${totalItems} records`}</div>
|
|
315
|
+
<div>{totalItems ? `${data.length}/${totalItems} records` : `${data.length} records`}</div>
|
|
316
316
|
</EuiFlexGroup>
|
|
317
317
|
)}
|
|
318
318
|
|
|
@@ -85,7 +85,9 @@ const TextEditor = ({ handleOnChange, value: currentValue = '' }: EditorInputFie
|
|
|
85
85
|
const [value, setValue] = useState<string>(currentValue);
|
|
86
86
|
|
|
87
87
|
const handleTextChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
88
|
-
|
|
88
|
+
const nextValue = e.target.value || '';
|
|
89
|
+
setValue(nextValue);
|
|
90
|
+
handleOnChange(nextValue);
|
|
89
91
|
};
|
|
90
92
|
|
|
91
93
|
const handleOnBlur = () => {
|
|
@@ -105,7 +107,11 @@ const NumberEditor = ({ handleOnChange, value: currentValue }: EditorInputFieldP
|
|
|
105
107
|
const [value, setValue] = useState<string>(currentValue?.toString() || '');
|
|
106
108
|
|
|
107
109
|
const handleNumberChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
108
|
-
|
|
110
|
+
const nextValue = e.target.value || '';
|
|
111
|
+
setValue(nextValue);
|
|
112
|
+
const numberValue = parseFloat(nextValue);
|
|
113
|
+
if (Number.isNaN(numberValue)) return;
|
|
114
|
+
handleOnChange(numberValue);
|
|
109
115
|
};
|
|
110
116
|
|
|
111
117
|
const handleOnBlur = () => {
|
|
@@ -193,21 +199,11 @@ export const WfoValueEditor = ({
|
|
|
193
199
|
return <InputElement handleOnChange={handleOnChange} value={value} />;
|
|
194
200
|
};
|
|
195
201
|
|
|
196
|
-
const handleWrapperKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
|
197
|
-
if (event.key !== 'Enter') return;
|
|
198
|
-
// Restrict to the editor inputs: the editors' own Enter handlers have already run
|
|
199
|
-
// (bubble phase) and committed the value via blur, so the search sees it. Enter on
|
|
200
|
-
// the boolean buttons means "select" — its click fires only after this keydown, so
|
|
201
|
-
// searching there would use the pre-toggle value.
|
|
202
|
-
if (!(event.target instanceof HTMLInputElement)) return;
|
|
203
|
-
queryBuilderContext?.onValueEditorEnter();
|
|
204
|
-
};
|
|
205
|
-
|
|
206
202
|
// react-querybuilder delivers the standard `rule-value` class via this prop; the wrapper
|
|
207
203
|
// makes it queryable in the DOM (WfoFieldSelector relies on it to move focus here).
|
|
208
204
|
// `display: contents` keeps the children direct participants in the rule's flex row.
|
|
209
205
|
return (
|
|
210
|
-
<div className={className} style={{ display: 'contents' }}
|
|
206
|
+
<div className={className} style={{ display: 'contents' }}>
|
|
211
207
|
{getEditor()}
|
|
212
208
|
</div>
|
|
213
209
|
);
|
|
@@ -168,6 +168,12 @@ export const getWfoStructuredSearchTableStyles = ({ theme, isDarkModeActive }: W
|
|
|
168
168
|
padding: `${theme.base / 8}px ${theme.base / 4}px`,
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
const applyFilterContentStyles = css({
|
|
172
|
+
display: 'inline-flex',
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
gap: theme.base / 2,
|
|
175
|
+
});
|
|
176
|
+
|
|
171
177
|
const expandingRowFieldStyles = css({
|
|
172
178
|
display: 'flex',
|
|
173
179
|
padding: `${theme.base / 8}px ${theme.base / 4}px`,
|
|
@@ -193,6 +199,7 @@ export const getWfoStructuredSearchTableStyles = ({ theme, isDarkModeActive }: W
|
|
|
193
199
|
ruleGroupBodyGridStyles,
|
|
194
200
|
hideExpandedRowStyle,
|
|
195
201
|
expandingRowFieldStyles,
|
|
202
|
+
applyFilterContentStyles,
|
|
196
203
|
dotStyles,
|
|
197
204
|
};
|
|
198
205
|
};
|
package/src/components/WfoTable/WfoStructuredSearchTable/useSearchWithDebouncedCallback.spec.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import { act, renderHook } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
import { FILTER_CHANGE_DEBOUNCE_DELAY, useSearchWithDebouncedCallback } from './utils';
|
|
7
|
+
|
|
8
|
+
const renderSearchHook = (searchCallback: () => void, initialFilterString = 'subscription.status == "active"') =>
|
|
9
|
+
renderHook(
|
|
10
|
+
({ filterString }: { filterString: string }) =>
|
|
11
|
+
useSearchWithDebouncedCallback({ filterString, isValidFilterString: true, searchCallback }),
|
|
12
|
+
{ initialProps: { filterString: initialFilterString } },
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
describe('useSearchWithDebouncedCallback', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
jest.useFakeTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
jest.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('schedules a search when the filter string changes, and not for the one it starts with', () => {
|
|
25
|
+
const searchCallback = jest.fn();
|
|
26
|
+
const { result, rerender } = renderSearchHook(searchCallback);
|
|
27
|
+
|
|
28
|
+
act(() => {
|
|
29
|
+
jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
|
|
30
|
+
});
|
|
31
|
+
expect(searchCallback).not.toHaveBeenCalled();
|
|
32
|
+
|
|
33
|
+
rerender({ filterString: 'subscription.status == "activ"' });
|
|
34
|
+
expect(result.current.pendingSearchRun).toBeDefined();
|
|
35
|
+
|
|
36
|
+
act(() => {
|
|
37
|
+
jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
|
|
38
|
+
});
|
|
39
|
+
expect(searchCallback).toHaveBeenCalledTimes(1);
|
|
40
|
+
expect(result.current.pendingSearchRun).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('searches immediately on click, dropping the countdown and the run it had scheduled', () => {
|
|
44
|
+
const searchCallback = jest.fn();
|
|
45
|
+
const { result, rerender } = renderSearchHook(searchCallback);
|
|
46
|
+
|
|
47
|
+
rerender({ filterString: 'subscription.status == "activ"' });
|
|
48
|
+
expect(result.current.pendingSearchRun).toBeDefined();
|
|
49
|
+
|
|
50
|
+
act(() => {
|
|
51
|
+
result.current.handleSubmitSearchOnClick();
|
|
52
|
+
});
|
|
53
|
+
expect(searchCallback).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(result.current.pendingSearchRun).toBeUndefined();
|
|
55
|
+
|
|
56
|
+
// The scheduled run was cancelled rather than left to fire a second search.
|
|
57
|
+
act(() => {
|
|
58
|
+
jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
|
|
59
|
+
});
|
|
60
|
+
expect(searchCallback).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does the same on enter, and leaves shift+enter alone', () => {
|
|
64
|
+
const searchCallback = jest.fn();
|
|
65
|
+
const { result, rerender } = renderSearchHook(searchCallback);
|
|
66
|
+
|
|
67
|
+
rerender({ filterString: 'subscription.status == "activ"' });
|
|
68
|
+
|
|
69
|
+
act(() => {
|
|
70
|
+
result.current.handleSubmitSearchOnEnter({
|
|
71
|
+
key: 'Enter',
|
|
72
|
+
shiftKey: true,
|
|
73
|
+
preventDefault: jest.fn(),
|
|
74
|
+
} as unknown as React.KeyboardEvent<HTMLElement>);
|
|
75
|
+
});
|
|
76
|
+
expect(searchCallback).not.toHaveBeenCalled();
|
|
77
|
+
expect(result.current.pendingSearchRun).toBeDefined();
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
result.current.handleSubmitSearchOnEnter({
|
|
81
|
+
key: 'Enter',
|
|
82
|
+
shiftKey: false,
|
|
83
|
+
preventDefault: jest.fn(),
|
|
84
|
+
} as unknown as React.KeyboardEvent<HTMLElement>);
|
|
85
|
+
});
|
|
86
|
+
expect(searchCallback).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(result.current.pendingSearchRun).toBeUndefined();
|
|
88
|
+
|
|
89
|
+
act(() => {
|
|
90
|
+
jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
|
|
91
|
+
});
|
|
92
|
+
expect(searchCallback).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -1,7 +1,61 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type KeyboardEventHandler, useEffect, useRef } from 'react';
|
|
2
|
+
import { FullOperator, RuleGroupType, generateID } from 'react-querybuilder';
|
|
2
3
|
import { prepareRuleGroup } from 'react-querybuilder';
|
|
3
4
|
import { parseCEL } from 'react-querybuilder/parseCEL';
|
|
4
5
|
|
|
6
|
+
import { useDebouncedCallback } from '@/hooks';
|
|
7
|
+
import { OperatorDisplay } from '@/types';
|
|
8
|
+
|
|
9
|
+
export const FILTER_CHANGE_DEBOUNCE_DELAY = 1000;
|
|
10
|
+
|
|
11
|
+
interface SearchWithDebouncedCallbackProps {
|
|
12
|
+
filterString?: string;
|
|
13
|
+
isValidFilterString: boolean;
|
|
14
|
+
searchCallback: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useSearchWithDebouncedCallback = ({
|
|
18
|
+
filterString,
|
|
19
|
+
isValidFilterString,
|
|
20
|
+
searchCallback,
|
|
21
|
+
}: SearchWithDebouncedCallbackProps) => {
|
|
22
|
+
const {
|
|
23
|
+
trigger: triggerSearch,
|
|
24
|
+
cancel: cancelSearch,
|
|
25
|
+
pendingRun: pendingSearchRun,
|
|
26
|
+
} = useDebouncedCallback(searchCallback);
|
|
27
|
+
const lastFilterStringRef = useRef(filterString);
|
|
28
|
+
|
|
29
|
+
const handleSubmitSearchOnClick = () => {
|
|
30
|
+
if (!isValidFilterString) return;
|
|
31
|
+
triggerSearch();
|
|
32
|
+
return;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const hasFilterStringChanged = filterString !== lastFilterStringRef.current;
|
|
37
|
+
lastFilterStringRef.current = filterString;
|
|
38
|
+
|
|
39
|
+
if (!hasFilterStringChanged) return;
|
|
40
|
+
|
|
41
|
+
if (isValidFilterString) {
|
|
42
|
+
triggerSearch(FILTER_CHANGE_DEBOUNCE_DELAY);
|
|
43
|
+
} else {
|
|
44
|
+
cancelSearch();
|
|
45
|
+
}
|
|
46
|
+
}, [filterString, isValidFilterString, triggerSearch, cancelSearch]);
|
|
47
|
+
|
|
48
|
+
// Enter applies the filter exactly like that button; Shift+Enter is left alone so it can
|
|
49
|
+
// insert a newline in a textarea.
|
|
50
|
+
const handleSubmitSearchOnEnter: KeyboardEventHandler<HTMLElement> = (event) => {
|
|
51
|
+
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
handleSubmitSearchOnClick();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return { handleSubmitSearchOnClick, pendingSearchRun, handleSubmitSearchOnEnter };
|
|
57
|
+
};
|
|
58
|
+
|
|
5
59
|
/** Collects the unique field names used by the rules of a rule group, including nested groups. */
|
|
6
60
|
export const collectRuleFields = (ruleGroup: RuleGroupType): string[] => {
|
|
7
61
|
const fields = ruleGroup.rules.flatMap((rule) => {
|
|
@@ -82,3 +136,57 @@ export const buildColumnFilter = <T>(
|
|
|
82
136
|
|
|
83
137
|
return { filterString, ruleGroup };
|
|
84
138
|
};
|
|
139
|
+
|
|
140
|
+
// Maps PathInfo operator names to react-querybuilder's native operator names,
|
|
141
|
+
// which is what parseCEL produces and formatQuery(cel) expects.
|
|
142
|
+
// has_component/not_has_component ride on notNull/null: they survive the CEL round trip
|
|
143
|
+
// (`field != null` / `field == null`) and formatQuery(elasticsearch) turns them into
|
|
144
|
+
// exists / must_not-exists, which the backend translates to component-presence filters.
|
|
145
|
+
const SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP: Record<string, string> = {
|
|
146
|
+
eq: '=',
|
|
147
|
+
neq: '!=',
|
|
148
|
+
lt: '<',
|
|
149
|
+
lte: '<=',
|
|
150
|
+
gt: '>',
|
|
151
|
+
gte: '>=',
|
|
152
|
+
between: 'between',
|
|
153
|
+
like: 'contains',
|
|
154
|
+
not_regexp: 'doesNotContain',
|
|
155
|
+
has_component: 'notNull',
|
|
156
|
+
not_has_component: 'null',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Operators without a value; marking them unary makes react-querybuilder's Rule hide the value editor.
|
|
160
|
+
const RQB_UNARY_OPERATORS = ['null', 'notNull'];
|
|
161
|
+
|
|
162
|
+
const OPERATOR_MAP: Record<string, OperatorDisplay> = {
|
|
163
|
+
eq: { symbol: '=', description: 'equals' },
|
|
164
|
+
neq: { symbol: '≠', description: 'not equals' },
|
|
165
|
+
lt: { symbol: '<', description: 'less than' },
|
|
166
|
+
lte: { symbol: '≤', description: 'less than or equal to' },
|
|
167
|
+
gt: { symbol: '>', description: 'greater than' },
|
|
168
|
+
gte: { symbol: '≥', description: 'greater than or equal to' },
|
|
169
|
+
between: { symbol: '⟷', description: 'between (range)' },
|
|
170
|
+
has_component: { symbol: '✓', description: 'has component' },
|
|
171
|
+
not_has_component: { symbol: '✗', description: 'does not have component' },
|
|
172
|
+
like: { symbol: '∋', description: 'contains' },
|
|
173
|
+
not_regexp: { symbol: '∌', description: 'does not contain' },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const operatorsToRQBOperatorOptionsMapper = (operators?: string[]): FullOperator[] => {
|
|
177
|
+
return (operators ?? []).map((operator) => {
|
|
178
|
+
const { symbol, description } = OPERATOR_MAP[operator] || { symbol: operator, description: operator };
|
|
179
|
+
const rqbOperator = SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP[operator] ?? operator;
|
|
180
|
+
return {
|
|
181
|
+
name: rqbOperator,
|
|
182
|
+
label: `${symbol} ${description}`,
|
|
183
|
+
value: rqbOperator,
|
|
184
|
+
...(RQB_UNARY_OPERATORS.includes(rqbOperator) && { arity: 'unary' }),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const onAddGroupHandler = (ruleGroup: RuleGroupType): RuleGroupType => {
|
|
190
|
+
const [firstRule] = ruleGroup.rules;
|
|
191
|
+
return firstRule ? { ...ruleGroup, rules: [...ruleGroup.rules, { ...firstRule, id: generateID() }] } : ruleGroup;
|
|
192
|
+
};
|
|
@@ -37,7 +37,8 @@ export const TableSettingsModal = <T,>({
|
|
|
37
37
|
extraSettings,
|
|
38
38
|
}: TableSettingsModalProps<T>) => {
|
|
39
39
|
const t = useTranslations('main');
|
|
40
|
-
const { formRowStyle, columnsListStyle, selectFieldStyle } =
|
|
40
|
+
const { formStyle, formRowStyle, columnsListStyle, selectFieldStyle } =
|
|
41
|
+
useWithOrchestratorTheme(getWfoTableSettingsModalStyles);
|
|
41
42
|
const scrollBarStyle = useEuiScrollBar();
|
|
42
43
|
|
|
43
44
|
const [columns, setColumns] = useState(tableConfig.columns);
|
|
@@ -72,7 +73,7 @@ export const TableSettingsModal = <T,>({
|
|
|
72
73
|
})
|
|
73
74
|
}
|
|
74
75
|
>
|
|
75
|
-
<EuiForm>
|
|
76
|
+
<EuiForm css={formStyle}>
|
|
76
77
|
<div css={[columnsListStyle, scrollBarStyle]}>
|
|
77
78
|
{columns.map(({ field, name, isVisible }) => (
|
|
78
79
|
<div key={field.toString()}>
|
|
@@ -14,6 +14,15 @@ export const getWfoTableSettingsModalStyles = (wfoThemeHelpers: WfoThemeHelpers)
|
|
|
14
14
|
},
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
const formStyle = css({
|
|
18
|
+
'.euiFormRow .euiFormRow__labelWrapper': {
|
|
19
|
+
flexBasis: '50%',
|
|
20
|
+
},
|
|
21
|
+
'.euiFormRow .euiFormRow__fieldWrapper': {
|
|
22
|
+
flexBasis: '50%',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
17
26
|
const { theme } = wfoThemeHelpers;
|
|
18
27
|
|
|
19
28
|
const columnsListStyle = css({
|
|
@@ -30,6 +39,7 @@ export const getWfoTableSettingsModalStyles = (wfoThemeHelpers: WfoThemeHelpers)
|
|
|
30
39
|
});
|
|
31
40
|
|
|
32
41
|
return {
|
|
42
|
+
formStyle,
|
|
33
43
|
formRowStyle,
|
|
34
44
|
columnsListStyle,
|
|
35
45
|
selectFieldStyle: formFieldBaseStyle,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const ORCHESTRATOR_UI_LIBRARY_VERSION = '8.9.
|
|
1
|
+
export const ORCHESTRATOR_UI_LIBRARY_VERSION = '8.9.3';
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// The run currently scheduled: its `delay` lets a countdown indicator animate over exactly the
|
|
4
|
+
// remaining wait, and its `id` changes per scheduled run so the indicator can restart itself.
|
|
5
|
+
export type DebouncedPendingRun = {
|
|
6
|
+
id: number;
|
|
7
|
+
delay: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
interface DebouncedCallback {
|
|
11
|
+
trigger: (delay?: number) => void;
|
|
12
|
+
cancel: () => void;
|
|
13
|
+
pendingRun?: DebouncedPendingRun;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useDebouncedCallback(callback: () => void): DebouncedCallback {
|
|
17
|
+
const callbackRef = useRef(callback);
|
|
18
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
19
|
+
const runIdRef = useRef(0);
|
|
20
|
+
const [pendingRun, setPendingRun] = useState<DebouncedPendingRun>();
|
|
21
|
+
callbackRef.current = callback;
|
|
22
|
+
|
|
23
|
+
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
|
24
|
+
|
|
25
|
+
const trigger = useCallback((delay?: number) => {
|
|
26
|
+
clearTimeout(timeoutRef.current);
|
|
27
|
+
|
|
28
|
+
if (delay === undefined) {
|
|
29
|
+
setPendingRun(undefined);
|
|
30
|
+
callbackRef.current();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const id = ++runIdRef.current;
|
|
35
|
+
setPendingRun({ id, delay });
|
|
36
|
+
timeoutRef.current = setTimeout(() => {
|
|
37
|
+
setPendingRun(undefined);
|
|
38
|
+
callbackRef.current();
|
|
39
|
+
}, delay);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const cancel = useCallback(() => {
|
|
43
|
+
clearTimeout(timeoutRef.current);
|
|
44
|
+
setPendingRun(undefined);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return { trigger, cancel, pendingRun };
|
|
48
|
+
}
|
|
@@ -208,7 +208,8 @@ const useGetComponentMatcherExtender = (): ComponentMatcherExtender => {
|
|
|
208
208
|
...currentMatchers
|
|
209
209
|
.filter((matcher) => matcher.id !== 'text')
|
|
210
210
|
.filter((matcher) => matcher.id !== 'array')
|
|
211
|
-
.filter((matcher) => matcher.id !== 'object')
|
|
211
|
+
.filter((matcher) => matcher.id !== 'object')
|
|
212
|
+
.filter((matcher) => matcher.id !== 'list'),
|
|
212
213
|
{
|
|
213
214
|
id: 'object',
|
|
214
215
|
ElementMatch: {
|