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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +7 -7
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/.turbo/turbo-test.log +294 -18
  4. package/CHANGELOG.md +9 -0
  5. package/dist/index.d.ts +17 -4
  6. package/dist/index.js +1637 -1450
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/WfoKeyValueTable/styles.ts +24 -0
  10. package/src/components/WfoPydanticForm/fields/WfoLabel.tsx +5 -1
  11. package/src/components/WfoPydanticForm/fields/styles.ts +12 -3
  12. package/src/components/WfoSettingsModal/WfoInformationModal.tsx +3 -2
  13. package/src/components/WfoTable/WfoAdvancedTable/WfoAdvancedTable.tsx +5 -1
  14. package/src/components/WfoTable/WfoStructuredSearchTable/WfoApplyFilterButton.tsx +37 -0
  15. package/src/components/WfoTable/WfoStructuredSearchTable/WfoDebounceCountdown.tsx +58 -0
  16. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.spec.tsx +196 -0
  17. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +71 -50
  18. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +24 -92
  19. package/src/components/WfoTable/WfoStructuredSearchTable/WfoRangeEditor.tsx +0 -1
  20. package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +2 -2
  21. package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +9 -13
  22. package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +7 -0
  23. package/src/components/WfoTable/WfoStructuredSearchTable/useSearchWithDebouncedCallback.spec.tsx +94 -0
  24. package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +109 -1
  25. package/src/configuration/version.ts +1 -1
  26. package/src/hooks/index.ts +1 -0
  27. package/src/hooks/useDebouncedCallback.ts +48 -0
  28. package/src/hooks/usePathAutoComplete.spec.tsx +94 -9
  29. package/src/hooks/usePathAutoComplete.ts +10 -15
  30. package/src/hooks/useSearchPagination.ts +1 -1
  31. package/src/messages/en-GB.json +3 -1
  32. package/src/messages/nl-NL.json +3 -1
  33. package/src/types/search.ts +1 -2
@@ -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
- 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
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,43 +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 { 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
- };
29
+ import {
30
+ collectRuleFields,
31
+ onAddGroupHandler,
32
+ operatorsToRQBOperatorOptionsMapper,
33
+ useSearchWithDebouncedCallback,
34
+ } from './utils';
66
35
 
67
36
  interface WfoFilterBuilderProps {
68
37
  filterString?: string;
@@ -78,15 +47,10 @@ interface WfoFilterBuilderProps {
78
47
 
79
48
  const initialRuleGroup: RuleGroupType = {
80
49
  id: 'root',
81
- rules: [{ id: 'rule-0', field: '~', operator: '=', value: '' }],
50
+ rules: [{ id: 'rule-0', field: defaultPlaceholderFieldName, operator: '=', value: '' }],
82
51
  combinator: 'and',
83
52
  };
84
53
 
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
54
  export const WfoFilterBuilder = ({
91
55
  filterString,
92
56
  onUpdateFilterString,
@@ -98,24 +62,9 @@ export const WfoFilterBuilder = ({
98
62
  onToggleFilterBuilder,
99
63
  useAdvancedNestedSearch = true,
100
64
  }: 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
65
  const t = useTranslations('common');
115
66
  const { queryBuilderContainerStyles } = useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
116
67
  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)
119
68
  const [fieldPathInfoMap, setFieldPathInfoMap] = useState<Map<string, PathInfo>>(new Map());
120
69
 
121
70
  // Enter in a value editor commits its value on blur, and that state update has not
@@ -125,9 +74,11 @@ export const WfoFilterBuilder = ({
125
74
  const latestRuleGroupRef = useRef<RuleGroupType | undefined>(queryBuilderRuleGroup);
126
75
  latestRuleGroupRef.current = queryBuilderRuleGroup;
127
76
 
128
- const handleValueEditorEnter = () => {
129
- handleSearch({ ruleGroup: latestRuleGroupRef.current });
130
- };
77
+ const { handleSubmitSearchOnClick, pendingSearchRun, handleSubmitSearchOnEnter } = useSearchWithDebouncedCallback({
78
+ filterString,
79
+ isValidFilterString,
80
+ searchCallback: () => handleSearch({ ruleGroup: latestRuleGroupRef.current }),
81
+ });
131
82
 
132
83
  const handleFieldSelected = (field: string, operators: string[], pathInfo?: PathInfo) => {
133
84
  setFieldToOperatorMap((previousMap) => {
@@ -157,7 +108,6 @@ export const WfoFilterBuilder = ({
157
108
  onFieldSelected: handleFieldSelected,
158
109
  prefilledFieldOptions,
159
110
  fieldPathInfoMap,
160
- onValueEditorEnter: handleValueEditorEnter,
161
111
  useAdvancedNestedSearch,
162
112
  };
163
113
 
@@ -172,7 +122,7 @@ export const WfoFilterBuilder = ({
172
122
  return (
173
123
  <EuiFlexGroup css={queryBuilderContainerStyles}>
174
124
  <EuiFlexGroup direction={'column'}>
175
- <EuiFlexItem>
125
+ <EuiFlexItem onKeyDown={handleSubmitSearchOnEnter}>
176
126
  <QueryBuilder
177
127
  query={queryBuilderRuleGroup}
178
128
  enableMountQueryChange={false}
@@ -183,11 +133,9 @@ export const WfoFilterBuilder = ({
183
133
  context={queryBuilderContext}
184
134
  getOperators={(field) => {
185
135
  const operators = fieldToOperatorMap.get(field);
186
- return mapOperatorsToRQBOperatorOptions(operators);
136
+ return operatorsToRQBOperatorOptionsMapper(operators);
187
137
  }}
188
138
  controlElements={{
189
- // WfoFieldSelector requires the context this QueryBuilder always provides,
190
- // while react-querybuilder declares it optional — hence the cast.
191
139
  fieldSelector: WfoFieldSelector as ComponentType<FieldSelectorProps>,
192
140
  operatorSelector: WfoOperatorSelector,
193
141
  valueEditor: WfoValueEditor,
@@ -220,33 +168,17 @@ export const WfoFilterBuilder = ({
220
168
  const filterString = e.target.value;
221
169
  onUpdateFilterString(filterString);
222
170
  }}
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
- }}
171
+ onKeyDown={handleSubmitSearchOnEnter}
232
172
  isInvalid={!isValidFilterString}
233
173
  />
234
174
  </EuiFlexItem>
235
175
 
236
176
  <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>
177
+ <WfoApplyFilterButton
178
+ isDisabled={!isValidFilterString}
179
+ pendingSearchRun={pendingSearchRun}
180
+ onClick={handleSubmitSearchOnClick}
181
+ />
250
182
  <WfoTextAnchor
251
183
  text={t('removeFilter')}
252
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
- setValue(e.target.value || '');
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
- setValue(e.target.value || '');
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' }} onKeyDown={handleWrapperKeyDown}>
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
  };
@@ -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 { RuleGroupType } from 'react-querybuilder';
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
+ };
@@ -1 +1 @@
1
- export const ORCHESTRATOR_UI_LIBRARY_VERSION = '8.9.2';
1
+ export const ORCHESTRATOR_UI_LIBRARY_VERSION = '8.9.3';
@@ -16,3 +16,4 @@ export * from './useGetWorkflowNameById';
16
16
  export * from './usePathAutoComplete';
17
17
  export * from './useGetPydanticFormsConfig';
18
18
  export * from './useLanguageCode';
19
+ export * from './useDebouncedCallback';
@@ -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
+ }