@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
@@ -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,147 @@
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
+ type SearchHookProps = { filterString: string; hasEmptyRuleValue?: boolean };
9
+
10
+ const renderSearchHook = (searchCallback: () => void, initialFilterString = 'subscription.status == "active"') =>
11
+ renderHook(
12
+ ({ filterString, hasEmptyRuleValue = false }: SearchHookProps) =>
13
+ useSearchWithDebouncedCallback({ filterString, isValidFilterString: true, hasEmptyRuleValue, searchCallback }),
14
+ { initialProps: { filterString: initialFilterString } as SearchHookProps },
15
+ );
16
+
17
+ describe('useSearchWithDebouncedCallback', () => {
18
+ beforeEach(() => {
19
+ jest.useFakeTimers();
20
+ });
21
+
22
+ afterEach(() => {
23
+ jest.useRealTimers();
24
+ });
25
+
26
+ it('schedules a search when the filter string changes, and not for the one it starts with', () => {
27
+ const searchCallback = jest.fn();
28
+ const { result, rerender } = renderSearchHook(searchCallback);
29
+
30
+ act(() => {
31
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
32
+ });
33
+ expect(searchCallback).not.toHaveBeenCalled();
34
+
35
+ rerender({ filterString: 'subscription.status == "activ"' });
36
+ expect(result.current.pendingSearchRun).toBeDefined();
37
+
38
+ act(() => {
39
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
40
+ });
41
+ expect(searchCallback).toHaveBeenCalledTimes(1);
42
+ expect(result.current.pendingSearchRun).toBeUndefined();
43
+ });
44
+
45
+ it('searches immediately on click, dropping the countdown and the run it had scheduled', () => {
46
+ const searchCallback = jest.fn();
47
+ const { result, rerender } = renderSearchHook(searchCallback);
48
+
49
+ rerender({ filterString: 'subscription.status == "activ"' });
50
+ expect(result.current.pendingSearchRun).toBeDefined();
51
+
52
+ act(() => {
53
+ result.current.handleSubmitSearchOnClick();
54
+ });
55
+ expect(searchCallback).toHaveBeenCalledTimes(1);
56
+ expect(result.current.pendingSearchRun).toBeUndefined();
57
+
58
+ // The scheduled run was cancelled rather than left to fire a second search.
59
+ act(() => {
60
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
61
+ });
62
+ expect(searchCallback).toHaveBeenCalledTimes(1);
63
+ });
64
+
65
+ it('does the same on enter, and leaves shift+enter alone', () => {
66
+ const searchCallback = jest.fn();
67
+ const { result, rerender } = renderSearchHook(searchCallback);
68
+
69
+ rerender({ filterString: 'subscription.status == "activ"' });
70
+
71
+ act(() => {
72
+ result.current.handleSubmitSearchOnEnter({
73
+ key: 'Enter',
74
+ shiftKey: true,
75
+ preventDefault: jest.fn(),
76
+ } as unknown as React.KeyboardEvent<HTMLElement>);
77
+ });
78
+ expect(searchCallback).not.toHaveBeenCalled();
79
+ expect(result.current.pendingSearchRun).toBeDefined();
80
+
81
+ act(() => {
82
+ result.current.handleSubmitSearchOnEnter({
83
+ key: 'Enter',
84
+ shiftKey: false,
85
+ preventDefault: jest.fn(),
86
+ } as unknown as React.KeyboardEvent<HTMLElement>);
87
+ });
88
+ expect(searchCallback).toHaveBeenCalledTimes(1);
89
+ expect(result.current.pendingSearchRun).toBeUndefined();
90
+
91
+ act(() => {
92
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
93
+ });
94
+ expect(searchCallback).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it('does not schedule a search while a value editor is empty', () => {
98
+ const searchCallback = jest.fn();
99
+ const { result, rerender } = renderSearchHook(searchCallback);
100
+
101
+ // Clearing the value editor empties the value out of the filter string.
102
+ rerender({ filterString: 'subscription.status == ""', hasEmptyRuleValue: true });
103
+ expect(result.current.pendingSearchRun).toBeUndefined();
104
+
105
+ act(() => {
106
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
107
+ });
108
+ expect(searchCallback).not.toHaveBeenCalled();
109
+
110
+ // Typing a value again resumes the automatic search.
111
+ rerender({ filterString: 'subscription.status == "a"', hasEmptyRuleValue: false });
112
+ expect(result.current.pendingSearchRun).toBeDefined();
113
+
114
+ act(() => {
115
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
116
+ });
117
+ expect(searchCallback).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it('drops a scheduled search when the value editor is cleared before it runs', () => {
121
+ const searchCallback = jest.fn();
122
+ const { result, rerender } = renderSearchHook(searchCallback);
123
+
124
+ rerender({ filterString: 'subscription.status == "activ"' });
125
+ expect(result.current.pendingSearchRun).toBeDefined();
126
+
127
+ rerender({ filterString: 'subscription.status == ""', hasEmptyRuleValue: true });
128
+ expect(result.current.pendingSearchRun).toBeUndefined();
129
+
130
+ act(() => {
131
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
132
+ });
133
+ expect(searchCallback).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('still searches on click while a value editor is empty', () => {
137
+ const searchCallback = jest.fn();
138
+ const { result, rerender } = renderSearchHook(searchCallback);
139
+
140
+ rerender({ filterString: 'subscription.status == ""', hasEmptyRuleValue: true });
141
+
142
+ act(() => {
143
+ result.current.handleSubmitSearchOnClick();
144
+ });
145
+ expect(searchCallback).toHaveBeenCalledTimes(1);
146
+ });
147
+ });
@@ -1,7 +1,7 @@
1
1
  import type { RuleGroupType, RuleType } from 'react-querybuilder';
2
2
  import { formatQuery } from 'react-querybuilder';
3
3
 
4
- import { collectRuleFields, parseCelToRuleGroup } from './utils';
4
+ import { collectRuleFields, hasNestedRuleWithEmptyValue, parseCelToRuleGroup } from './utils';
5
5
 
6
6
  describe('parseCelToRuleGroup', () => {
7
7
  it('assigns ids to the parsed group and rules so rule identity stays stable', () => {
@@ -54,3 +54,62 @@ describe('collectRuleFields', () => {
54
54
  expect(collectRuleFields({ combinator: 'and', rules: [] })).toEqual([]);
55
55
  });
56
56
  });
57
+
58
+ describe('hasRuleWithEmptyValue', () => {
59
+ const groupWith = (rules: RuleGroupType['rules']): RuleGroupType => ({ combinator: 'and', rules });
60
+
61
+ it('reports an empty text value, including whitespace only', () => {
62
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'subscription.status', operator: '=', value: '' }]))).toBe(
63
+ true,
64
+ );
65
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'subscription.status', operator: '=', value: ' ' }]))).toBe(
66
+ true,
67
+ );
68
+ expect(
69
+ hasNestedRuleWithEmptyValue(groupWith([{ field: 'subscription.status', operator: '=', value: 'active' }])),
70
+ ).toBe(false);
71
+ });
72
+
73
+ it('treats a missing value as empty and a committed boolean or number as filled', () => {
74
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'lldp', operator: '=', value: undefined }]))).toBe(true);
75
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'lldp', operator: '=', value: false }]))).toBe(false);
76
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port.speed', operator: '>', value: 0 }]))).toBe(false);
77
+ });
78
+
79
+ it('reports a range with only one side filled in', () => {
80
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port.speed', operator: 'between', value: '1000,' }]))).toBe(
81
+ true,
82
+ );
83
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port.speed', operator: 'between', value: '1000' }]))).toBe(
84
+ true,
85
+ );
86
+ expect(
87
+ hasNestedRuleWithEmptyValue(groupWith([{ field: 'port.speed', operator: 'between', value: '1000,2000' }])),
88
+ ).toBe(false);
89
+ });
90
+
91
+ it('never reports operators that take no value, whatever their leftover value is', () => {
92
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port', operator: 'notNull', value: null }]))).toBe(false);
93
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port', operator: 'null', value: null }]))).toBe(false);
94
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port', operator: 'notNull', value: '' }]))).toBe(false);
95
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port', operator: 'null', value: 'abc' }]))).toBe(false);
96
+ });
97
+
98
+ it('does not hold back a search for a filter restored from CEL that uses has component', () => {
99
+ expect(hasNestedRuleWithEmptyValue(parseCelToRuleGroup('subscription.port != null'))).toBe(false);
100
+ expect(hasNestedRuleWithEmptyValue(parseCelToRuleGroup('subscription.port == null'))).toBe(false);
101
+ });
102
+
103
+ it('looks inside nested groups', () => {
104
+ const ruleGroup = groupWith([
105
+ { field: 'subscription.status', operator: '=', value: 'active' },
106
+ groupWith([{ field: 'subscription.note', operator: 'contains', value: '' }]),
107
+ ]);
108
+
109
+ expect(hasNestedRuleWithEmptyValue(ruleGroup)).toBe(true);
110
+ });
111
+
112
+ it('returns false without a rule group', () => {
113
+ expect(hasNestedRuleWithEmptyValue(undefined)).toBe(false);
114
+ });
115
+ });
@@ -1,7 +1,63 @@
1
- import type { RuleGroupType } from 'react-querybuilder';
1
+ import { type KeyboardEventHandler, useEffect, useRef } from 'react';
2
+ import { FullOperator, RuleGroupType, RuleType, 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
+ hasEmptyRuleValue?: boolean;
15
+ searchCallback: () => void;
16
+ }
17
+
18
+ export const useSearchWithDebouncedCallback = ({
19
+ filterString,
20
+ isValidFilterString,
21
+ hasEmptyRuleValue = false,
22
+ searchCallback,
23
+ }: SearchWithDebouncedCallbackProps) => {
24
+ const {
25
+ trigger: triggerSearch,
26
+ cancel: cancelSearch,
27
+ pendingRun: pendingSearchRun,
28
+ } = useDebouncedCallback(searchCallback);
29
+ const lastFilterStringRef = useRef(filterString);
30
+
31
+ const handleSubmitSearchOnClick = () => {
32
+ if (!isValidFilterString) return;
33
+ triggerSearch();
34
+ return;
35
+ };
36
+
37
+ useEffect(() => {
38
+ const hasFilterStringChanged = filterString !== lastFilterStringRef.current;
39
+ lastFilterStringRef.current = filterString;
40
+
41
+ if (!hasFilterStringChanged) return;
42
+
43
+ if (isValidFilterString && !hasEmptyRuleValue) {
44
+ triggerSearch(FILTER_CHANGE_DEBOUNCE_DELAY);
45
+ } else {
46
+ cancelSearch();
47
+ }
48
+ }, [filterString, isValidFilterString, hasEmptyRuleValue, triggerSearch, cancelSearch]);
49
+
50
+ // Enter applies the filter exactly like that button; Shift+Enter is left alone so it can
51
+ // insert a newline in a textarea.
52
+ const handleSubmitSearchOnEnter: KeyboardEventHandler<HTMLElement> = (event) => {
53
+ if (event.key !== 'Enter' || event.shiftKey) return;
54
+ event.preventDefault();
55
+ handleSubmitSearchOnClick();
56
+ };
57
+
58
+ return { handleSubmitSearchOnClick, pendingSearchRun, handleSubmitSearchOnEnter };
59
+ };
60
+
5
61
  /** Collects the unique field names used by the rules of a rule group, including nested groups. */
6
62
  export const collectRuleFields = (ruleGroup: RuleGroupType): string[] => {
7
63
  const fields = ruleGroup.rules.flatMap((rule) => {
@@ -82,3 +138,79 @@ export const buildColumnFilter = <T>(
82
138
 
83
139
  return { filterString, ruleGroup };
84
140
  };
141
+
142
+ // Maps PathInfo operator names to react-querybuilder's native operator names,
143
+ // which is what parseCEL produces and formatQuery(cel) expects.
144
+ // has_component/not_has_component ride on notNull/null: they survive the CEL round trip
145
+ // (`field != null` / `field == null`) and formatQuery(elasticsearch) turns them into
146
+ // exists / must_not-exists, which the backend translates to component-presence filters.
147
+ const SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP: Record<string, string> = {
148
+ eq: '=',
149
+ neq: '!=',
150
+ lt: '<',
151
+ lte: '<=',
152
+ gt: '>',
153
+ gte: '>=',
154
+ between: 'between',
155
+ like: 'contains',
156
+ not_regexp: 'doesNotContain',
157
+ has_component: 'notNull',
158
+ not_has_component: 'null',
159
+ };
160
+
161
+ // Operators without a value; marking them unary makes react-querybuilder's Rule hide the value editor.
162
+ const RQB_UNARY_OPERATORS = ['null', 'notNull'];
163
+
164
+ const isEmptyValue = (rule: RuleType): boolean => {
165
+ const { value } = rule;
166
+ if (RQB_UNARY_OPERATORS.includes(rule.operator)) {
167
+ return false;
168
+ } else if (typeof value !== 'string') {
169
+ return value === undefined || value === null;
170
+ } else if (rule.operator === 'between') {
171
+ const rangeParts = value.split(',');
172
+ return rangeParts.length < 2 || rangeParts.some((rangePart) => rangePart.trim() === '');
173
+ }
174
+ return value.trim() === '';
175
+ };
176
+
177
+ /** True when any rule of the group, nested groups included, has an empty value editor. */
178
+ export const hasNestedRuleWithEmptyValue = (ruleGroup?: RuleGroupType): boolean =>
179
+ !!ruleGroup?.rules.some((rule) => {
180
+ if (typeof rule === 'string') {
181
+ return false;
182
+ }
183
+ return 'rules' in rule ? hasNestedRuleWithEmptyValue(rule) : isEmptyValue(rule);
184
+ });
185
+
186
+ const OPERATOR_MAP: Record<string, OperatorDisplay> = {
187
+ eq: { symbol: '=', description: 'equals' },
188
+ neq: { symbol: '≠', description: 'not equals' },
189
+ lt: { symbol: '<', description: 'less than' },
190
+ lte: { symbol: '≤', description: 'less than or equal to' },
191
+ gt: { symbol: '>', description: 'greater than' },
192
+ gte: { symbol: '≥', description: 'greater than or equal to' },
193
+ between: { symbol: '⟷', description: 'between (range)' },
194
+ has_component: { symbol: '✓', description: 'has component' },
195
+ not_has_component: { symbol: '✗', description: 'does not have component' },
196
+ like: { symbol: '∋', description: 'contains' },
197
+ not_regexp: { symbol: '∌', description: 'does not contain' },
198
+ };
199
+
200
+ export const operatorsToRQBOperatorOptionsMapper = (operators?: string[]): FullOperator[] => {
201
+ return (operators ?? []).map((operator) => {
202
+ const { symbol, description } = OPERATOR_MAP[operator] || { symbol: operator, description: operator };
203
+ const rqbOperator = SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP[operator] ?? operator;
204
+ return {
205
+ name: rqbOperator,
206
+ label: `${symbol} ${description}`,
207
+ value: rqbOperator,
208
+ ...(RQB_UNARY_OPERATORS.includes(rqbOperator) && { arity: 'unary' }),
209
+ };
210
+ });
211
+ };
212
+
213
+ export const onAddGroupHandler = (ruleGroup: RuleGroupType): RuleGroupType => {
214
+ const [firstRule] = ruleGroup.rules;
215
+ return firstRule ? { ...ruleGroup, rules: [...ruleGroup.rules, { ...firstRule, id: generateID() }] } : ruleGroup;
216
+ };
@@ -1 +1 @@
1
- export const ORCHESTRATOR_UI_LIBRARY_VERSION = '8.9.2';
1
+ export const ORCHESTRATOR_UI_LIBRARY_VERSION = '9.0.0';
@@ -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
+ }
@@ -1,15 +1,15 @@
1
- import { renderHook, waitFor } from '@testing-library/react';
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
2
 
3
- import { EntityKind } from '@/types';
3
+ import { useSearchPathsQuery } from '@/rtk/endpoints';
4
+ import { EntityKind, PathAutocompleteResponse } from '@/types';
4
5
 
5
- import { useFieldsPathInfo } from './usePathAutoComplete';
6
+ import { useFieldsPathInfo, usePathAutocomplete } from './usePathAutoComplete';
6
7
 
7
8
  const fetchPathsMock = jest.fn();
8
9
 
9
- jest.mock('@/rtk/endpoints', () => ({
10
- useSearchPathsQuery: jest.fn(),
11
- useLazySearchPathsQuery: () => [fetchPathsMock],
12
- useSearchDefinitionsQuery: () => ({
10
+ jest.mock('@/rtk/endpoints', () => {
11
+ // Stable like RTK Query's cached data: the hooks depend on it in effects.
12
+ const definitionsResult = {
13
13
  data: {
14
14
  boolean: {
15
15
  operators: ['eq', 'neq'],
@@ -17,8 +17,13 @@ jest.mock('@/rtk/endpoints', () => ({
17
17
  },
18
18
  },
19
19
  isError: false,
20
- }),
21
- }));
20
+ };
21
+ return {
22
+ useSearchPathsQuery: jest.fn(),
23
+ useLazySearchPathsQuery: () => [fetchPathsMock],
24
+ useSearchDefinitionsQuery: () => definitionsResult,
25
+ };
26
+ });
22
27
 
23
28
  describe('useFieldsPathInfo', () => {
24
29
  beforeEach(() => {
@@ -62,3 +67,83 @@ describe('useFieldsPathInfo', () => {
62
67
  expect(fetchPathsMock).toHaveBeenCalledTimes(1);
63
68
  });
64
69
  });
70
+
71
+ describe('usePathAutocomplete', () => {
72
+ const SAPS_RESPONSE = {
73
+ leaves: [],
74
+ components: [{ name: 'saps', ui_types: ['component'], paths: ['saps.port', 'saps.vlan'] }],
75
+ };
76
+ const EMPTY_RESPONSE: PathAutocompleteResponse = { leaves: [], components: [] };
77
+
78
+ // Only the fields the hook reads; the full RTK Query result type is far larger.
79
+ const mockSearchPaths = (
80
+ getData: (args: { q: string }, options?: { skip?: boolean }) => PathAutocompleteResponse | undefined,
81
+ ) =>
82
+ jest.mocked(useSearchPathsQuery).mockImplementation(((args: { q: string }, options?: { skip?: boolean }) => ({
83
+ data: getData(args, options),
84
+ isFetching: false,
85
+ isError: false,
86
+ })) as unknown as typeof useSearchPathsQuery);
87
+
88
+ beforeEach(() => {
89
+ jest.useFakeTimers();
90
+ mockSearchPaths((args, options) => {
91
+ if (options?.skip) return undefined;
92
+ return args.q === 'sa' ? SAPS_RESPONSE : EMPTY_RESPONSE;
93
+ });
94
+ });
95
+
96
+ afterEach(() => {
97
+ jest.useRealTimers();
98
+ });
99
+
100
+ it('skips the paths request and offers no paths while the prefix is empty', () => {
101
+ const { result } = renderHook(() => usePathAutocomplete('', EntityKind.SUBSCRIPTION));
102
+
103
+ act(() => {
104
+ jest.advanceTimersByTime(300);
105
+ });
106
+
107
+ expect(useSearchPathsQuery).toHaveBeenCalled();
108
+ jest.mocked(useSearchPathsQuery).mock.calls.forEach(([, options]) => expect(options?.skip).toBe(true));
109
+ expect(result.current.paths).toEqual([]);
110
+ });
111
+
112
+ it('reports loading from the keystroke until the debounced request has settled', async () => {
113
+ const { result, rerender } = renderHook(({ prefix }) => usePathAutocomplete(prefix, EntityKind.SUBSCRIPTION), {
114
+ initialProps: { prefix: '' },
115
+ });
116
+ expect(result.current.loading).toBe(false);
117
+
118
+ rerender({ prefix: 'sa' });
119
+ expect(result.current.loading).toBe(true);
120
+ expect(result.current.paths).toEqual([]);
121
+
122
+ act(() => {
123
+ jest.advanceTimersByTime(300);
124
+ });
125
+
126
+ await waitFor(() => expect(result.current.loading).toBe(false));
127
+ expect(result.current.paths).toHaveLength(1);
128
+ });
129
+
130
+ it('maps the components returned for a typed prefix, e.g. saps for "sa"', async () => {
131
+ const { result } = renderHook(() => usePathAutocomplete('sa', EntityKind.SUBSCRIPTION));
132
+
133
+ act(() => {
134
+ jest.advanceTimersByTime(300);
135
+ });
136
+
137
+ await waitFor(() => expect(result.current.paths).toHaveLength(1));
138
+ expect(useSearchPathsQuery).toHaveBeenLastCalledWith(
139
+ { q: 'sa', entity_type: EntityKind.SUBSCRIPTION },
140
+ { skip: false },
141
+ );
142
+ expect(result.current.paths[0]).toMatchObject({
143
+ path: 'saps',
144
+ type: 'component',
145
+ group: 'component',
146
+ availablePaths: ['saps.port', 'saps.vlan'],
147
+ });
148
+ });
149
+ });
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
 
3
3
  import { useLazySearchPathsQuery, useSearchDefinitionsQuery, useSearchPathsQuery } from '@/rtk/endpoints';
4
4
  import { EntityKind, PathAutocompleteResponse, PathInfo, value_schema } from '@/types';
@@ -97,35 +97,30 @@ const mapPathAutocompleteResponseToPathInfos = (
97
97
  };
98
98
 
99
99
  export const usePathAutocomplete = (prefix: string, entityType: EntityKind) => {
100
- const [paths, setPaths] = useState<PathInfo[]>([]);
101
100
  const debouncedPrefix = useDebounce(prefix, 300);
102
101
  const { data: definitions = FALLBACK_DEFINITIONS, isError: defError } = useSearchDefinitionsQuery();
103
102
 
104
103
  const {
105
104
  data: pathData,
106
- isLoading,
105
+ isFetching,
107
106
  isError,
108
107
  } = useSearchPathsQuery({ q: debouncedPrefix, entity_type: entityType }, { skip: debouncedPrefix.length < 1 });
109
108
 
110
- useEffect(() => {
111
- if (debouncedPrefix.length < 1) {
112
- setPaths([]);
113
- return;
114
- }
115
-
116
- if (!pathData) {
117
- return;
118
- }
109
+ const paths = useMemo(
110
+ () =>
111
+ debouncedPrefix.length < 1 || !pathData ? [] : mapPathAutocompleteResponseToPathInfos(pathData, definitions),
112
+ [pathData, definitions, debouncedPrefix.length],
113
+ );
119
114
 
120
- setPaths(mapPathAutocompleteResponseToPathInfos(pathData, definitions));
121
- }, [pathData, definitions, debouncedPrefix?.length]);
115
+ const isDebouncing = prefix.length >= 1 && prefix !== debouncedPrefix;
116
+ const loading = isDebouncing || isFetching;
122
117
 
123
118
  const errorMessage =
124
119
  isError ? 'Failed to load paths'
125
120
  : defError ? 'Failed to load definitions'
126
121
  : null;
127
122
 
128
- return { paths, loading: isLoading, error: errorMessage };
123
+ return { paths, loading, error: errorMessage };
129
124
  };
130
125
 
131
126
  /**
@@ -51,7 +51,7 @@ export const useSearchPagination = (
51
51
  {
52
52
  page: currentPage,
53
53
  results: results.data,
54
- cursor: results.cursor.start_cursor,
54
+ cursor: results.cursor?.start_cursor ?? null,
55
55
  },
56
56
  ]);
57
57