@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.
@@ -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
- const enrichedPaths: PathInfo[] = [];
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
- try {
393
- const ruleGroup = parseCEL(celString);
394
- if (celString === '') {
395
- setIsValidFilterString(true);
396
- } else if (ruleGroup?.rules?.length > 0) {
397
- // parseCEL returns a query object check if it has any rules
398
- setIsValidFilterString(true);
399
- setQueryBuilderRuleGroup(ruleGroup);
400
- } else {
401
- // If there are no rules created based on this string then
402
- // we assume the string is not valid. In any case it will not do anything
403
- // to the search results
404
- setIsValidFilterString(false);
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
- setIsValidFilterString(true);
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
  };
@@ -112,5 +112,6 @@ export const {
112
112
  useLazySearchQuery,
113
113
  useSearchWithPaginationMutation,
114
114
  useSearchPathsQuery,
115
+ useLazySearchPathsQuery,
115
116
  useSearchDefinitionsQuery,
116
117
  } = searchApi;