@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.
@@ -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
- import { WfoRangeEditor, WfoRangeElementProps } from './WfoRangeEditor';
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
- export type HandleOnChange<T> = (value: T | undefined, rangeIndex?: number) => void;
23
-
24
- export interface EditorProps<T> {
25
- handleOnChange: HandleOnChange<T>;
26
- value: T;
27
- operator?: string;
28
- }
29
-
30
- const BooleanEditor = ({ handleOnChange, value: currentValue = true }: EditorProps<boolean>) => {
31
- const [value, setValue] = useState<string>(currentValue.toString());
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
- handleOnChange(true);
35
- // Sets the initial value to true so we allow empty dep array here
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 = '' }: EditorProps<string>) => {
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, rangeIndex, value: currentValue }: WfoRangeElementProps) => {
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, rangeIndex);
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, rangeIndex, value: currentValue }: WfoRangeElementProps) => {
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(), rangeIndex);
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
- if (uiFieldType === UiFieldType.boolean) {
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 (uiFieldType === UiFieldType.number) {
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 <TextEditor handleOnChange={handleOnChange} value={value} />;
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
- return ruleGroup?.rules?.length > 0 ? ruleGroup : undefined;
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';
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
- 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;