@orchestrator-ui/orchestrator-ui-components 8.7.2 → 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 +15 -10
- package/CHANGELOG.md +6 -0
- package/dist/index.d.ts +484 -2
- package/dist/index.js +276 -183
- package/dist/index.js.map +1 -1
- package/jest.config.cjs +9 -8
- package/package.json +1 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +30 -3
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoOperatorSelector.spec.tsx +143 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoOperatorSelector.tsx +33 -11
- 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 +22 -4
- 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,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
|
};
|