@orchestrator-ui/orchestrator-ui-components 8.7.1 → 8.7.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 +8 -8
- package/.turbo/turbo-lint.log +1 -1
- package/.turbo/turbo-test.log +17 -12
- package/CHANGELOG.md +13 -0
- package/dist/index.d.ts +484 -2
- package/dist/index.js +356 -233
- package/dist/index.js.map +1 -1
- package/jest.config.cjs +9 -8
- package/package.json +1 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +1 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +55 -10
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoOperatorSelector.spec.tsx +143 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoOperatorSelector.tsx +51 -12
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoRangeEditor.tsx +38 -50
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoRestoreLoop.spec.tsx +130 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.spec.tsx +60 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +55 -32
- package/src/components/WfoTable/WfoStructuredSearchTable/utils.spec.ts +56 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +40 -1
- package/src/configuration/version.ts +1 -1
- package/src/hooks/usePathAutoComplete.spec.tsx +64 -0
- package/src/hooks/usePathAutoComplete.ts +98 -41
- package/src/pages/WfoSearchPocPage.tsx +18 -17
- package/src/rtk/endpoints/search.ts +1 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ValueEditorProps } from 'react-querybuilder';
|
|
3
|
+
|
|
4
|
+
import '@testing-library/jest-dom';
|
|
5
|
+
import { render, screen } from '@testing-library/react';
|
|
6
|
+
|
|
7
|
+
import type { PathInfo } from '@/types';
|
|
8
|
+
|
|
9
|
+
import { WfoValueEditor } from './WfoValueEditor';
|
|
10
|
+
|
|
11
|
+
jest.mock('@/hooks', () => ({
|
|
12
|
+
useWithOrchestratorTheme: () => ({}),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock('next-intl', () => ({
|
|
16
|
+
useTranslations: () => (key: string) => key,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const BOOLEAN_PATH_INFO = { ui_types: ['boolean'] } as PathInfo;
|
|
20
|
+
|
|
21
|
+
const renderBooleanValueEditor = (value: unknown, handleOnChange = jest.fn()) => {
|
|
22
|
+
render(
|
|
23
|
+
<WfoValueEditor
|
|
24
|
+
{...({
|
|
25
|
+
field: 'lldp',
|
|
26
|
+
operator: '=',
|
|
27
|
+
value,
|
|
28
|
+
handleOnChange,
|
|
29
|
+
className: 'rule-value',
|
|
30
|
+
context: { fieldPathInfoMap: new Map([['lldp', BOOLEAN_PATH_INFO]]) },
|
|
31
|
+
} as unknown as ValueEditorProps)}
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
return handleOnChange;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('WfoValueEditor boolean editor', () => {
|
|
38
|
+
it('commits the default true on mount for a freshly selected field without a value', () => {
|
|
39
|
+
const handleOnChange = renderBooleanValueEditor('');
|
|
40
|
+
|
|
41
|
+
expect(handleOnChange).toHaveBeenCalledWith(true);
|
|
42
|
+
expect(screen.getByRole('button', { name: 'True', pressed: true })).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not re-commit a restored boolean value', () => {
|
|
46
|
+
const handleOnChange = renderBooleanValueEditor(false);
|
|
47
|
+
|
|
48
|
+
expect(handleOnChange).not.toHaveBeenCalled();
|
|
49
|
+
expect(screen.getByRole('button', { name: 'False', pressed: true })).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not overwrite a half-typed literal from the filter string textarea', () => {
|
|
53
|
+
// While the user edits `lldp == false` in the textarea, intermediate states like
|
|
54
|
+
// `lldp == fals` parse to a non-boolean value. Committing a normalized boolean here
|
|
55
|
+
// would echo back into the filter string and snap the textarea back mid-edit.
|
|
56
|
+
const handleOnChange = renderBooleanValueEditor('fals');
|
|
57
|
+
|
|
58
|
+
expect(handleOnChange).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -7,10 +7,15 @@ import { useTranslations } from 'next-intl';
|
|
|
7
7
|
|
|
8
8
|
import { EuiButtonGroup, EuiDatePicker, EuiFieldNumber, EuiFieldText } from '@elastic/eui';
|
|
9
9
|
|
|
10
|
+
import { WfoRangeEditor } from '@/components/WfoTable/WfoStructuredSearchTable/WfoRangeEditor';
|
|
10
11
|
import { getWfoStructuredSearchTableStyles } from '@/components/WfoTable/WfoStructuredSearchTable/styles';
|
|
11
12
|
import { useWithOrchestratorTheme } from '@/hooks';
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
export interface EditorInputFieldProps<T = string> {
|
|
15
|
+
handleOnChange: ValueEditorProps['handleOnChange'];
|
|
16
|
+
value: T;
|
|
17
|
+
}
|
|
18
|
+
export type EditorComponent = React.ComponentType<EditorInputFieldProps<ValueEditorProps['value']>>;
|
|
14
19
|
|
|
15
20
|
enum UiFieldType {
|
|
16
21
|
text = 'text',
|
|
@@ -19,20 +24,30 @@ enum UiFieldType {
|
|
|
19
24
|
datetime = 'datetime',
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
handleOnChange
|
|
26
|
-
value:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
27
|
+
// The value is only a boolean for rules committed by this editor; a rule parsed from a
|
|
28
|
+
// CEL string can carry anything, e.g. a half-typed literal ('fals') or a quoted string.
|
|
29
|
+
const BooleanEditor = ({
|
|
30
|
+
handleOnChange,
|
|
31
|
+
value: currentValue,
|
|
32
|
+
}: EditorInputFieldProps<boolean | string | undefined>) => {
|
|
33
|
+
// A restored query (URL / filter string) delivers a real boolean; anything else —
|
|
34
|
+
// a freshly selected field ('') or a value left behind by another editor — means
|
|
35
|
+
// there is no boolean value yet and the editor starts at its default, true.
|
|
36
|
+
const initialValue = typeof currentValue === 'boolean' ? currentValue : true;
|
|
37
|
+
const [value, setValue] = useState<string>(initialValue.toString());
|
|
32
38
|
|
|
33
39
|
useEffect(() => {
|
|
34
|
-
|
|
35
|
-
//
|
|
40
|
+
// Commit the default only for a rule without a value yet (a freshly selected field)
|
|
41
|
+
// so it is complete without user interaction. Any other value belongs to someone
|
|
42
|
+
// else and is left alone: a boolean restored from CEL has nothing to commit (and
|
|
43
|
+
// committing anyway loops when the query update remounts this editor), and the
|
|
44
|
+
// half-typed literal of a CEL string being edited in the textarea ('fals') must not
|
|
45
|
+
// be overwritten — the commit would echo a normalized boolean back into the filter
|
|
46
|
+
// string, snapping the textarea back mid-edit.
|
|
47
|
+
if (currentValue === undefined || currentValue === '') {
|
|
48
|
+
handleOnChange(initialValue);
|
|
49
|
+
}
|
|
50
|
+
// Only on mount: currentValue is this editor's own output after that
|
|
36
51
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
52
|
}, []);
|
|
38
53
|
|
|
@@ -65,7 +80,7 @@ const BooleanEditor = ({ handleOnChange, value: currentValue = true }: EditorPro
|
|
|
65
80
|
);
|
|
66
81
|
};
|
|
67
82
|
|
|
68
|
-
const TextEditor = ({ handleOnChange, value: currentValue = '' }:
|
|
83
|
+
const TextEditor = ({ handleOnChange, value: currentValue = '' }: EditorInputFieldProps<string>) => {
|
|
69
84
|
const [value, setValue] = useState<string>(currentValue);
|
|
70
85
|
|
|
71
86
|
const handleTextChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
@@ -85,7 +100,7 @@ const TextEditor = ({ handleOnChange, value: currentValue = '' }: EditorProps<st
|
|
|
85
100
|
return <EuiFieldText value={value} onChange={handleTextChange} onBlur={handleOnBlur} onKeyDown={handleOnKeyDown} />;
|
|
86
101
|
};
|
|
87
102
|
|
|
88
|
-
const NumberEditor = ({ handleOnChange,
|
|
103
|
+
const NumberEditor = ({ handleOnChange, value: currentValue }: EditorInputFieldProps<number>) => {
|
|
89
104
|
const [value, setValue] = useState<string>(currentValue?.toString() || '');
|
|
90
105
|
|
|
91
106
|
const handleNumberChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
@@ -94,7 +109,7 @@ const NumberEditor = ({ handleOnChange, rangeIndex, value: currentValue }: WfoRa
|
|
|
94
109
|
|
|
95
110
|
const handleOnBlur = () => {
|
|
96
111
|
const numberValue = parseFloat(value);
|
|
97
|
-
handleOnChange(numberValue
|
|
112
|
+
handleOnChange(numberValue);
|
|
98
113
|
};
|
|
99
114
|
|
|
100
115
|
const handleOnKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
|
|
@@ -113,7 +128,7 @@ const NumberEditor = ({ handleOnChange, rangeIndex, value: currentValue }: WfoRa
|
|
|
113
128
|
);
|
|
114
129
|
};
|
|
115
130
|
|
|
116
|
-
const DatePicker = ({ handleOnChange,
|
|
131
|
+
const DatePicker = ({ handleOnChange, value: currentValue }: EditorInputFieldProps<string>) => {
|
|
117
132
|
const [date, setDate] = useState<string>(currentValue || '');
|
|
118
133
|
const t = useTranslations('search.page');
|
|
119
134
|
|
|
@@ -123,9 +138,8 @@ const DatePicker = ({ handleOnChange, rangeIndex, value: currentValue }: WfoRang
|
|
|
123
138
|
onChange={(date) => {
|
|
124
139
|
const utcDate = date ? moment.utc(date) : undefined;
|
|
125
140
|
setDate(utcDate?.toISOString() || '');
|
|
126
|
-
handleOnChange(utcDate?.toISOString()
|
|
141
|
+
handleOnChange(utcDate?.toISOString());
|
|
127
142
|
}}
|
|
128
|
-
id={rangeIndex ? `date-range-${rangeIndex}` : 'date-range'}
|
|
129
143
|
css={{ width: '330px' }}
|
|
130
144
|
showTimeSelect
|
|
131
145
|
dateFormat="yyyy-MM-dd HH:mm"
|
|
@@ -145,27 +159,36 @@ export const WfoValueEditor = ({
|
|
|
145
159
|
value,
|
|
146
160
|
className,
|
|
147
161
|
}: ValueEditorProps) => {
|
|
162
|
+
// For components that don't take a value in addition to an operator - for example where you
|
|
163
|
+
// only choose 'Has component' or 'Does not have component' - the WfoValueEditor should not be rendered.
|
|
164
|
+
// React-query-builder handles this by default by setting the unary constant in the getOperators
|
|
165
|
+
// property of the QueryBuilder component (see WfoFilterBuilder)
|
|
166
|
+
// Because this check might not have run yet when the query is rebuild from an URL
|
|
167
|
+
// we make the check explicitly here aswell.
|
|
168
|
+
if (operator === 'null' || operator === 'notNull') {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const getComponentByType = (): EditorComponent => {
|
|
173
|
+
if (uiFieldType === UiFieldType.boolean) return BooleanEditor;
|
|
174
|
+
if (uiFieldType === UiFieldType.datetime) return DatePicker;
|
|
175
|
+
if (uiFieldType === UiFieldType.number) return NumberEditor;
|
|
176
|
+
return TextEditor;
|
|
177
|
+
};
|
|
178
|
+
|
|
148
179
|
const fieldPathInfoMap = context?.fieldPathInfoMap;
|
|
149
180
|
|
|
150
181
|
const fieldInfo = fieldPathInfoMap && fieldPathInfoMap.has(fieldName) ? fieldPathInfoMap.get(fieldName) : undefined;
|
|
151
|
-
const uiFieldType = fieldInfo?.ui_types[0] || UiFieldType.text;
|
|
182
|
+
const uiFieldType = fieldInfo?.ui_types?.[0] || UiFieldType.text;
|
|
152
183
|
|
|
153
184
|
const getEditor = () => {
|
|
154
|
-
|
|
155
|
-
return <BooleanEditor handleOnChange={handleOnChange} value={value} />;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (uiFieldType === UiFieldType.datetime) {
|
|
159
|
-
return <WfoRangeEditor handleOnChange={handleOnChange} operator={operator} Element={DatePicker} value={value} />;
|
|
160
|
-
}
|
|
185
|
+
const InputElement = getComponentByType();
|
|
161
186
|
|
|
162
|
-
if (
|
|
163
|
-
return
|
|
164
|
-
<WfoRangeEditor handleOnChange={handleOnChange} operator={operator} Element={NumberEditor} value={value} />
|
|
165
|
-
);
|
|
187
|
+
if (operator === 'between') {
|
|
188
|
+
return <WfoRangeEditor handleOnChange={handleOnChange} value={value} InputElement={InputElement} />;
|
|
166
189
|
}
|
|
167
190
|
|
|
168
|
-
return <
|
|
191
|
+
return <InputElement handleOnChange={handleOnChange} value={value} />;
|
|
169
192
|
};
|
|
170
193
|
|
|
171
194
|
const handleWrapperKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { RuleGroupType, RuleType } from 'react-querybuilder';
|
|
2
|
+
import { formatQuery } from 'react-querybuilder';
|
|
3
|
+
|
|
4
|
+
import { collectRuleFields, parseCelToRuleGroup } from './utils';
|
|
5
|
+
|
|
6
|
+
describe('parseCelToRuleGroup', () => {
|
|
7
|
+
it('assigns ids to the parsed group and rules so rule identity stays stable', () => {
|
|
8
|
+
const ruleGroup = parseCelToRuleGroup('lldp == true && port.speed > 1000');
|
|
9
|
+
|
|
10
|
+
expect(ruleGroup?.id).toBeTruthy();
|
|
11
|
+
expect(ruleGroup?.rules).toHaveLength(2);
|
|
12
|
+
ruleGroup?.rules.forEach((rule) => {
|
|
13
|
+
expect((rule as RuleType).id).toBeTruthy();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns undefined for strings that do not parse to rules', () => {
|
|
18
|
+
expect(parseCelToRuleGroup('')).toBeUndefined();
|
|
19
|
+
expect(parseCelToRuleGroup('not valid cel ===')).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('drops the field value source parseCEL assigns to bare identifiers, keeping values quoted', () => {
|
|
23
|
+
// parseCEL reads the bare identifier as a field reference (valueSource 'field').
|
|
24
|
+
// Left in place, formatQuery would render the value unquoted: `lldp == fals`.
|
|
25
|
+
const ruleGroup = parseCelToRuleGroup('lldp == fals');
|
|
26
|
+
|
|
27
|
+
expect(ruleGroup).toBeDefined();
|
|
28
|
+
expect((ruleGroup?.rules[0] as RuleType).valueSource).toBeUndefined();
|
|
29
|
+
expect(formatQuery(ruleGroup as RuleGroupType, { format: 'cel', fallbackExpression: '' })).toBe('lldp == "fals"');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('collectRuleFields', () => {
|
|
34
|
+
it('collects unique fields from rules, including nested groups', () => {
|
|
35
|
+
const ruleGroup: RuleGroupType = {
|
|
36
|
+
combinator: 'and',
|
|
37
|
+
rules: [
|
|
38
|
+
{ field: 'lldp', operator: '=', value: true },
|
|
39
|
+
{ field: 'subscription.status', operator: '=', value: 'active' },
|
|
40
|
+
{
|
|
41
|
+
combinator: 'or',
|
|
42
|
+
rules: [
|
|
43
|
+
{ field: 'lldp', operator: '=', value: false },
|
|
44
|
+
{ field: 'port.speed', operator: '>', value: 1000 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
expect(collectRuleFields(ruleGroup)).toEqual(['lldp', 'subscription.status', 'port.speed']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns an empty list for a group without rules', () => {
|
|
54
|
+
expect(collectRuleFields({ combinator: 'and', rules: [] })).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
import type { RuleGroupType } from 'react-querybuilder';
|
|
2
|
+
import { prepareRuleGroup } from 'react-querybuilder';
|
|
2
3
|
import { parseCEL } from 'react-querybuilder/parseCEL';
|
|
3
4
|
|
|
5
|
+
/** Collects the unique field names used by the rules of a rule group, including nested groups. */
|
|
6
|
+
export const collectRuleFields = (ruleGroup: RuleGroupType): string[] => {
|
|
7
|
+
const fields = ruleGroup.rules.flatMap((rule) => {
|
|
8
|
+
if (typeof rule === 'string') {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
if ('rules' in rule) {
|
|
12
|
+
return collectRuleFields(rule);
|
|
13
|
+
}
|
|
14
|
+
return [rule.field];
|
|
15
|
+
});
|
|
16
|
+
return [...new Set(fields)];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// The filter builder has no field-to-field comparisons: a 'field' value source only
|
|
20
|
+
// appears when parseCEL reads a bare identifier (e.g. the half-typed literal in
|
|
21
|
+
// `lldp == fals`) as a field reference. If it stays on the rule it survives later field
|
|
22
|
+
// and value edits (resetOnFieldChange is off and the value editors only set the value),
|
|
23
|
+
// and formatQuery renders the value of such a rule unquoted — producing invalid CEL like
|
|
24
|
+
// `subscription.end_date == 2026-07-05T22:00:00.000Z` once a string value is committed.
|
|
25
|
+
const dropFieldValueSources = (ruleGroup: RuleGroupType): RuleGroupType => ({
|
|
26
|
+
...ruleGroup,
|
|
27
|
+
rules: ruleGroup.rules.map((rule) => {
|
|
28
|
+
if (typeof rule === 'string' || 'rules' in rule) {
|
|
29
|
+
return typeof rule === 'string' ? rule : dropFieldValueSources(rule);
|
|
30
|
+
}
|
|
31
|
+
if (rule.valueSource === 'field') {
|
|
32
|
+
const ruleWithoutValueSource = { ...rule };
|
|
33
|
+
delete ruleWithoutValueSource.valueSource;
|
|
34
|
+
return ruleWithoutValueSource;
|
|
35
|
+
}
|
|
36
|
+
return rule;
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
|
|
4
40
|
export const parseCelToRuleGroup = (celString: string): RuleGroupType | undefined => {
|
|
5
41
|
if (!celString) {
|
|
6
42
|
return undefined;
|
|
7
43
|
}
|
|
8
44
|
try {
|
|
9
45
|
const ruleGroup = parseCEL(celString);
|
|
10
|
-
|
|
46
|
+
// prepareRuleGroup assigns the rule ids parseCEL leaves out. Without stable ids the
|
|
47
|
+
// QueryBuilder regenerates them on every query prop change, remounting all rules —
|
|
48
|
+
// which loses editor state and can loop with editors that commit a value on mount.
|
|
49
|
+
return ruleGroup?.rules?.length > 0 ? prepareRuleGroup(dropFieldValueSources(ruleGroup)) : undefined;
|
|
11
50
|
} catch {
|
|
12
51
|
return undefined;
|
|
13
52
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const ORCHESTRATOR_UI_LIBRARY_VERSION = '8.7.
|
|
1
|
+
export const ORCHESTRATOR_UI_LIBRARY_VERSION = '8.7.3';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
|
|
3
|
+
import { EntityKind } from '@/types';
|
|
4
|
+
|
|
5
|
+
import { useFieldsPathInfo } from './usePathAutoComplete';
|
|
6
|
+
|
|
7
|
+
const fetchPathsMock = jest.fn();
|
|
8
|
+
|
|
9
|
+
jest.mock('@/rtk/endpoints', () => ({
|
|
10
|
+
useSearchPathsQuery: jest.fn(),
|
|
11
|
+
useLazySearchPathsQuery: () => [fetchPathsMock],
|
|
12
|
+
useSearchDefinitionsQuery: () => ({
|
|
13
|
+
data: {
|
|
14
|
+
boolean: {
|
|
15
|
+
operators: ['eq', 'neq'],
|
|
16
|
+
value_schema: { eq: { kind: 'boolean' }, neq: { kind: 'boolean' } },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
isError: false,
|
|
20
|
+
}),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe('useFieldsPathInfo', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
fetchPathsMock.mockImplementation(({ q }: { q: string }) => ({
|
|
26
|
+
unwrap: () =>
|
|
27
|
+
Promise.resolve(
|
|
28
|
+
q === 'lldp' ?
|
|
29
|
+
{ leaves: [{ name: 'lldp', ui_types: ['boolean'], paths: [] }], components: [] }
|
|
30
|
+
: { leaves: [], components: [] },
|
|
31
|
+
),
|
|
32
|
+
}));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('resolves the path info of an exactly matching leaf', async () => {
|
|
36
|
+
const { result } = renderHook(() => useFieldsPathInfo(['lldp'], EntityKind.SUBSCRIPTION));
|
|
37
|
+
|
|
38
|
+
await waitFor(() => expect(result.current.get('lldp')).toBeTruthy());
|
|
39
|
+
expect(result.current.get('lldp')).toMatchObject({
|
|
40
|
+
path: 'lldp',
|
|
41
|
+
ui_types: ['boolean'],
|
|
42
|
+
operators: ['eq', 'neq'],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('stores null for a field the backend does not know', async () => {
|
|
47
|
+
const { result } = renderHook(() => useFieldsPathInfo(['nonexistent'], EntityKind.SUBSCRIPTION));
|
|
48
|
+
|
|
49
|
+
await waitFor(() => expect(result.current.has('nonexistent')).toBe(true));
|
|
50
|
+
expect(result.current.get('nonexistent')).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('looks up each field at most once across rerenders', async () => {
|
|
54
|
+
const { result, rerender } = renderHook(({ fields }) => useFieldsPathInfo(fields, EntityKind.SUBSCRIPTION), {
|
|
55
|
+
initialProps: { fields: ['lldp'] },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await waitFor(() => expect(result.current.has('lldp')).toBe(true));
|
|
59
|
+
rerender({ fields: ['lldp'] });
|
|
60
|
+
rerender({ fields: ['lldp'] });
|
|
61
|
+
|
|
62
|
+
expect(fetchPathsMock).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
|
-
import { useSearchDefinitionsQuery, useSearchPathsQuery } from '@/rtk/endpoints';
|
|
4
|
-
import { EntityKind, PathInfo, value_schema } from '@/types';
|
|
3
|
+
import { useLazySearchPathsQuery, useSearchDefinitionsQuery, useSearchPathsQuery } from '@/rtk/endpoints';
|
|
4
|
+
import { EntityKind, PathAutocompleteResponse, PathInfo, value_schema } from '@/types';
|
|
5
5
|
|
|
6
6
|
import { useDebounce } from './useDebounce';
|
|
7
7
|
|
|
@@ -50,6 +50,52 @@ const FALLBACK_DEFINITIONS: Record<
|
|
|
50
50
|
},
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
type SearchDefinitions = typeof FALLBACK_DEFINITIONS;
|
|
54
|
+
|
|
55
|
+
const mapPathAutocompleteResponseToPathInfos = (
|
|
56
|
+
pathData: PathAutocompleteResponse,
|
|
57
|
+
definitions: SearchDefinitions,
|
|
58
|
+
): PathInfo[] => {
|
|
59
|
+
const enrichedPaths: PathInfo[] = [];
|
|
60
|
+
|
|
61
|
+
// Process leaves first
|
|
62
|
+
(pathData.leaves || []).forEach((leaf) => {
|
|
63
|
+
const primaryType = leaf.ui_types[0] || 'string';
|
|
64
|
+
const typeDefinition = definitions[primaryType];
|
|
65
|
+
|
|
66
|
+
enrichedPaths.push({
|
|
67
|
+
path: leaf.name,
|
|
68
|
+
type: primaryType as 'string' | 'number' | 'datetime' | 'boolean',
|
|
69
|
+
operators: typeDefinition?.operators || [],
|
|
70
|
+
value_schema: typeDefinition?.value_schema || {},
|
|
71
|
+
group: 'leaf',
|
|
72
|
+
displayLabel: leaf.name,
|
|
73
|
+
ui_types: leaf.ui_types,
|
|
74
|
+
availablePaths: leaf.paths || [],
|
|
75
|
+
pathCount: leaf.paths ? leaf.paths?.length : 0,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
(pathData.components || []).forEach((component) => {
|
|
80
|
+
const primaryType = component.ui_types[0] || 'string';
|
|
81
|
+
const typeDefinition = definitions[primaryType];
|
|
82
|
+
|
|
83
|
+
enrichedPaths.push({
|
|
84
|
+
path: component.name,
|
|
85
|
+
type: 'component',
|
|
86
|
+
operators: typeDefinition?.operators || [],
|
|
87
|
+
value_schema: typeDefinition?.value_schema || {},
|
|
88
|
+
group: 'component',
|
|
89
|
+
displayLabel: component.name,
|
|
90
|
+
ui_types: component.ui_types,
|
|
91
|
+
availablePaths: component.paths || [],
|
|
92
|
+
pathCount: component.paths ? component.paths?.length : 0,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return enrichedPaths;
|
|
97
|
+
};
|
|
98
|
+
|
|
53
99
|
export const usePathAutocomplete = (prefix: string, entityType: EntityKind) => {
|
|
54
100
|
const [paths, setPaths] = useState<PathInfo[]>([]);
|
|
55
101
|
const debouncedPrefix = useDebounce(prefix, 300);
|
|
@@ -71,44 +117,7 @@ export const usePathAutocomplete = (prefix: string, entityType: EntityKind) => {
|
|
|
71
117
|
return;
|
|
72
118
|
}
|
|
73
119
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// Process leaves first
|
|
77
|
-
(pathData.leaves || []).forEach((leaf) => {
|
|
78
|
-
const primaryType = leaf.ui_types[0] || 'string';
|
|
79
|
-
const typeDefinition = definitions[primaryType];
|
|
80
|
-
|
|
81
|
-
enrichedPaths.push({
|
|
82
|
-
path: leaf.name,
|
|
83
|
-
type: primaryType as 'string' | 'number' | 'datetime' | 'boolean',
|
|
84
|
-
operators: typeDefinition?.operators || [],
|
|
85
|
-
value_schema: typeDefinition?.value_schema || {},
|
|
86
|
-
group: 'leaf',
|
|
87
|
-
displayLabel: leaf.name,
|
|
88
|
-
ui_types: leaf.ui_types,
|
|
89
|
-
availablePaths: leaf.paths || [],
|
|
90
|
-
pathCount: leaf.paths ? leaf.paths?.length : 0,
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
(pathData.components || []).forEach((component) => {
|
|
95
|
-
const primaryType = component.ui_types[0] || 'string';
|
|
96
|
-
const typeDefinition = definitions[primaryType];
|
|
97
|
-
|
|
98
|
-
enrichedPaths.push({
|
|
99
|
-
path: component.name,
|
|
100
|
-
type: 'component',
|
|
101
|
-
operators: typeDefinition?.operators || [],
|
|
102
|
-
value_schema: typeDefinition?.value_schema || {},
|
|
103
|
-
group: 'component',
|
|
104
|
-
displayLabel: component.name,
|
|
105
|
-
ui_types: component.ui_types,
|
|
106
|
-
availablePaths: component.paths || [],
|
|
107
|
-
pathCount: component.paths ? component.paths?.length : 0,
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
setPaths(enrichedPaths);
|
|
120
|
+
setPaths(mapPathAutocompleteResponseToPathInfos(pathData, definitions));
|
|
112
121
|
}, [pathData, definitions, debouncedPrefix?.length]);
|
|
113
122
|
|
|
114
123
|
const errorMessage =
|
|
@@ -118,3 +127,51 @@ export const usePathAutocomplete = (prefix: string, entityType: EntityKind) => {
|
|
|
118
127
|
|
|
119
128
|
return { paths, loading: isLoading, error: errorMessage };
|
|
120
129
|
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolves PathInfo for exact field paths that were never picked through the field
|
|
133
|
+
* selector, e.g. the fields of a query restored from the URL. Returns a map that gains
|
|
134
|
+
* an entry per field once its lookup settles: the matching PathInfo, or null when the
|
|
135
|
+
* backend does not know the path. Each field is looked up at most once.
|
|
136
|
+
*/
|
|
137
|
+
export const useFieldsPathInfo = (fields: string[], entityType: EntityKind) => {
|
|
138
|
+
const [fieldsPathInfo, setFieldsPathInfo] = useState<Map<string, PathInfo | null>>(new Map());
|
|
139
|
+
const requestedFieldsRef = useRef<Set<string>>(new Set());
|
|
140
|
+
const [fetchPaths] = useLazySearchPathsQuery();
|
|
141
|
+
const { data: definitions, isError: definitionsFailed } = useSearchDefinitionsQuery();
|
|
142
|
+
|
|
143
|
+
// Wait for the definitions request to settle so resolved fields get the backend's
|
|
144
|
+
// operator lists instead of the fallback ones.
|
|
145
|
+
const settledDefinitions = definitions ?? (definitionsFailed ? FALLBACK_DEFINITIONS : undefined);
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
// Changing entity type invalidates previous lookups.
|
|
149
|
+
requestedFieldsRef.current = new Set();
|
|
150
|
+
setFieldsPathInfo(new Map());
|
|
151
|
+
}, [entityType]);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!settledDefinitions) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const newFields = fields.filter((field) => field && !requestedFieldsRef.current.has(field));
|
|
158
|
+
newFields.forEach((field) => {
|
|
159
|
+
requestedFieldsRef.current.add(field);
|
|
160
|
+
fetchPaths({ q: field, entity_type: entityType }, true)
|
|
161
|
+
.unwrap()
|
|
162
|
+
.then((pathData) => {
|
|
163
|
+
const pathInfos = mapPathAutocompleteResponseToPathInfos(pathData, settledDefinitions);
|
|
164
|
+
const match =
|
|
165
|
+
pathInfos.find((pathInfo) => pathInfo.path === field)
|
|
166
|
+
?? pathInfos.find((pathInfo) => pathInfo.availablePaths?.includes(field));
|
|
167
|
+
setFieldsPathInfo((previous) => new Map(previous).set(field, match ?? null));
|
|
168
|
+
})
|
|
169
|
+
.catch(() => {
|
|
170
|
+
// Allow a retry on a later render, e.g. after a transient network error.
|
|
171
|
+
requestedFieldsRef.current.delete(field);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}, [fields, entityType, fetchPaths, settledDefinitions]);
|
|
175
|
+
|
|
176
|
+
return fieldsPathInfo;
|
|
177
|
+
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type { RuleGroupType } from 'react-querybuilder';
|
|
3
3
|
import { formatQuery } from 'react-querybuilder/formatQuery';
|
|
4
|
-
import { parseCEL } from 'react-querybuilder/parseCEL';
|
|
5
4
|
|
|
6
5
|
import { useTranslations } from 'next-intl';
|
|
7
6
|
import Link from 'next/link';
|
|
@@ -389,21 +388,19 @@ export const WfoSearchPocPage = () => {
|
|
|
389
388
|
};
|
|
390
389
|
|
|
391
390
|
const safeCelParse = useCallback((celString: string) => {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
} catch {
|
|
391
|
+
if (celString === '') {
|
|
392
|
+
setIsValidFilterString(true);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// parseCelToRuleGroup returns undefined when the string parses to no rules — in that
|
|
396
|
+
// case we assume the string is not valid. In any case it would not do anything to the
|
|
397
|
+
// search results. It also assigns the rule ids parseCEL leaves out, which the query
|
|
398
|
+
// builder needs to keep rule identity stable across query updates.
|
|
399
|
+
const ruleGroup = parseCelToRuleGroup(celString);
|
|
400
|
+
if (ruleGroup) {
|
|
401
|
+
setIsValidFilterString(true);
|
|
402
|
+
setQueryBuilderRuleGroup(ruleGroup);
|
|
403
|
+
} else {
|
|
407
404
|
setIsValidFilterString(false);
|
|
408
405
|
}
|
|
409
406
|
}, []);
|
|
@@ -448,7 +445,11 @@ export const WfoSearchPocPage = () => {
|
|
|
448
445
|
setIsValidFilterString(true);
|
|
449
446
|
} else {
|
|
450
447
|
setFilterString(celQuery);
|
|
451
|
-
|
|
448
|
+
// formatQuery output is normally valid CEL, but not unconditionally — a rule can
|
|
449
|
+
// hold state formatQuery renders as unparseable CEL (e.g. a 'field' value source
|
|
450
|
+
// renders its value unquoted). Validate the round trip so the invalid marker and
|
|
451
|
+
// the Apply button track what the textarea actually shows.
|
|
452
|
+
setIsValidFilterString(!!parseCelToRuleGroup(celQuery));
|
|
452
453
|
}
|
|
453
454
|
}
|
|
454
455
|
};
|