@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.
- package/.turbo/turbo-build.log +8 -8
- package/.turbo/turbo-lint.log +1 -1
- package/.turbo/turbo-test.log +17 -10
- package/CHANGELOG.md +28 -0
- package/dist/index.d.ts +506 -19
- package/dist/index.js +614 -399
- package/dist/index.js.map +1 -1
- package/jest.config.cjs +9 -8
- package/package.json +1 -1
- package/src/components/WfoBadges/WfoWorkflowTargetBadge/WfoWorkflowTargetBadge.tsx +2 -2
- package/src/components/WfoContentHeader/WfoContentHeader.tsx +1 -1
- package/src/components/WfoInlineNoteEdit/WfoProcessListNoteEdit.spec.tsx +35 -0
- package/src/components/WfoInlineNoteEdit/WfoProcessListNoteEdit.tsx +7 -9
- package/src/components/WfoProcessList/WfoProcessListDeltaPopover.tsx +1 -1
- package/src/components/WfoProcessList/WfoProcessesList.tsx +7 -5
- package/src/components/WfoSubscription/WfoProcessesTimeline.tsx +21 -7
- package/src/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActions.tsx +25 -9
- package/src/components/WfoSubscription/WfoSubscriptionGeneralSections/WfoSubscriptionDetailSection.tsx +1 -1
- package/src/components/WfoSubscription/styles.ts +6 -0
- package/src/components/WfoSubscription/utils/utils.ts +2 -4
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoExpandingSearchRow.tsx +0 -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/WfoStructuredSearchTable.tsx +40 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.spec.tsx +60 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +22 -4
- package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +8 -9
- package/src/components/WfoTable/WfoStructuredSearchTable/utils.spec.ts +56 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +40 -1
- package/src/components/WfoTable/WfoTable/WfoTable.tsx +5 -1
- package/src/components/WfoTable/WfoTable/WfoTableDataRows.tsx +11 -2
- package/src/components/WfoTable/WfoTable/WfoTableExpandedRowReveal.spec.tsx +120 -0
- package/src/components/WfoTable/WfoTable/styles.ts +6 -0
- package/src/components/WfoTable/utils/tableConfigPersistence.ts +1 -0
- package/src/components/WfoTimeline/styles.ts +1 -1
- package/src/components/WfoWorkflowUserGuide/WfoPageWithUserGuide.tsx +8 -2
- package/src/components/WfoWorkflowUserGuide/WfoWorkflowGuideExpandablePanel.tsx +39 -30
- package/src/components/WfoWorkflowUserGuide/styles.ts +55 -7
- package/src/configuration/constants.ts +1 -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/hooks/useStoredTableConfig.ts +1 -0
- package/src/messages/en-GB.json +3 -1
- package/src/messages/nl-NL.json +4 -2
- package/src/pages/WfoSearchPocPage.tsx +19 -18
- package/src/pages/tasks/WfoTasksListPage.tsx +1 -0
- package/src/rtk/endpoints/search.ts +1 -0
- package/src/types/types.ts +11 -11
- 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 {
|
|
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
|
-
|
|
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
|
|
|
@@ -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:
|
|
106
|
-
backgroundColor:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
+
rowExpandingConfiguration && (showExpandedRows ? showExpandedRowStyle : toggleExpandedRowOnHoverStyle),
|
|
53
62
|
]}
|
|
54
63
|
onClick={() => onRowClick?.(row)}
|
|
55
64
|
>
|