@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/jest.config.cjs CHANGED
@@ -2,19 +2,20 @@ const base = require('@orchestrator-ui/jest-config/jest-base.config.js');
2
2
  const nextJest = require('next/jest');
3
3
 
4
4
  const createJestConfig = nextJest({
5
- // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
6
- dir: './',
5
+ // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
6
+ dir: './',
7
7
  });
8
8
 
9
9
  // The entry for "uuid" in the moduleNameMapper can be removed when EUI updates the dependency version to 9.0.0 or higher.
10
10
  // https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md#900-2022-09-05
11
11
  const customJestConfig = {
12
- ...base,
13
- displayName: 'Wfo-UI Tests',
14
- moduleNameMapper: {
15
- '^uuid$': 'uuid',
16
- '^@copilotkit/react-core/v2$': '<rootDir>/__mocks__/@copilotkit/react-core.js',
17
- },
12
+ ...base,
13
+ displayName: 'Wfo-UI Tests',
14
+ moduleNameMapper: {
15
+ '^uuid$': 'uuid',
16
+ // Mirrors the "@/*" path alias from tsconfig.json
17
+ '^@/(.*)$': '<rootDir>/src/$1',
18
+ },
18
19
  };
19
20
 
20
21
  module.exports = createJestConfig(customJestConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchestrator-ui/orchestrator-ui-components",
3
- "version": "8.7.1",
3
+ "version": "8.7.3",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Library of UI Components used to display the workflow orchestrator frontend",
6
6
  "author": {
@@ -85,7 +85,7 @@ export const WfoFieldSelector = ({ handleOnChange, disabled, rule, context }: Fi
85
85
  ?? paths.find((path) => path.availablePaths?.includes(selectedValue));
86
86
  const operators = matchingPath?.operators ?? prefilledFieldOptions.get(selectedValue) ?? [];
87
87
 
88
- context?.onFieldSelected?.(selectedValue, operators);
88
+ context?.onFieldSelected?.(selectedValue, operators, matchingPath);
89
89
  };
90
90
 
91
91
  const handleFieldSelection = (selectedOptions: EuiComboBoxOptionOption<string>[]) => {
@@ -1,4 +1,4 @@
1
- import React, { useRef, useState } from 'react';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { FullOperator, QueryBuilder, type RuleGroupType, generateID } from 'react-querybuilder';
3
3
  import 'react-querybuilder/dist/query-builder.css';
4
4
 
@@ -8,9 +8,9 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
8
8
 
9
9
  import { SearchParams, WfoAutoExpandableTextArea, WfoTextAnchor } from '@/components';
10
10
  import { WfoCombinatorSelector } from '@/components/WfoTable/WfoStructuredSearchTable/WfoCombinatorSelector';
11
- import { useWithOrchestratorTheme } from '@/hooks';
12
- import { OperatorDisplay } from '@/types';
13
- import type { FieldToOperatorMap } from '@/types';
11
+ import { useFieldsPathInfo, useWithOrchestratorTheme } from '@/hooks';
12
+ import { EntityKind, OperatorDisplay } from '@/types';
13
+ import type { FieldToOperatorMap, PathInfo } from '@/types';
14
14
 
15
15
  import { WfoFieldSelector } from './WfoFieldSelector';
16
16
  import { WfoInlineCombinator } from './WfoInlineCombinator';
@@ -20,9 +20,13 @@ import { WfoRule } from './WfoRule';
20
20
  import { WfoRuleGroup } from './WfoRuleGroup';
21
21
  import { WfoValueEditor } from './WfoValueEditor';
22
22
  import { getWfoStructuredSearchTableStyles } from './styles';
23
+ import { collectRuleFields } from './utils';
23
24
 
24
25
  // Maps PathInfo operator names to react-querybuilder's native operator names,
25
26
  // which is what parseCEL produces and formatQuery(cel) expects.
27
+ // has_component/not_has_component ride on notNull/null: they survive the CEL round trip
28
+ // (`field != null` / `field == null`) and formatQuery(elasticsearch) turns them into
29
+ // exists / must_not-exists, which the backend translates to component-presence filters.
26
30
  const SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP: Record<string, string> = {
27
31
  eq: '=',
28
32
  neq: '!=',
@@ -32,8 +36,13 @@ const SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP: Record<string, string> = {
32
36
  gte: '>=',
33
37
  between: 'between',
34
38
  like: 'contains',
39
+ has_component: 'notNull',
40
+ not_has_component: 'null',
35
41
  };
36
42
 
43
+ // Operators without a value; marking them unary makes react-querybuilder's Rule hide the value editor.
44
+ const RQB_UNARY_OPERATORS = ['null', 'notNull'];
45
+
37
46
  const OPERATOR_MAP: Record<string, OperatorDisplay> = {
38
47
  eq: { symbol: '=', description: 'equals' },
39
48
  neq: { symbol: '≠', description: 'not equals' },
@@ -47,10 +56,6 @@ const OPERATOR_MAP: Record<string, OperatorDisplay> = {
47
56
  like: { symbol: '∋', description: 'contains' },
48
57
  };
49
58
 
50
- /* TODO: Add the missing operators
51
- ['has_component', 'not_has_component'];
52
- */
53
-
54
59
  interface WfoFilterBuilderProps {
55
60
  filterString?: string;
56
61
  onUpdateFilterString: (filterString: string) => void;
@@ -87,13 +92,21 @@ export const WfoFilterBuilder = ({
87
92
  return (operators ?? []).map((operator) => {
88
93
  const { symbol, description } = OPERATOR_MAP[operator] || { symbol: operator, description: operator };
89
94
  const rqbOperator = SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP[operator] ?? operator;
90
- return { name: rqbOperator, label: `${symbol} ${description}`, value: rqbOperator };
95
+ return {
96
+ name: rqbOperator,
97
+ label: `${symbol} ${description}`,
98
+ value: rqbOperator,
99
+ ...(RQB_UNARY_OPERATORS.includes(rqbOperator) && { arity: 'unary' }),
100
+ };
91
101
  });
92
102
  };
93
103
 
94
104
  const t = useTranslations('common');
95
105
  const { queryBuilderContainerStyles } = useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
96
106
  const [fieldToOperatorMap, setFieldToOperatorMap] = useState<FieldToOperatorMap>(prefilledFieldOptions);
107
+ // Path info per selected field, so the value editor can pick a typed editor (date picker,
108
+ // number input, boolean toggle or range editor)
109
+ const [fieldPathInfoMap, setFieldPathInfoMap] = useState<Map<string, PathInfo>>(new Map());
97
110
 
98
111
  // Enter in a value editor commits its value on blur, and that state update has not
99
112
  // flushed yet when the search runs in the same keydown. onQueryChange fires
@@ -106,12 +119,38 @@ export const WfoFilterBuilder = ({
106
119
  handleSearch({ ruleGroup: latestRuleGroupRef.current });
107
120
  };
108
121
 
109
- const handleFieldSelected = (field: string, operators: string[]) => {
122
+ const handleFieldSelected = (field: string, operators: string[], pathInfo?: PathInfo) => {
110
123
  setFieldToOperatorMap((previousMap) => {
111
124
  return new Map(previousMap).set(field, operators);
112
125
  });
126
+ if (pathInfo) {
127
+ setFieldPathInfoMap((previousMap) => {
128
+ return new Map(previousMap).set(field, pathInfo);
129
+ });
130
+ }
113
131
  };
114
132
 
133
+ // Fields can enter the query without passing through the field selector — a filter
134
+ // restored from the URL or added by a column-header search. Resolve their path info
135
+ // so the value editor renders the right input type (date picker, boolean toggle, ...)
136
+ // and the operator selector gets the field's operator list.
137
+ const unresolvedFields = useMemo(
138
+ () =>
139
+ collectRuleFields(queryBuilderRuleGroup).filter(
140
+ (field) => field && field !== '~' && !fieldPathInfoMap.has(field),
141
+ ),
142
+ [queryBuilderRuleGroup, fieldPathInfoMap],
143
+ );
144
+ const resolvedFieldsPathInfo = useFieldsPathInfo(unresolvedFields, EntityKind.SUBSCRIPTION);
145
+
146
+ useEffect(() => {
147
+ resolvedFieldsPathInfo.forEach((pathInfo, field) => {
148
+ if (pathInfo && !fieldPathInfoMap.has(field)) {
149
+ handleFieldSelected(field, pathInfo.operators, pathInfo);
150
+ }
151
+ });
152
+ }, [fieldPathInfoMap, resolvedFieldsPathInfo]);
153
+
115
154
  return (
116
155
  <EuiFlexGroup css={queryBuilderContainerStyles}>
117
156
  <EuiFlexGroup direction={'column'}>
@@ -126,6 +165,7 @@ export const WfoFilterBuilder = ({
126
165
  context={{
127
166
  onFieldSelected: handleFieldSelected,
128
167
  prefilledFieldOptions,
168
+ fieldPathInfoMap,
129
169
  onValueEditorEnter: handleValueEditorEnter,
130
170
  }}
131
171
  getOperators={(field) => {
@@ -144,6 +184,11 @@ export const WfoFilterBuilder = ({
144
184
  addGroupAction: null,
145
185
  removeGroupAction: null,
146
186
  removeRuleAction: WfoRemoveRuleAction,
187
+ // Field-to-field comparisons are not supported, but a rule can briefly hold
188
+ // valueSource 'field' while a CEL literal is being typed in the textarea
189
+ // ('lldp == fals' parses 'fals' as an identifier) — without this override
190
+ // react-querybuilder's default value source selector flashes into the rule.
191
+ valueSourceSelector: null,
147
192
  }}
148
193
  addRuleToNewGroups
149
194
  onAddGroup={onAddGroupHandler}
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Reproduces the WfoFilterBuilder wiring around operator selection: a controlled
3
+ * QueryBuilder with resetOnFieldChange=false, getOperators backed by a map that is
4
+ * filled when a field is selected (like fieldToOperatorMap), and a value editor
5
+ * that hides itself for the unary null/notNull operators.
6
+ */
7
+ import React, { useState } from 'react';
8
+ import type { FieldSelectorProps, FullOperator, RuleGroupType, ValueEditorProps } from 'react-querybuilder';
9
+ import { QueryBuilder } from 'react-querybuilder';
10
+
11
+ import '@testing-library/jest-dom';
12
+ import { fireEvent, render, screen } from '@testing-library/react';
13
+
14
+ import { WfoOperatorSelector } from './WfoOperatorSelector';
15
+
16
+ const FIELD_OPERATORS: Record<string, FullOperator[]> = {
17
+ componentField: [
18
+ { name: 'notNull', label: '✓ has component', value: 'notNull', arity: 'unary' },
19
+ { name: 'null', label: '✗ does not have component', value: 'null', arity: 'unary' },
20
+ ],
21
+ textField: [
22
+ { name: '=', label: '= equals', value: '=' },
23
+ { name: '!=', label: '≠ not equals', value: '!=' },
24
+ ],
25
+ // A field the autocomplete could not resolve: storeFieldOperators falls back to []
26
+ unknownField: [],
27
+ // A field whose operator list starts with the unary component operators
28
+ mixedField: [
29
+ { name: 'notNull', label: '✓ has component', value: 'notNull', arity: 'unary' },
30
+ { name: 'null', label: '✗ does not have component', value: 'null', arity: 'unary' },
31
+ { name: 'contains', label: '∋ contains', value: 'contains' },
32
+ ],
33
+ };
34
+
35
+ const FieldSelectorStub = ({ handleOnChange, value, context }: FieldSelectorProps) => (
36
+ <select
37
+ data-testid="field-selector"
38
+ value={value}
39
+ onChange={(e) => {
40
+ context.onFieldSelected(e.target.value);
41
+ handleOnChange(e.target.value);
42
+ }}
43
+ >
44
+ <option value="~">~</option>
45
+ <option value="componentField">componentField</option>
46
+ <option value="textField">textField</option>
47
+ <option value="unknownField">unknownField</option>
48
+ <option value="mixedField">mixedField</option>
49
+ </select>
50
+ );
51
+
52
+ const ValueEditorStub = ({ operator }: ValueEditorProps) => {
53
+ if (operator === 'null' || operator === 'notNull') {
54
+ return null;
55
+ }
56
+ return <input data-testid="value-editor" />;
57
+ };
58
+
59
+ const initialRuleGroup: RuleGroupType = {
60
+ id: 'root',
61
+ rules: [{ id: 'rule-0', field: '~', operator: '=', value: '' }],
62
+ combinator: 'and',
63
+ };
64
+
65
+ const Harness = ({ onQueryChange }: { onQueryChange?: (q: RuleGroupType) => void }) => {
66
+ const [query, setQuery] = useState<RuleGroupType>(initialRuleGroup);
67
+ const [fieldToOperatorMap, setFieldToOperatorMap] = useState<Map<string, FullOperator[]>>(new Map());
68
+
69
+ return (
70
+ <QueryBuilder
71
+ query={query}
72
+ enableMountQueryChange={false}
73
+ onQueryChange={(q: RuleGroupType) => {
74
+ setQuery(q);
75
+ onQueryChange?.(q);
76
+ }}
77
+ context={{
78
+ onFieldSelected: (field: string) => {
79
+ setFieldToOperatorMap((previousMap) => new Map(previousMap).set(field, FIELD_OPERATORS[field] ?? []));
80
+ },
81
+ }}
82
+ getOperators={(field) => fieldToOperatorMap.get(field) ?? []}
83
+ controlElements={{
84
+ fieldSelector: FieldSelectorStub,
85
+ operatorSelector: WfoOperatorSelector,
86
+ valueEditor: ValueEditorStub,
87
+ }}
88
+ resetOnFieldChange={false}
89
+ />
90
+ );
91
+ };
92
+
93
+ const selectField = (field: string) => {
94
+ fireEvent.change(screen.getByTestId('field-selector'), { target: { value: field } });
95
+ };
96
+
97
+ // The operator selector is the only select inside the rule row besides the field-selector stub.
98
+ const getOperatorSelect = () =>
99
+ screen
100
+ .getAllByRole<HTMLSelectElement>('combobox')
101
+ .find((select) => select.closest('.rule') && select.dataset.testid !== 'field-selector') as HTMLSelectElement;
102
+
103
+ describe('WfoOperatorSelector operator reset on field change', () => {
104
+ it('resets to the first operator when the selected field does not support the current one', () => {
105
+ render(<Harness />);
106
+
107
+ selectField('componentField');
108
+ expect(getOperatorSelect().value).toBe('notNull');
109
+ expect(screen.queryByTestId('value-editor')).not.toBeInTheDocument();
110
+ });
111
+
112
+ it('recovers from a unary operator when switching to a field without unary operators', () => {
113
+ render(<Harness />);
114
+
115
+ selectField('componentField');
116
+ expect(getOperatorSelect().value).toBe('notNull');
117
+
118
+ selectField('textField');
119
+ expect(getOperatorSelect().value).toBe('=');
120
+ expect(screen.getByTestId('value-editor')).toBeInTheDocument();
121
+ });
122
+
123
+ it('recovers from a unary operator when switching to a field whose operators are unknown', () => {
124
+ render(<Harness />);
125
+
126
+ selectField('componentField');
127
+ expect(getOperatorSelect().value).toBe('notNull');
128
+
129
+ selectField('unknownField');
130
+ expect(getOperatorSelect().value).not.toBe('notNull');
131
+ expect(screen.getByTestId('value-editor')).toBeInTheDocument();
132
+ });
133
+
134
+ it('prefers a non-unary operator as the default when the current operator is invalid', () => {
135
+ render(<Harness />);
136
+
137
+ // The initial rule operator '=' is not in mixedField's list; the reset should skip
138
+ // the leading unary operators so the value editor stays visible.
139
+ selectField('mixedField');
140
+ expect(getOperatorSelect().value).toBe('contains');
141
+ expect(screen.getByTestId('value-editor')).toBeInTheDocument();
142
+ });
143
+ });
@@ -7,11 +7,28 @@ import { EuiSelect } from '@elastic/eui';
7
7
  const isOptionGroup = (operator: FullOperator | OptionGroup<FullOperator>): operator is OptionGroup<FullOperator> =>
8
8
  'options' in operator;
9
9
 
10
+ // The search backend has no is-null operator: null/notNull only occur here as the
11
+ // react-querybuilder encoding of the component-presence operators, so a restored rule
12
+ // should label them accordingly instead of using defaultOperators' "is (not) null".
13
+ const FALLBACK_OPERATOR_LABELS: Record<string, string> = {
14
+ notNull: '✓ has component',
15
+ null: '✗ does not have component',
16
+ };
17
+
18
+ // null/notNull hide the value editor, so they only make a sane default when a field
19
+ // offers nothing else; prefer the first operator that keeps the value editor visible.
20
+ const getDefaultOperator = (options: FullOperator[]) =>
21
+ (options.find((option) => option.arity !== 'unary') ?? options[0]).name;
22
+
10
23
  export const WfoOperatorSelector = (props: OperatorSelectorProps) => {
11
24
  const { value, handleOnChange } = props;
12
25
 
13
- const flatOptions = (props.options as Array<FullOperator | OptionGroup<FullOperator>>).flatMap((option) =>
14
- isOptionGroup(option) ? option.options : [option],
26
+ const flatOptions = useMemo(
27
+ () =>
28
+ (props.options as Array<FullOperator | OptionGroup<FullOperator>>).flatMap((option) =>
29
+ isOptionGroup(option) ? option.options : [option],
30
+ ),
31
+ [props.options],
15
32
  );
16
33
 
17
34
  const selectOptions = useMemo(
@@ -26,25 +43,47 @@ export const WfoOperatorSelector = (props: OperatorSelectorProps) => {
26
43
  const optionsChanged = previousOptionsKeyRef.current !== optionsKey;
27
44
  previousOptionsKeyRef.current = optionsKey;
28
45
 
29
- // Reset to the first option only when the field's operator list changes — i.e. the
30
- // user picked a (different) field, and resetOnFieldChange=false on QueryBuilder
31
- // preserved an operator that is not valid for it. The `optionsChanged` guard keeps
32
- // rules restored from CEL (URL or textarea) intact on mount: parseCEL can produce
33
- // operators outside the prefilled operator lists (e.g. beginsWith), and resetting
34
- // those here would silently rewrite the user's filter string.
35
- if (!optionsChanged || selectOptions.length === 0) return;
46
+ // Reset to a default option only when the field's operator list changes — i.e. the
47
+ // user picked a (different) field and resetOnFieldChange=false on QueryBuilder
48
+ // preserved an operator that is not valid for it, or WfoFilterBuilder resolved the
49
+ // real operator list of a field restored from CEL (URL or textarea). The
50
+ // `optionsChanged` guard keeps restored rules intact while their field's operators
51
+ // are still unknown: parseCEL can produce operators outside the prefilled operator
52
+ // lists (e.g. beginsWith), and resetting those would silently rewrite the user's
53
+ // filter string.
54
+ if (!optionsChanged) return;
55
+
56
+ if (flatOptions.length === 0) {
57
+ // The new field's operators are unknown (nothing stored for it). A left-over unary
58
+ // operator would keep the value editor hidden with nothing in the dropdown to bring
59
+ // it back, so fall back to the query builder's default operator.
60
+ if (value === 'null' || value === 'notNull') {
61
+ handleOnChange('=');
62
+ }
63
+ return;
64
+ }
65
+
36
66
  const currentValueIsValid = selectOptions.some((option) => option.value === value);
37
67
  if (!currentValueIsValid) {
38
- handleOnChange(selectOptions[0].value);
68
+ handleOnChange(getDefaultOperator(flatOptions));
39
69
  }
40
- }, [optionsKey, selectOptions, value, handleOnChange]);
70
+ }, [optionsKey, flatOptions, selectOptions, value, handleOnChange]);
41
71
 
42
72
  // A restored operator that falls outside the field's list must still be visible in
43
73
  // the dropdown; without it EuiSelect renders an empty selection for the rule.
44
74
  const currentValueIsListed = !value || selectOptions.some((option) => option.value === value);
45
75
  const displayOptions =
46
76
  currentValueIsListed ? selectOptions : (
47
- [...selectOptions, { value, text: defaultOperators.find((operator) => operator.name === value)?.label ?? value }]
77
+ [
78
+ ...selectOptions,
79
+ {
80
+ value,
81
+ text:
82
+ FALLBACK_OPERATOR_LABELS[value]
83
+ ?? defaultOperators.find((operator) => operator.name === value)?.label
84
+ ?? value,
85
+ },
86
+ ]
48
87
  );
49
88
 
50
89
  return (
@@ -1,64 +1,52 @@
1
1
  import React, { useEffect, useState } from 'react';
2
+ import type { ValueEditorProps } from 'react-querybuilder';
2
3
 
3
4
  import { EuiFlexGroup } from '@elastic/eui';
4
5
 
5
- export interface WfoRangeElementProps {
6
- handleOnChange: (value: string | number | undefined, rangeIndex?: number) => void;
7
- value: string;
8
- operator?: string;
9
- rangeIndex?: number;
10
- }
6
+ import type { EditorComponent } from './WfoValueEditor';
11
7
 
12
8
  interface WfoRangeEditorProps {
13
- handleOnChange: (value: string | number | undefined, rangeIndex?: number) => void;
14
- operator: string;
9
+ handleOnChange: ValueEditorProps['handleOnChange'];
15
10
  value: string;
16
- Element: React.ComponentType<WfoRangeElementProps>;
11
+ InputElement: EditorComponent;
17
12
  }
18
13
 
19
- export const WfoRangeEditor = ({ handleOnChange, operator, Element, value: currentValue }: WfoRangeEditorProps) => {
20
- const [currentOperator, setCurrentOperator] = useState(operator);
14
+ export const WfoRangeEditor = ({ handleOnChange, InputElement, value: currentValue }: WfoRangeEditorProps) => {
21
15
  const startValue = currentValue ? currentValue?.toString().split(',') : [];
22
- const [value, setValue] = useState<string[]>(startValue);
16
+ const [value, setValue] = useState<(string | undefined)[]>(startValue);
17
+
18
+ const handleRangeChange = (newValue: string | number | boolean | undefined, rangeIndex: number) => {
19
+ setValue((currentValues) => {
20
+ const next = [...currentValues];
21
+ const isCleared = newValue === undefined || (typeof newValue === 'number' && Number.isNaN(newValue));
22
+ next[rangeIndex] = isCleared ? undefined : String(newValue);
23
+ return next;
24
+ });
25
+ };
23
26
 
27
+ // Notify the parent only when both ends of the range hold a value
24
28
  useEffect(() => {
25
- if (operator !== currentOperator && (operator === 'between' || currentOperator === 'between')) {
26
- setValue([]);
27
- handleOnChange('');
28
- setCurrentOperator(operator);
29
- }
30
- }, [currentOperator, handleOnChange, operator]);
31
-
32
- const handleChange = (value: string | number | undefined, rangeIndex: number = 0) => {
33
- if (operator === 'between') {
34
- setValue((currentDates) => {
35
- // remove value if set to undefined
36
- if (value === null) {
37
- return currentDates.filter((_, index) => index !== rangeIndex);
38
- }
39
- // add value at supplied index
40
- currentDates[rangeIndex] = value as string;
41
-
42
- // call the parent if 2 values are present
43
- if (currentDates.length === 2) {
44
- handleOnChange(`${currentDates[0]},${currentDates[1]}`);
45
- }
46
-
47
- return currentDates;
48
- });
49
- } else {
50
- handleOnChange(value);
29
+ if (value[0] !== undefined && value[1] !== undefined) {
30
+ handleOnChange(`${value[0]},${value[1]}`);
51
31
  }
52
- };
53
-
54
- if (operator === 'between') {
55
- return (
56
- <EuiFlexGroup direction="row" gutterSize="s">
57
- <Element handleOnChange={handleChange} rangeIndex={0} value={value[0]} />
58
- <Element handleOnChange={handleChange} rangeIndex={1} value={value[1]} />
59
- </EuiFlexGroup>
60
- );
61
- }
62
-
63
- return <Element handleOnChange={handleOnChange} value={value[0]} />;
32
+ // handleOnChange comes from react-querybuilder and is not referentially stable
33
+ // eslint-disable-next-line react-hooks/exhaustive-deps
34
+ }, [value]);
35
+
36
+ return (
37
+ <EuiFlexGroup direction="row" gutterSize="s">
38
+ <InputElement
39
+ handleOnChange={(value) => {
40
+ handleRangeChange(value, 0);
41
+ }}
42
+ value={value[0]}
43
+ />
44
+ <InputElement
45
+ handleOnChange={(value) => {
46
+ handleRangeChange(value, 1);
47
+ }}
48
+ value={value[1]}
49
+ />
50
+ </EuiFlexGroup>
51
+ );
64
52
  };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Regression test for the URL-restore render loop: a controlled QueryBuilder wired like
3
+ * WfoSearchPocPage + WfoFilterBuilder, restoring `lldp == true` from CEL while the path
4
+ * info of `lldp` resolves asynchronously (boolean ui type + operator list).
5
+ *
6
+ * The rule group is deliberately created with parseCEL directly — without the rule ids
7
+ * parseCelToRuleGroup adds — so every query prop change remounts the rules. The editors
8
+ * must not dispatch a query change on mount for a value the rule already holds, or the
9
+ * remount + mount-commit combination feeds back into an endless update cycle.
10
+ */
11
+ import React, { useEffect, useState } from 'react';
12
+ import type { FieldSelectorProps, FullOperator, RuleGroupType } from 'react-querybuilder';
13
+ import { QueryBuilder, formatQuery } from 'react-querybuilder';
14
+ import { parseCEL } from 'react-querybuilder/parseCEL';
15
+
16
+ import '@testing-library/jest-dom';
17
+ import { act, render, screen } from '@testing-library/react';
18
+
19
+ import type { PathInfo } from '@/types';
20
+
21
+ import { WfoOperatorSelector } from './WfoOperatorSelector';
22
+ import { WfoValueEditor } from './WfoValueEditor';
23
+
24
+ jest.mock('@/hooks', () => ({
25
+ useWithOrchestratorTheme: () => ({}),
26
+ }));
27
+
28
+ jest.mock('next-intl', () => ({
29
+ useTranslations: () => (key: string) => key,
30
+ }));
31
+
32
+ const LLDP_PATH_INFO = {
33
+ path: 'lldp',
34
+ type: 'boolean',
35
+ operators: ['eq', 'neq'],
36
+ value_schema: {},
37
+ group: 'leaf',
38
+ ui_types: ['boolean'],
39
+ } as unknown as PathInfo;
40
+
41
+ const SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP: Record<string, string> = {
42
+ eq: '=',
43
+ neq: '!=',
44
+ };
45
+
46
+ const mapOperators = (operators?: string[]): FullOperator[] =>
47
+ (operators ?? []).map((operator) => {
48
+ const rqbOperator = SEARCH_OPERATOR_TO_RQB_OPERATOR_MAP[operator] ?? operator;
49
+ return { name: rqbOperator, label: rqbOperator, value: rqbOperator };
50
+ });
51
+
52
+ const FieldSelectorStub = ({ value }: FieldSelectorProps) => <span data-testid="field">{value}</span>;
53
+
54
+ // Circuit breaker: records query updates and stops propagating them after a threshold
55
+ // so a render loop terminates and can be inspected instead of hanging the test.
56
+ const queryChangeLog: string[] = [];
57
+
58
+ const Harness = ({ initialCel = 'lldp == true' }: { initialCel?: string }) => {
59
+ // Page-like state: rule group parsed from the URL's CEL string (rules have no ids).
60
+ const [query, setQuery] = useState<RuleGroupType>(() => parseCEL(initialCel));
61
+ const [, setFilterString] = useState<string>(initialCel);
62
+
63
+ // WfoFilterBuilder-like state, with the async field resolution simulated: the maps
64
+ // are empty on mount and get lldp's path info after a tick.
65
+ const [fieldToOperatorMap, setFieldToOperatorMap] = useState<Map<string, string[]>>(new Map());
66
+ const [fieldPathInfoMap, setFieldPathInfoMap] = useState<Map<string, PathInfo>>(new Map());
67
+
68
+ useEffect(() => {
69
+ const timeout = setTimeout(() => {
70
+ setFieldToOperatorMap(new Map([['lldp', LLDP_PATH_INFO.operators]]));
71
+ setFieldPathInfoMap(new Map([['lldp', LLDP_PATH_INFO]]));
72
+ }, 0);
73
+ return () => clearTimeout(timeout);
74
+ }, []);
75
+
76
+ return (
77
+ <QueryBuilder
78
+ query={query}
79
+ enableMountQueryChange={false}
80
+ onQueryChange={(ruleGroup: RuleGroupType) => {
81
+ queryChangeLog.push(JSON.stringify(ruleGroup));
82
+ if (queryChangeLog.length > 20) return;
83
+ // Mirrors WfoSearchPocPage.onUpdateQueryBuilder
84
+ setQuery({ ...ruleGroup });
85
+ setFilterString(formatQuery({ ...ruleGroup }, { format: 'cel', fallbackExpression: '' }));
86
+ }}
87
+ context={{
88
+ fieldPathInfoMap,
89
+ onFieldSelected: () => {},
90
+ }}
91
+ getOperators={(field) => mapOperators(fieldToOperatorMap.get(field))}
92
+ controlElements={{
93
+ fieldSelector: FieldSelectorStub,
94
+ operatorSelector: WfoOperatorSelector,
95
+ valueEditor: WfoValueEditor,
96
+ valueSourceSelector: null,
97
+ }}
98
+ resetOnFieldChange={false}
99
+ />
100
+ );
101
+ };
102
+
103
+ describe('URL restore with async path info resolution', () => {
104
+ it('settles without a render loop once the path info resolves', async () => {
105
+ render(<Harness />);
106
+
107
+ // Flush the simulated resolution plus any follow-up effect cascades.
108
+ await act(async () => {
109
+ await new Promise((resolve) => setTimeout(resolve, 50));
110
+ });
111
+
112
+ expect(screen.getByTestId('field')).toHaveTextContent('lldp');
113
+ // The resolved boolean ui type must render the boolean editor for the restored rule
114
+ expect(screen.getByRole('button', { name: 'True' })).toBeInTheDocument();
115
+ // Nothing about the restored rule changed, so no query updates should have fired
116
+ expect(queryChangeLog).toEqual([]);
117
+ });
118
+
119
+ it('does not flash a value source selector while a literal is half-typed', async () => {
120
+ // 'fals' parses as an identifier, giving the rule valueSource 'field'; without the
121
+ // valueSourceSelector override the default selector appears next to the editor.
122
+ render(<Harness initialCel="lldp == fals" />);
123
+
124
+ await act(async () => {
125
+ await new Promise((resolve) => setTimeout(resolve, 50));
126
+ });
127
+
128
+ expect(document.querySelector('[data-testid="value-source-selector"]')).not.toBeInTheDocument();
129
+ });
130
+ });