@orchestrator-ui/orchestrator-ui-components 8.7.2 → 8.8.1

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.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +8 -8
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/.turbo/turbo-test.log +17 -10
  4. package/CHANGELOG.md +28 -0
  5. package/dist/index.d.ts +506 -19
  6. package/dist/index.js +614 -399
  7. package/dist/index.js.map +1 -1
  8. package/jest.config.cjs +9 -8
  9. package/package.json +1 -1
  10. package/src/components/WfoBadges/WfoWorkflowTargetBadge/WfoWorkflowTargetBadge.tsx +2 -2
  11. package/src/components/WfoContentHeader/WfoContentHeader.tsx +1 -1
  12. package/src/components/WfoInlineNoteEdit/WfoProcessListNoteEdit.spec.tsx +35 -0
  13. package/src/components/WfoInlineNoteEdit/WfoProcessListNoteEdit.tsx +7 -9
  14. package/src/components/WfoProcessList/WfoProcessListDeltaPopover.tsx +1 -1
  15. package/src/components/WfoProcessList/WfoProcessesList.tsx +7 -5
  16. package/src/components/WfoSubscription/WfoProcessesTimeline.tsx +21 -7
  17. package/src/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActions.tsx +25 -9
  18. package/src/components/WfoSubscription/WfoSubscriptionGeneralSections/WfoSubscriptionDetailSection.tsx +1 -1
  19. package/src/components/WfoSubscription/styles.ts +6 -0
  20. package/src/components/WfoSubscription/utils/utils.ts +2 -4
  21. package/src/components/WfoTable/WfoStructuredSearchTable/WfoExpandingSearchRow.tsx +0 -1
  22. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +30 -3
  23. package/src/components/WfoTable/WfoStructuredSearchTable/WfoOperatorSelector.spec.tsx +143 -0
  24. package/src/components/WfoTable/WfoStructuredSearchTable/WfoOperatorSelector.tsx +33 -11
  25. package/src/components/WfoTable/WfoStructuredSearchTable/WfoRestoreLoop.spec.tsx +130 -0
  26. package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +40 -1
  27. package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.spec.tsx +60 -0
  28. package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +22 -4
  29. package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +8 -9
  30. package/src/components/WfoTable/WfoStructuredSearchTable/utils.spec.ts +56 -0
  31. package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +40 -1
  32. package/src/components/WfoTable/WfoTable/WfoTable.tsx +5 -1
  33. package/src/components/WfoTable/WfoTable/WfoTableDataRows.tsx +11 -2
  34. package/src/components/WfoTable/WfoTable/WfoTableExpandedRowReveal.spec.tsx +120 -0
  35. package/src/components/WfoTable/WfoTable/styles.ts +6 -0
  36. package/src/components/WfoTable/utils/tableConfigPersistence.ts +1 -0
  37. package/src/components/WfoTimeline/styles.ts +1 -1
  38. package/src/components/WfoWorkflowUserGuide/WfoPageWithUserGuide.tsx +8 -2
  39. package/src/components/WfoWorkflowUserGuide/WfoWorkflowGuideExpandablePanel.tsx +39 -30
  40. package/src/components/WfoWorkflowUserGuide/styles.ts +55 -7
  41. package/src/configuration/constants.ts +1 -1
  42. package/src/configuration/version.ts +1 -1
  43. package/src/hooks/usePathAutoComplete.spec.tsx +64 -0
  44. package/src/hooks/usePathAutoComplete.ts +98 -41
  45. package/src/hooks/useStoredTableConfig.ts +1 -0
  46. package/src/messages/en-GB.json +3 -1
  47. package/src/messages/nl-NL.json +4 -2
  48. package/src/pages/WfoSearchPocPage.tsx +19 -18
  49. package/src/pages/tasks/WfoTasksListPage.tsx +1 -0
  50. package/src/rtk/endpoints/search.ts +1 -0
  51. package/src/types/types.ts +11 -11
  52. package/src/utils/getDefaultTableConfig.ts +2 -0
@@ -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
+ });
@@ -3,7 +3,16 @@ import type { RuleGroupType } from 'react-querybuilder';
3
3
 
4
4
  import { useTranslations } from 'next-intl';
5
5
 
6
- import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect, EuiSpacer, EuiText } from '@elastic/eui';
6
+ import {
7
+ EuiButton,
8
+ EuiFlexGroup,
9
+ EuiFlexItem,
10
+ EuiFormRow,
11
+ EuiSelect,
12
+ EuiSpacer,
13
+ EuiSwitch,
14
+ EuiText,
15
+ } from '@elastic/eui';
7
16
 
8
17
  import {
9
18
  DEFAULT_PAGE_SIZE,
@@ -70,6 +79,7 @@ export type WfoStructuredSearchTableProps<T extends object> = Omit<
70
79
  tableColumnConfig: WfoStructuredSearchTableColumnConfig<T>;
71
80
  rowExpandingConfiguration: WfoTableProps<T>['rowExpandingConfiguration'];
72
81
  defaultHiddenColumns?: TableColumnKeys<T>;
82
+ defaultShowMatchDetails?: boolean;
73
83
  queryText?: string;
74
84
  localStorageKey: string;
75
85
  exportDataIsLoading?: boolean;
@@ -100,6 +110,7 @@ export type WfoStructuredSearchTableProps<T extends object> = Omit<
100
110
  export const WfoStructuredSearchTable = <T extends object>({
101
111
  tableColumnConfig,
102
112
  defaultHiddenColumns = [],
113
+ defaultShowMatchDetails = false,
103
114
  queryText,
104
115
  localStorageKey,
105
116
  exportDataIsLoading,
@@ -136,6 +147,7 @@ export const WfoStructuredSearchTable = <T extends object>({
136
147
  const [showTableSettingsModal, setShowTableSettingsModal] = useState(false);
137
148
  const [rowDetailModalData, setRowDetailModalData] = useState<T | undefined>(undefined);
138
149
  const [showInformationModal, setShowInformationModal] = useState(false);
150
+ const [showMatchDetails, setShowMatchDetails] = useState(defaultShowMatchDetails);
139
151
  const t = useTranslations('common');
140
152
 
141
153
  useEffect(() => {
@@ -144,6 +156,10 @@ export const WfoStructuredSearchTable = <T extends object>({
144
156
  }
145
157
  }, [defaultHiddenColumns]);
146
158
 
159
+ useEffect(() => {
160
+ setShowMatchDetails(defaultShowMatchDetails);
161
+ }, [defaultShowMatchDetails]);
162
+
147
163
  useEffect(() => {
148
164
  if (filterString) {
149
165
  setIsFilterBuilderVisible(true);
@@ -182,6 +198,18 @@ export const WfoStructuredSearchTable = <T extends object>({
182
198
  setTableConfigToLocalStorage(localStorageKey, {
183
199
  hiddenColumns: updatedHiddenColumns,
184
200
  selectedPageSize: updatedTableConfig.selectedPageSize,
201
+ showMatchDetails,
202
+ });
203
+ };
204
+
205
+ // The toggle applies live, so persist it immediately alongside the currently committed
206
+ // hidden columns and page size instead of waiting for the modal's "Update" action.
207
+ const handleToggleShowMatchDetails = (checked: boolean) => {
208
+ setShowMatchDetails(checked);
209
+ setTableConfigToLocalStorage(localStorageKey, {
210
+ hiddenColumns,
211
+ selectedPageSize: pageSize ?? DEFAULT_PAGE_SIZE,
212
+ showMatchDetails: checked,
185
213
  });
186
214
  };
187
215
 
@@ -189,6 +217,7 @@ export const WfoStructuredSearchTable = <T extends object>({
189
217
  const defaultTableConfig = getDefaultTableConfig<T>(localStorageKey);
190
218
  setHiddenColumns(defaultTableConfig.hiddenColumns);
191
219
  setPageSize(defaultTableConfig.selectedPageSize);
220
+ setShowMatchDetails(defaultTableConfig.showMatchDetails ?? false);
192
221
  setShowTableSettingsModal(false);
193
222
  clearTableConfigFromLocalStorage(localStorageKey);
194
223
  };
@@ -255,6 +284,7 @@ export const WfoStructuredSearchTable = <T extends object>({
255
284
  columnConfig={tableColumnsWithControlColumns}
256
285
  hiddenColumns={hiddenColumns}
257
286
  rowExpandingConfiguration={rowExpandingConfiguration}
287
+ showExpandedRows={showMatchDetails}
258
288
  onUpdateDataSorting={onUpdateDataSorting}
259
289
  onUpdateDataSearch={handleColumnFilterSearch}
260
290
  dataSorting={dataSorting}
@@ -284,6 +314,15 @@ export const WfoStructuredSearchTable = <T extends object>({
284
314
  onResetToDefaults={handleResetToDefaults}
285
315
  extraSettings={
286
316
  <>
317
+ <EuiFormRow label={t('showMatchDetails')} display="columnCompressed">
318
+ <EuiSwitch
319
+ showLabel={false}
320
+ label={t('showMatchDetails')}
321
+ checked={showMatchDetails}
322
+ onChange={(event) => handleToggleShowMatchDetails(event.target.checked)}
323
+ compressed
324
+ />
325
+ </EuiFormRow>
287
326
  <EuiFormRow label={t('retrieval')} display="columnCompressed">
288
327
  <EuiSelect
289
328
  options={[
@@ -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
- const BooleanEditor = ({ handleOnChange, value: currentValue = true }: EditorInputFieldProps<boolean>) => {
28
- 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());
29
38
 
30
39
  useEffect(() => {
31
- handleOnChange(true);
32
- // 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
33
51
  // eslint-disable-next-line react-hooks/exhaustive-deps
34
52
  }, []);
35
53
 
@@ -2,7 +2,7 @@ import { css } from '@emotion/react';
2
2
 
3
3
  import { WfoThemeHelpers } from '@/hooks';
4
4
 
5
- export const getWfoStructuredSearchTableStyles = ({ theme }: WfoThemeHelpers) => {
5
+ export const getWfoStructuredSearchTableStyles = ({ theme, isDarkModeActive }: WfoThemeHelpers) => {
6
6
  const queryBuilderContainerStyles = css({
7
7
  backgroundColor: theme.colors.backgroundBaseSubdued,
8
8
  padding: theme.base / 2,
@@ -63,7 +63,7 @@ export const getWfoStructuredSearchTableStyles = ({ theme }: WfoThemeHelpers) =>
63
63
  const ruleGroupContainerBlueStyles = css({
64
64
  ...ruleGroupContainerBase,
65
65
  borderRadius: theme.border.radius.small,
66
- backgroundColor: '#E9F1F9',
66
+ backgroundColor: isDarkModeActive ? theme.colors.backgroundLightPrimary : '#E9F1F9',
67
67
  });
68
68
 
69
69
  const ruleGroupContainerWhiteStyles = css({
@@ -83,7 +83,7 @@ export const getWfoStructuredSearchTableStyles = ({ theme }: WfoThemeHelpers) =>
83
83
  ...ruleGroupContainerBase,
84
84
  borderBottomLeftRadius: theme.border.radius.small,
85
85
  borderTopLeftRadius: theme.border.radius.small,
86
- backgroundColor: '#E9F1F9',
86
+ backgroundColor: isDarkModeActive ? theme.colors.backgroundLightPrimary : '#E9F1F9',
87
87
  });
88
88
 
89
89
  const removeGroupActionStyles = css({
@@ -99,11 +99,10 @@ export const getWfoStructuredSearchTableStyles = ({ theme }: WfoThemeHelpers) =>
99
99
  });
100
100
 
101
101
  const expandingSearchRowStyles = css({
102
- padding: theme.base / 2,
103
- height: theme.base * 2,
102
+ padding: `${theme.base / 4}px ${theme.base / 2}px`,
104
103
  borderRadius: theme.border.radius.medium,
105
- border: 'thin solid FEF7E0',
106
- backgroundColor: '#FEF7E0',
104
+ border: `thin solid ${theme.colors.highlight}`,
105
+ backgroundColor: theme.colors.highlight,
107
106
  });
108
107
  const expandingRowBodyStyles = css({
109
108
  display: 'flex',
@@ -166,12 +165,12 @@ export const getWfoStructuredSearchTableStyles = ({ theme }: WfoThemeHelpers) =>
166
165
  display: 'none',
167
166
  });
168
167
  const dotStyles = css({
169
- padding: theme.base / 4,
168
+ padding: `${theme.base / 8}px ${theme.base / 4}px`,
170
169
  });
171
170
 
172
171
  const expandingRowFieldStyles = css({
173
172
  display: 'flex',
174
- padding: theme.base / 4,
173
+ padding: `${theme.base / 8}px ${theme.base / 4}px`,
175
174
  alignItems: 'center',
176
175
  });
177
176
 
@@ -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
  }
@@ -82,8 +82,9 @@ export type WfoTableProps<T extends object> = {
82
82
  rowExpandingConfiguration?: {
83
83
  uniqueRowId: keyof WfoTableColumnConfig<T>;
84
84
  uniqueRowIdToExpandedRowMap: Record<string, ReactNode>;
85
- shouldOnlyShowOnHover?: boolean;
86
85
  };
86
+ // When true, every row's expanded detail row is revealed at once (otherwise all are hidden)
87
+ showExpandedRows?: boolean;
87
88
  pagination?: Pagination;
88
89
  overrideHeader?: (
89
90
  tableHeaderEntries: Array<[string, WfoTableControlColumnConfigItem<T> | WfoTableDataColumnConfigItem<T, keyof T>]>,
@@ -107,6 +108,7 @@ export const WfoTable = <T extends object>({
107
108
  isLoading = false,
108
109
  dataSorting = [],
109
110
  rowExpandingConfiguration,
111
+ showExpandedRows = false,
110
112
  pagination,
111
113
  overrideHeader,
112
114
  onUpdateDataSorting,
@@ -236,6 +238,7 @@ export const WfoTable = <T extends object>({
236
238
  hiddenColumns={hiddenColumns}
237
239
  columnOrder={columnOrder}
238
240
  rowExpandingConfiguration={rowExpandingConfiguration}
241
+ showExpandedRows={showExpandedRows}
239
242
  onRowClick={onRowClick}
240
243
  />
241
244
  ))}
@@ -252,6 +255,7 @@ export const WfoTable = <T extends object>({
252
255
  hiddenColumns={hiddenColumns}
253
256
  columnOrder={columnOrder}
254
257
  rowExpandingConfiguration={rowExpandingConfiguration}
258
+ showExpandedRows={showExpandedRows}
255
259
  onRowClick={onRowClick}
256
260
  />
257
261
  </tbody>
@@ -11,7 +11,14 @@ import { getSortedVisibleColumns } from './utils';
11
11
 
12
12
  export type WfoTableDataRowsProps<T extends object> = Pick<
13
13
  WfoTableProps<T>,
14
- 'data' | 'columnConfig' | 'hiddenColumns' | 'columnOrder' | 'rowExpandingConfiguration' | 'onRowClick' | 'className'
14
+ | 'data'
15
+ | 'columnConfig'
16
+ | 'hiddenColumns'
17
+ | 'columnOrder'
18
+ | 'rowExpandingConfiguration'
19
+ | 'showExpandedRows'
20
+ | 'onRowClick'
21
+ | 'className'
15
22
  >;
16
23
 
17
24
  export const DATA_ROW_CLASS = 'data-row';
@@ -24,6 +31,7 @@ export const WfoTableDataRows = <T extends object>({
24
31
  hiddenColumns = [],
25
32
  columnOrder = [],
26
33
  rowExpandingConfiguration,
34
+ showExpandedRows,
27
35
  onRowClick,
28
36
  className,
29
37
  }: WfoTableDataRowsProps<T>) => {
@@ -34,6 +42,7 @@ export const WfoTableDataRows = <T extends object>({
34
42
  dataRowStyle,
35
43
  clickableStyle,
36
44
  setWidth,
45
+ showExpandedRowStyle,
37
46
  toggleExpandedRowOnHoverStyle,
38
47
  } = useWithOrchestratorTheme(getWfoTableStyles);
39
48
 
@@ -49,7 +58,7 @@ export const WfoTableDataRows = <T extends object>({
49
58
  rowStyle,
50
59
  dataRowStyle,
51
60
  onRowClick && clickableStyle,
52
- rowExpandingConfiguration?.shouldOnlyShowOnHover && toggleExpandedRowOnHoverStyle,
61
+ rowExpandingConfiguration && (showExpandedRows ? showExpandedRowStyle : toggleExpandedRowOnHoverStyle),
53
62
  ]}
54
63
  onClick={() => onRowClick?.(row)}
55
64
  >