@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
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,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,8 +8,8 @@ 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';
|
|
11
|
+
import { useFieldsPathInfo, useWithOrchestratorTheme } from '@/hooks';
|
|
12
|
+
import { EntityKind, OperatorDisplay } from '@/types';
|
|
13
13
|
import type { FieldToOperatorMap, PathInfo } from '@/types';
|
|
14
14
|
|
|
15
15
|
import { WfoFieldSelector } from './WfoFieldSelector';
|
|
@@ -20,6 +20,7 @@ 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.
|
|
@@ -129,6 +130,27 @@ export const WfoFilterBuilder = ({
|
|
|
129
130
|
}
|
|
130
131
|
};
|
|
131
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
|
+
|
|
132
154
|
return (
|
|
133
155
|
<EuiFlexGroup css={queryBuilderContainerStyles}>
|
|
134
156
|
<EuiFlexGroup direction={'column'}>
|
|
@@ -162,6 +184,11 @@ export const WfoFilterBuilder = ({
|
|
|
162
184
|
addGroupAction: null,
|
|
163
185
|
removeGroupAction: null,
|
|
164
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,
|
|
165
192
|
}}
|
|
166
193
|
addRuleToNewGroups
|
|
167
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
|
+
});
|
|
@@ -15,11 +15,20 @@ const FALLBACK_OPERATOR_LABELS: Record<string, string> = {
|
|
|
15
15
|
null: '✗ does not have component',
|
|
16
16
|
};
|
|
17
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
|
+
|
|
18
23
|
export const WfoOperatorSelector = (props: OperatorSelectorProps) => {
|
|
19
24
|
const { value, handleOnChange } = props;
|
|
20
25
|
|
|
21
|
-
const flatOptions = (
|
|
22
|
-
|
|
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],
|
|
23
32
|
);
|
|
24
33
|
|
|
25
34
|
const selectOptions = useMemo(
|
|
@@ -34,18 +43,31 @@ export const WfoOperatorSelector = (props: OperatorSelectorProps) => {
|
|
|
34
43
|
const optionsChanged = previousOptionsKeyRef.current !== optionsKey;
|
|
35
44
|
previousOptionsKeyRef.current = optionsKey;
|
|
36
45
|
|
|
37
|
-
// Reset to
|
|
38
|
-
// user picked a (different) field
|
|
39
|
-
// preserved an operator that is not valid for it
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
+
|
|
44
66
|
const currentValueIsValid = selectOptions.some((option) => option.value === value);
|
|
45
67
|
if (!currentValueIsValid) {
|
|
46
|
-
handleOnChange(
|
|
68
|
+
handleOnChange(getDefaultOperator(flatOptions));
|
|
47
69
|
}
|
|
48
|
-
}, [optionsKey, selectOptions, value, handleOnChange]);
|
|
70
|
+
}, [optionsKey, flatOptions, selectOptions, value, handleOnChange]);
|
|
49
71
|
|
|
50
72
|
// A restored operator that falls outside the field's list must still be visible in
|
|
51
73
|
// the dropdown; without it EuiSelect renders an empty selection for the rule.
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -24,12 +24,30 @@ enum UiFieldType {
|
|
|
24
24
|
datetime = 'datetime',
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
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());
|
|
29
38
|
|
|
30
39
|
useEffect(() => {
|
|
31
|
-
|
|
32
|
-
//
|
|
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
|
|
33
51
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
52
|
}, []);
|
|
35
53
|
|
|
@@ -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';
|