@evoke-platform/ui-components 1.10.1-dev.0 → 1.10.1-dev.2
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/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.js +15 -15
- package/dist/published/components/custom/CriteriaBuilder/PropertyTree.d.ts +4 -2
- package/dist/published/components/custom/CriteriaBuilder/PropertyTree.js +71 -148
- package/dist/published/components/custom/CriteriaBuilder/PropertyTreeItem.d.ts +11 -7
- package/dist/published/components/custom/CriteriaBuilder/PropertyTreeItem.js +43 -41
- package/dist/published/components/custom/CriteriaBuilder/types.d.ts +8 -1
- package/dist/published/components/custom/CriteriaBuilder/utils.d.ts +7 -13
- package/dist/published/components/custom/CriteriaBuilder/utils.js +13 -23
- package/dist/published/components/custom/FormField/Select/Select.js +23 -4
- package/dist/published/components/custom/FormV2/tests/FormRendererContainer.test.js +480 -15
- package/dist/published/components/custom/FormV2/tests/test-data.js +20 -0
- package/dist/published/stories/CriteriaBuilder.stories.js +30 -0
- package/package.json +1 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { AddRounded, UnfoldMore } from '@mui/icons-material';
|
|
2
|
-
import { Typography } from '@mui/material';
|
|
3
2
|
import { QueryBuilderMaterial } from '@react-querybuilder/material';
|
|
4
3
|
import { isArray, isEmpty, startCase } from 'lodash';
|
|
5
4
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
@@ -7,7 +6,7 @@ import { QueryBuilder, RuleGroupBodyComponents, RuleGroupHeaderComponents, TestI
|
|
|
7
6
|
import 'react-querybuilder/dist/query-builder.css';
|
|
8
7
|
import escape from 'string-escape-regex';
|
|
9
8
|
import { TrashCan } from '../../../icons/custom';
|
|
10
|
-
import { Autocomplete, Button, IconButton } from '../../core';
|
|
9
|
+
import { Autocomplete, Button, IconButton, Typography } from '../../core';
|
|
11
10
|
import { Box } from '../../layout';
|
|
12
11
|
import { OverflowTextField } from '../OverflowTextField';
|
|
13
12
|
import { difference } from '../util';
|
|
@@ -181,7 +180,7 @@ const customSelector = (props) => {
|
|
|
181
180
|
});
|
|
182
181
|
handleOnChange(propertyId);
|
|
183
182
|
};
|
|
184
|
-
return (React.createElement(React.Fragment, null, isTreeViewEnabled ? (React.createElement(PropertyTree, { value: val ?? value, rootObject: object, fetchObject: fetchObject, handleTreePropertySelect: handleTreePropertySelect })) : (React.createElement(Autocomplete, { options: opts, value: val ?? null, getOptionLabel: (option) => {
|
|
183
|
+
return (React.createElement(React.Fragment, null, isTreeViewEnabled ? (React.createElement(PropertyTree, { value: val ?? value, rootObject: object, fetchObject: fetchObject, propertyTreeMap: context.propertyTreeMap ?? {}, handleTreePropertySelect: handleTreePropertySelect })) : (React.createElement(Autocomplete, { options: opts, value: val ?? null, getOptionLabel: (option) => {
|
|
185
184
|
if (typeof option === 'string') {
|
|
186
185
|
return opts.find((o) => option === o.name)?.label || option;
|
|
187
186
|
}
|
|
@@ -264,6 +263,18 @@ export const valueEditor = (props) => {
|
|
|
264
263
|
}
|
|
265
264
|
return ValueEditor(props);
|
|
266
265
|
};
|
|
266
|
+
const getAllRuleIds = (rules) => {
|
|
267
|
+
const ids = [];
|
|
268
|
+
rules.forEach((rule) => {
|
|
269
|
+
if ('rules' in rule) {
|
|
270
|
+
ids.push(...getAllRuleIds(rule.rules));
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
ids.push(rule.field);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
return ids;
|
|
277
|
+
};
|
|
267
278
|
const CriteriaBuilder = (props) => {
|
|
268
279
|
const { properties, criteria, setCriteria, originalCriteria, enablePresetValues, presetValues, operators, disabled, disabledCriteria, hideBorder, presetGroupLabel, customValueEditor, treeViewOpts, disableRegexEscapeChars, } = props;
|
|
269
280
|
const [propertyTreeMap, setPropertyTreeMap] = useState();
|
|
@@ -299,18 +310,7 @@ const CriteriaBuilder = (props) => {
|
|
|
299
310
|
// they are then used in the custom query builder components to determine the input type etc
|
|
300
311
|
const updatePropertyTreeMap = async () => {
|
|
301
312
|
const newQuery = parseMongoDB(criteria || originalCriteria || {});
|
|
302
|
-
const ids =
|
|
303
|
-
const traverseRulesForIds = (rules) => {
|
|
304
|
-
rules.forEach((rule) => {
|
|
305
|
-
if ('rules' in rule) {
|
|
306
|
-
traverseRulesForIds(rule.rules);
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
309
|
-
ids.push(rule.field);
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
};
|
|
313
|
-
traverseRulesForIds(newQuery.rules);
|
|
313
|
+
const ids = getAllRuleIds(newQuery.rules);
|
|
314
314
|
let newPropertyTreeMap = {};
|
|
315
315
|
const newPropertyTreeMapPromises = [];
|
|
316
316
|
for (const id of ids) {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { Property } from '@evoke-platform/context';
|
|
1
2
|
import React from 'react';
|
|
2
3
|
import { EvokeObject } from '../../../types';
|
|
3
4
|
type PropertyTreeProps = {
|
|
4
5
|
fetchObject: (id: string) => Promise<EvokeObject | undefined>;
|
|
5
6
|
rootObject: EvokeObject;
|
|
6
|
-
handleTreePropertySelect: (propertyId: string) => void
|
|
7
|
+
handleTreePropertySelect: (propertyId: string) => Promise<void>;
|
|
7
8
|
value: string | undefined;
|
|
9
|
+
propertyTreeMap: Record<string, Property>;
|
|
8
10
|
};
|
|
9
|
-
declare const PropertyTree: (
|
|
11
|
+
declare const PropertyTree: (props: PropertyTreeProps) => React.JSX.Element;
|
|
10
12
|
export default PropertyTree;
|
|
@@ -1,160 +1,83 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ClickAwayListener } from '@mui/material';
|
|
2
2
|
import React, { useEffect, useState } from 'react';
|
|
3
|
-
import { Autocomplete,
|
|
3
|
+
import { Autocomplete, RichTreeView } from '../../core';
|
|
4
|
+
import { Box } from '../../layout';
|
|
4
5
|
import { OverflowTextField } from '../OverflowTextField';
|
|
5
|
-
import PropertyTreeItem from './PropertyTreeItem';
|
|
6
|
-
import {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const [
|
|
11
|
-
const [
|
|
12
|
-
const
|
|
13
|
-
const originalValue = value ?? '';
|
|
14
|
-
const flattenObjectProperties = (properties) => {
|
|
15
|
-
let result = [];
|
|
16
|
-
properties.forEach((property) => {
|
|
17
|
-
const { children, ...rest } = property;
|
|
18
|
-
const fullId = property.id;
|
|
19
|
-
const fullName = property.name;
|
|
20
|
-
const newProperty = {
|
|
21
|
-
...rest,
|
|
22
|
-
id: fullId,
|
|
23
|
-
name: fullName,
|
|
24
|
-
};
|
|
25
|
-
result.push(newProperty);
|
|
26
|
-
// If the property has children, recursively process them
|
|
27
|
-
if (children && children.length > 0) {
|
|
28
|
-
const childProperties = flattenObjectProperties(children);
|
|
29
|
-
result = result.concat(childProperties);
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
6
|
+
import { PropertyTreeItem } from './PropertyTreeItem';
|
|
7
|
+
import { findTreeItemById, truncateNamePath, updateTreeNode } from './utils';
|
|
8
|
+
const NAME_PATH_LIMIT = 35;
|
|
9
|
+
const PropertyTree = (props) => {
|
|
10
|
+
const { fetchObject, handleTreePropertySelect, rootObject, value, propertyTreeMap } = props;
|
|
11
|
+
const [options, setOptions] = useState([]);
|
|
12
|
+
const [expandedItems, setExpandedItems] = useState([]);
|
|
13
|
+
const [openDropdown, setOpenDropdown] = useState(false);
|
|
34
14
|
useEffect(() => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
...newObjectPropertyNamePathMap,
|
|
57
|
-
[originalValue]: fieldNamePath,
|
|
58
|
-
};
|
|
59
|
-
setObjectPropertyNamePathMap(newObjectPropertyNamePathMap);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
fetchDisplayNames();
|
|
66
|
-
}
|
|
67
|
-
}, [originalValue, rootObject, objectProperties, fetchObject]);
|
|
68
|
-
const handleNodeToggle = (event, nodeIds) => {
|
|
69
|
-
const newlyExpandedNodes = nodeIds.filter((id) => !expandedNodes.includes(id));
|
|
70
|
-
newlyExpandedNodes.forEach((nodeId) => {
|
|
71
|
-
handleLoadData(nodeId);
|
|
72
|
-
});
|
|
73
|
-
setExpandedNodes(nodeIds);
|
|
15
|
+
// Transform rootObject properties to TreeItem format
|
|
16
|
+
setOptions(rootObject.properties.map((property) => ({
|
|
17
|
+
id: property.id,
|
|
18
|
+
label: property.name,
|
|
19
|
+
value: property.id,
|
|
20
|
+
type: property.type,
|
|
21
|
+
objectId: property.objectId,
|
|
22
|
+
children: property.children
|
|
23
|
+
? property.children.map((child) => ({
|
|
24
|
+
id: child.id,
|
|
25
|
+
label: child.name,
|
|
26
|
+
value: child.id,
|
|
27
|
+
type: child.type,
|
|
28
|
+
objectId: child.objectId,
|
|
29
|
+
}))
|
|
30
|
+
: undefined,
|
|
31
|
+
})));
|
|
32
|
+
setExpandedItems([]);
|
|
33
|
+
}, [rootObject.properties]);
|
|
34
|
+
const handleExpandedItemsChange = (e, itemIds) => {
|
|
35
|
+
setExpandedItems(itemIds);
|
|
74
36
|
};
|
|
75
|
-
const
|
|
76
|
-
|
|
37
|
+
const handleUpdateNodeChildren = (nodeId, children) => {
|
|
38
|
+
setOptions((prevOptions) => updateTreeNode(prevOptions, nodeId, (node) => ({ ...node, children })));
|
|
77
39
|
};
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const handleTreeItemClick = (propertyId) => {
|
|
98
|
-
const property = findPropertyById(objectProperties, propertyId);
|
|
99
|
-
// this prevents the selection of a parent node
|
|
100
|
-
if (property && !property.children) {
|
|
101
|
-
handleTreePropertySelect(property.id);
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
return (React.createElement(Autocomplete, { "aria-label": "Property Selector", value: value, fullWidth: true, sx: {
|
|
105
|
-
width: '37%',
|
|
106
|
-
}, disableClearable: true, options: propertyOptions.map((property) => {
|
|
107
|
-
return {
|
|
108
|
-
label: objectPropertyNamePathMap[property.id],
|
|
109
|
-
value: property.id,
|
|
110
|
-
};
|
|
111
|
-
}), componentsProps: {
|
|
112
|
-
paper: {
|
|
113
|
-
sx: {
|
|
114
|
-
'& .MuiAutocomplete-option': {
|
|
115
|
-
'&.Mui-focused': {
|
|
116
|
-
'&:has(.MuiTreeItem-group)': {
|
|
117
|
-
backgroundColor: 'transparent', // Remove background color if child of TreeView
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
}, getOptionLabel: (option) => {
|
|
40
|
+
return (React.createElement(Autocomplete, { "aria-label": "Property Selector", value: value, fullWidth: true, sx: { width: '37%' }, size: "small", disableClearable: true, options: options, open: openDropdown, onBlur: (e) => {
|
|
41
|
+
const targetComponents = e.relatedTarget?.getAttribute('id')?.split('-');
|
|
42
|
+
// If the user clicks on tree component with children, don't close the menu.
|
|
43
|
+
if (targetComponents?.[targetComponents.length - 1]) {
|
|
44
|
+
const treeItem = findTreeItemById(options, targetComponents[targetComponents.length - 1]);
|
|
45
|
+
// Only keep dropdown open if the clicked item has children AND was not selected
|
|
46
|
+
if (!treeItem?.children) {
|
|
47
|
+
setOpenDropdown(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
setOpenDropdown(false);
|
|
52
|
+
}
|
|
53
|
+
}, onOpen: () => setOpenDropdown(true), onClose: (e, reason) => {
|
|
54
|
+
// Only close if user clicked the caret close icon
|
|
55
|
+
if (reason === 'toggleInput') {
|
|
56
|
+
setOpenDropdown(false);
|
|
57
|
+
}
|
|
58
|
+
}, onFocus: () => setOpenDropdown(true), getOptionLabel: (option) => {
|
|
124
59
|
// Retrieve the full name path from the map
|
|
125
60
|
const namePath = typeof option === 'string'
|
|
126
|
-
? (
|
|
127
|
-
: (
|
|
61
|
+
? ((propertyTreeMap[option] ?? '')?.name ?? option)
|
|
62
|
+
: (propertyTreeMap[option.value]?.name ?? option.value);
|
|
128
63
|
return truncateNamePath(namePath, NAME_PATH_LIMIT);
|
|
129
64
|
}, renderInput: (params) => {
|
|
130
|
-
const fullDisplayName = value &&
|
|
131
|
-
return (React.createElement(OverflowTextField, { ...params, "aria-label": fullDisplayName, value: fullDisplayName,
|
|
132
|
-
},
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// top level items
|
|
147
|
-
return (React.createElement(MenuItem, { sx: {
|
|
148
|
-
'&:hover': {
|
|
149
|
-
backgroundColor: 'transparent',
|
|
150
|
-
},
|
|
151
|
-
'.MuiTreeItem-group &': {
|
|
152
|
-
'&:hover': {
|
|
153
|
-
backgroundColor: 'rgba(0, 0, 0, 0.04)',
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
}, onClick: () => handleTreeItemClick(property?.id), key: property.id, value: property.id }, property.name));
|
|
157
|
-
}
|
|
65
|
+
const fullDisplayName = value && propertyTreeMap[value]?.name;
|
|
66
|
+
return (React.createElement(OverflowTextField, { ...params, "aria-label": fullDisplayName, value: fullDisplayName, placeholder: "Select a property" }));
|
|
67
|
+
}, ListboxComponent: (props) => {
|
|
68
|
+
return (React.createElement(ClickAwayListener, { onClickAway: (e) => {
|
|
69
|
+
// Close the dropdown when clicking outside but don't close when clicking to the autocomplete input
|
|
70
|
+
if (!e.target.closest('.MuiAutocomplete-root')) {
|
|
71
|
+
setOpenDropdown(false);
|
|
72
|
+
}
|
|
73
|
+
} },
|
|
74
|
+
React.createElement(Box, { ...props },
|
|
75
|
+
React.createElement(RichTreeView, { sx: { width: '100%', paddingLeft: 0 }, role: "menu", "aria-label": "Property Tree", expandedItems: expandedItems, onExpandedItemsChange: handleExpandedItemsChange, items: options, slots: {
|
|
76
|
+
item: (itemProps) => (React.createElement(PropertyTreeItem, { ...itemProps, items: options, expanded: expandedItems, setExpanded: setExpandedItems, fetchObject: fetchObject, updateNodeChildren: handleUpdateNodeChildren, handleTreePropertySelect: async (propertyId) => {
|
|
77
|
+
await handleTreePropertySelect(propertyId);
|
|
78
|
+
setOpenDropdown(false);
|
|
79
|
+
} })),
|
|
80
|
+
} }))));
|
|
158
81
|
} }));
|
|
159
82
|
};
|
|
160
83
|
export default PropertyTree;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { TreeItemProps } from '@mui/x-tree-view';
|
|
1
2
|
import React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { Obj } from '../../../types';
|
|
4
|
+
import { TreeItem } from './types';
|
|
5
|
+
type PropertyTreeItemProps = TreeItemProps & {
|
|
6
|
+
items: TreeItem[];
|
|
7
|
+
expanded: string[];
|
|
8
|
+
setExpanded: (expanded: string[]) => void;
|
|
9
|
+
updateNodeChildren: (nodeId: string, children: TreeItem[]) => void;
|
|
5
10
|
fetchObject: (id: string) => Promise<Obj | undefined>;
|
|
6
|
-
|
|
7
|
-
onClick: (property: string) => void;
|
|
11
|
+
handleTreePropertySelect: (propertyId: string) => Promise<void>;
|
|
8
12
|
};
|
|
9
|
-
declare const PropertyTreeItem: (
|
|
10
|
-
export
|
|
13
|
+
export declare const PropertyTreeItem: (props: PropertyTreeItemProps) => React.JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -1,43 +1,45 @@
|
|
|
1
|
-
import { TreeItem } from '@mui/lab';
|
|
2
|
-
import { MenuItem } from '@mui/material';
|
|
3
1
|
import React from 'react';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
2
|
+
import { RichTreeItem } from '../../core';
|
|
3
|
+
import { findTreeItemById } from './utils';
|
|
4
|
+
export const PropertyTreeItem = (props) => {
|
|
5
|
+
const { itemId, label, items, setExpanded, expanded, children, updateNodeChildren, fetchObject, handleTreePropertySelect, } = props;
|
|
6
|
+
const onClick = async (e) => {
|
|
7
|
+
// If the item is already expanded, collapse it.
|
|
8
|
+
if (expanded.includes(itemId)) {
|
|
9
|
+
e.stopPropagation();
|
|
10
|
+
setExpanded(expanded.filter((id) => id !== itemId));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const item = findTreeItemById(items, itemId);
|
|
14
|
+
if (item?.type === 'object') {
|
|
15
|
+
e.stopPropagation();
|
|
16
|
+
// If the item has an associated "objectId", fetch the properties of the object and expand the item.
|
|
17
|
+
if (item.objectId && item.children?.length === 1 && item.children[0].type === 'loading') {
|
|
18
|
+
const object = item.objectId ? await fetchObject(item.objectId) : undefined;
|
|
19
|
+
if (object) {
|
|
20
|
+
updateNodeChildren(itemId, (object.properties ?? []).map((prop) => ({
|
|
21
|
+
id: `${itemId}.${prop.id}`,
|
|
22
|
+
label: prop.name,
|
|
23
|
+
value: `${itemId}.${prop.id}`,
|
|
24
|
+
type: prop.type,
|
|
25
|
+
objectId: prop.objectId,
|
|
26
|
+
children: prop.type === 'object'
|
|
27
|
+
? [
|
|
28
|
+
{
|
|
29
|
+
id: `${itemId}.${prop.id}-loading`,
|
|
30
|
+
label: 'Loading...',
|
|
31
|
+
value: `${itemId}.${prop.id}-loading`,
|
|
32
|
+
type: 'loading',
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
: undefined,
|
|
36
|
+
})));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
setExpanded([...expanded, itemId]);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await handleTreePropertySelect(itemId);
|
|
43
|
+
};
|
|
44
|
+
return React.createElement(RichTreeItem, { itemId: itemId, label: label, children: children, onClick: onClick });
|
|
42
45
|
};
|
|
43
|
-
export default PropertyTreeItem;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference types="react" />
|
|
2
2
|
import { BaseSelectorProps } from 'react-querybuilder';
|
|
3
3
|
import { ExpandedProperty } from '../../../types';
|
|
4
|
-
import { AutocompleteOption } from '../../core';
|
|
4
|
+
import { AutocompleteOption, TreeViewBaseItem } from '../../core';
|
|
5
5
|
export type ObjectProperty = {
|
|
6
6
|
id: string;
|
|
7
7
|
name: string;
|
|
@@ -51,3 +51,10 @@ export type MongoDBQueryValue = null | string | boolean | {
|
|
|
51
51
|
$in?: unknown[];
|
|
52
52
|
$nin?: unknown[];
|
|
53
53
|
};
|
|
54
|
+
export type TreeItem = TreeViewBaseItem<{
|
|
55
|
+
id: string;
|
|
56
|
+
label: string;
|
|
57
|
+
value: string;
|
|
58
|
+
type?: string;
|
|
59
|
+
objectId?: string;
|
|
60
|
+
}>;
|
|
@@ -1,23 +1,16 @@
|
|
|
1
1
|
import { Property } from '@evoke-platform/context';
|
|
2
2
|
import { RuleGroupType } from 'react-querybuilder';
|
|
3
|
-
import {
|
|
3
|
+
import { Obj, ObjectProperty } from '../../../types';
|
|
4
|
+
import { TreeItem } from './types';
|
|
4
5
|
/**
|
|
5
6
|
* Recursively updates a node in a tree structure by applying an updater function to the node with the specified ID.
|
|
6
7
|
*
|
|
7
|
-
* @param {
|
|
8
|
+
* @param {TreeItem[]} tree - The tree structure to update.
|
|
8
9
|
* @param {string} nodeId - The ID of the node to update.
|
|
9
|
-
* @param {(node:
|
|
10
|
-
* @returns {
|
|
10
|
+
* @param {(node: TreeItem) => TreeItem} updater - The function to apply to the node.
|
|
11
|
+
* @returns {TreeItem[]} - The updated tree structure.
|
|
11
12
|
*/
|
|
12
|
-
export declare const updateTreeNode: (tree:
|
|
13
|
-
/**
|
|
14
|
-
* Recursively searches for a property in a tree structure by its ID.
|
|
15
|
-
*
|
|
16
|
-
* @param {ExpandedProperty[]} properties - The tree structure to search.
|
|
17
|
-
* @param {string} id - The ID of the property to find.
|
|
18
|
-
* @returns {ExpandedProperty | null} - The found property or null if not found.
|
|
19
|
-
*/
|
|
20
|
-
export declare const findPropertyById: (properties: ExpandedProperty[], id: string) => ExpandedProperty | null;
|
|
13
|
+
export declare const updateTreeNode: (tree: TreeItem[], nodeId: string, updater: (node: TreeItem) => TreeItem) => TreeItem[];
|
|
21
14
|
type FetchObjectFunction = (id: string) => Promise<Obj | undefined>;
|
|
22
15
|
/**
|
|
23
16
|
* Fetches the display name path for a given property ID within an object hierarchy.
|
|
@@ -74,4 +67,5 @@ export declare const ALL_OPERATORS: {
|
|
|
74
67
|
* @returns {string} The resulting query string.
|
|
75
68
|
*/
|
|
76
69
|
export declare const getReadableQuery: (mongoQuery?: Record<string, unknown>, properties?: Property[]) => string;
|
|
70
|
+
export declare const findTreeItemById: (nodes: TreeItem[], nodeId: string) => TreeItem | null;
|
|
77
71
|
export {};
|
|
@@ -2,10 +2,10 @@ import { isArray, isEmpty, startCase } from 'lodash';
|
|
|
2
2
|
/**
|
|
3
3
|
* Recursively updates a node in a tree structure by applying an updater function to the node with the specified ID.
|
|
4
4
|
*
|
|
5
|
-
* @param {
|
|
5
|
+
* @param {TreeItem[]} tree - The tree structure to update.
|
|
6
6
|
* @param {string} nodeId - The ID of the node to update.
|
|
7
|
-
* @param {(node:
|
|
8
|
-
* @returns {
|
|
7
|
+
* @param {(node: TreeItem) => TreeItem} updater - The function to apply to the node.
|
|
8
|
+
* @returns {TreeItem[]} - The updated tree structure.
|
|
9
9
|
*/
|
|
10
10
|
export const updateTreeNode = (tree, nodeId, updater) => {
|
|
11
11
|
return tree.map((node) => {
|
|
@@ -20,26 +20,6 @@ export const updateTreeNode = (tree, nodeId, updater) => {
|
|
|
20
20
|
}
|
|
21
21
|
});
|
|
22
22
|
};
|
|
23
|
-
/**
|
|
24
|
-
* Recursively searches for a property in a tree structure by its ID.
|
|
25
|
-
*
|
|
26
|
-
* @param {ExpandedProperty[]} properties - The tree structure to search.
|
|
27
|
-
* @param {string} id - The ID of the property to find.
|
|
28
|
-
* @returns {ExpandedProperty | null} - The found property or null if not found.
|
|
29
|
-
*/
|
|
30
|
-
export const findPropertyById = (properties, id) => {
|
|
31
|
-
for (const prop of properties) {
|
|
32
|
-
if (prop.id === id) {
|
|
33
|
-
return prop;
|
|
34
|
-
}
|
|
35
|
-
else if (prop.children) {
|
|
36
|
-
const result = findPropertyById(prop.children, id);
|
|
37
|
-
if (result)
|
|
38
|
-
return result;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return null;
|
|
42
|
-
};
|
|
43
23
|
/**
|
|
44
24
|
* Fetches the display name path for a given property ID within an object hierarchy.
|
|
45
25
|
*
|
|
@@ -359,3 +339,13 @@ export const getReadableQuery = (mongoQuery, properties) => {
|
|
|
359
339
|
const parsedQuery = parseMongoDB(mongoQuery);
|
|
360
340
|
return buildQueryString(parsedQuery);
|
|
361
341
|
};
|
|
342
|
+
export const findTreeItemById = (nodes, nodeId) => {
|
|
343
|
+
for (const node of nodes) {
|
|
344
|
+
if (node.id === nodeId)
|
|
345
|
+
return node;
|
|
346
|
+
const found = node.children && findTreeItemById(node.children, nodeId);
|
|
347
|
+
if (found)
|
|
348
|
+
return found;
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
};
|
|
@@ -2,12 +2,14 @@ import { createFilterOptions, List, ListSubheader } from '@mui/material';
|
|
|
2
2
|
import { uniq } from 'lodash';
|
|
3
3
|
import React, { forwardRef, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { Clear } from '../../../../icons';
|
|
5
|
+
import { useFormContext } from '../../../../theme/hooks';
|
|
5
6
|
import { Autocomplete, FormControl, FormControlLabel, IconButton, Radio, RadioGroup, TextField, Typography, } from '../../../core';
|
|
6
7
|
import { Box } from '../../../layout';
|
|
7
8
|
import InputFieldComponent from '../InputFieldComponent/InputFieldComponent';
|
|
8
9
|
const filter = createFilterOptions();
|
|
9
10
|
const Select = (props) => {
|
|
10
11
|
const { id, property, defaultValue, error, errorMessage, onBlur, onChange, readOnly, isCombobox, selectOptions, required, size, isOptionEqualToValue, renderOption, getOptionLabel, disableCloseOnSelect, additionalProps, displayOption, sortBy, } = props;
|
|
12
|
+
const { onAutosave } = useFormContext();
|
|
11
13
|
const otherInputRef = useRef(null);
|
|
12
14
|
const [isOther, setIsOther] = useState(!!isCombobox &&
|
|
13
15
|
!!defaultValue &&
|
|
@@ -32,7 +34,7 @@ const Select = (props) => {
|
|
|
32
34
|
setValue(defaultValue);
|
|
33
35
|
}, [defaultValue]);
|
|
34
36
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
-
const handleChange = (event, selected) => {
|
|
37
|
+
const handleChange = async (event, selected) => {
|
|
36
38
|
if (Array.isArray(selected)) {
|
|
37
39
|
const newValues = selected.map((option) => option.value ?? option);
|
|
38
40
|
setValue(uniq(newValues));
|
|
@@ -56,6 +58,12 @@ const Select = (props) => {
|
|
|
56
58
|
onChange && onChange(property.id, selected, property);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
61
|
+
try {
|
|
62
|
+
await onAutosave?.(id);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.error('Autosave failed:', error);
|
|
66
|
+
}
|
|
59
67
|
};
|
|
60
68
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
69
|
const handleInputValueChange = (event, selectValue) => {
|
|
@@ -121,22 +129,29 @@ const Select = (props) => {
|
|
|
121
129
|
}
|
|
122
130
|
setIsOtherFocused(false);
|
|
123
131
|
} })))))),
|
|
124
|
-
|
|
132
|
+
onChange && !readOnly && value && (React.createElement(Box, { sx: {
|
|
125
133
|
':hover': { cursor: 'pointer' },
|
|
126
134
|
marginTop: '4px',
|
|
127
135
|
display: 'flex',
|
|
128
136
|
alignItems: 'center',
|
|
129
137
|
} },
|
|
130
|
-
React.createElement(IconButton, { "aria-label": `Clear`, onClick: () => {
|
|
138
|
+
React.createElement(IconButton, { "aria-label": `Clear`, onClick: async () => {
|
|
131
139
|
setValue('');
|
|
132
140
|
property && onChange(property.id, '');
|
|
133
141
|
setIsOther(false);
|
|
142
|
+
// Trigger autosave immediately when clearing radio selection
|
|
143
|
+
try {
|
|
144
|
+
await onAutosave?.(id);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
console.error('Autosave failed:', error);
|
|
148
|
+
}
|
|
134
149
|
}, sx: { padding: '3px', marginRight: '6px' } },
|
|
135
150
|
React.createElement(Clear, { sx: {
|
|
136
151
|
color: '#637381',
|
|
137
152
|
fontSize: '1.2rem',
|
|
138
153
|
} })),
|
|
139
|
-
React.createElement(Typography, { variant: "caption" }, "Clear Selection"))))) : (React.createElement(Autocomplete, { multiple: property?.type === 'array'
|
|
154
|
+
React.createElement(Typography, { variant: "caption" }, "Clear Selection"))))) : (React.createElement(Autocomplete, { multiple: property?.type === 'array', id: id, sortBy: sortBy, renderInput: (params) => (React.createElement(TextField, { ...params, value: value, fullWidth: true, onBlur: onBlur, inputProps: {
|
|
140
155
|
...params.inputProps,
|
|
141
156
|
'aria-describedby': isCombobox ? `${id}-instructions` : undefined,
|
|
142
157
|
} })), value: value ?? (property?.type === 'array' ? [] : undefined), onChange: handleChange, options: selectOptions ?? property?.enum ?? [], inputValue: inputValue ?? '', error: error, errorMessage: errorMessage, required: required, onInputChange: handleInputValueChange, size: size, filterOptions: (options, params) => {
|
|
@@ -169,6 +184,10 @@ const Select = (props) => {
|
|
|
169
184
|
'& button.MuiButtonBase-root': {
|
|
170
185
|
visibility: 'visible',
|
|
171
186
|
},
|
|
187
|
+
}, slotProps: {
|
|
188
|
+
clearIndicator: {
|
|
189
|
+
'data-testid': 'autocomplete-clear-button',
|
|
190
|
+
},
|
|
172
191
|
}, forcePopupIcon: true, ...(isCombobox ? { selectOnFocus: true, handleHomeEndKeys: true, freeSolo: true } : {}), ...(additionalProps ?? {}) }));
|
|
173
192
|
};
|
|
174
193
|
export default Select;
|
|
@@ -326,12 +326,7 @@ describe('FormRendererContainer', () => {
|
|
|
326
326
|
await user.clear(cityField);
|
|
327
327
|
await user.type(cityField, 'Cambridge');
|
|
328
328
|
await user.tab(); // Blur the field
|
|
329
|
-
|
|
330
|
-
await waitFor(() => {
|
|
331
|
-
expect(autosaveActionSpy).toHaveBeenCalled();
|
|
332
|
-
});
|
|
333
|
-
const lastCall = autosaveActionSpy.mock.lastCall?.[0];
|
|
334
|
-
expect(lastCall).toEqual(expect.objectContaining({
|
|
329
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
335
330
|
input: expect.objectContaining({
|
|
336
331
|
address: expect.objectContaining({
|
|
337
332
|
city: 'Cambridge',
|
|
@@ -448,15 +443,7 @@ describe('FormRendererContainer', () => {
|
|
|
448
443
|
// Wait for and select the autocomplete option
|
|
449
444
|
const autocompleteOption = await screen.findByText('456 Oak Street');
|
|
450
445
|
await user.click(autocompleteOption);
|
|
451
|
-
|
|
452
|
-
await waitFor(() => {
|
|
453
|
-
expect(autosaveActionSpy).toHaveBeenCalled();
|
|
454
|
-
});
|
|
455
|
-
// The autosave is triggered twice when selecting the autocomplete option,
|
|
456
|
-
// once by the selection and once by the onBlur event. We want to verify the last call
|
|
457
|
-
// has the correct data.
|
|
458
|
-
const lastCall = autosaveActionSpy.mock.lastCall?.[0];
|
|
459
|
-
expect(lastCall).toEqual(expect.objectContaining({
|
|
446
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
460
447
|
input: expect.objectContaining({
|
|
461
448
|
address: expect.objectContaining({
|
|
462
449
|
line1: '456 Oak Street',
|
|
@@ -531,6 +518,484 @@ describe('FormRendererContainer', () => {
|
|
|
531
518
|
// Verify autosave was not triggered
|
|
532
519
|
expect(autosaveActionSpy).not.toHaveBeenCalled();
|
|
533
520
|
});
|
|
521
|
+
it('should trigger autosave immediately when a radio button is selected', async () => {
|
|
522
|
+
const user = userEvent.setup();
|
|
523
|
+
const autosaveActionSpy = vi.fn();
|
|
524
|
+
// Create a form with a status field displayed as radio buttons
|
|
525
|
+
const licenseFormWithRadioButtons = {
|
|
526
|
+
id: 'licenseForm',
|
|
527
|
+
name: 'License Form',
|
|
528
|
+
objectId: 'license',
|
|
529
|
+
actionId: '_update',
|
|
530
|
+
autosaveActionId: '_autosave',
|
|
531
|
+
entries: [
|
|
532
|
+
{
|
|
533
|
+
parameterId: 'name',
|
|
534
|
+
type: 'input',
|
|
535
|
+
display: {
|
|
536
|
+
label: 'License Number',
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
parameterId: 'status',
|
|
541
|
+
type: 'input',
|
|
542
|
+
display: {
|
|
543
|
+
label: 'Status',
|
|
544
|
+
choicesDisplay: {
|
|
545
|
+
type: 'radioButton',
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
};
|
|
551
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => {
|
|
552
|
+
return HttpResponse.json({
|
|
553
|
+
id: 'test-license',
|
|
554
|
+
name: 'RN-123456',
|
|
555
|
+
status: 'Active',
|
|
556
|
+
});
|
|
557
|
+
}), http.get('/api/data/objects/license/instances/test-license/object', () => {
|
|
558
|
+
return HttpResponse.json(licenseObject);
|
|
559
|
+
}), http.get('/api/data/forms/licenseForm', () => {
|
|
560
|
+
return HttpResponse.json(licenseFormWithRadioButtons);
|
|
561
|
+
}), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
562
|
+
const body = (await request.json());
|
|
563
|
+
autosaveActionSpy(body);
|
|
564
|
+
return HttpResponse.json({
|
|
565
|
+
id: 'test-license',
|
|
566
|
+
name: 'RN-123456',
|
|
567
|
+
status: body.input.status,
|
|
568
|
+
});
|
|
569
|
+
}));
|
|
570
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
571
|
+
// Wait for the form to load and the radio buttons to appear
|
|
572
|
+
const inactiveRadio = await screen.findByRole('radio', { name: 'Inactive' });
|
|
573
|
+
// Click the "Inactive" radio button
|
|
574
|
+
await user.click(inactiveRadio);
|
|
575
|
+
// Verify autosave was triggered immediately with the new status
|
|
576
|
+
await waitFor(() => {
|
|
577
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
578
|
+
actionId: '_autosave',
|
|
579
|
+
input: expect.objectContaining({
|
|
580
|
+
status: 'Inactive',
|
|
581
|
+
}),
|
|
582
|
+
}));
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
it('should trigger autosave when the selection is cleared', async () => {
|
|
586
|
+
const user = userEvent.setup();
|
|
587
|
+
const autosaveActionSpy = vi.fn();
|
|
588
|
+
const licenseFormWithRadioButtons = {
|
|
589
|
+
id: 'licenseForm',
|
|
590
|
+
name: 'License Form',
|
|
591
|
+
objectId: 'license',
|
|
592
|
+
actionId: '_update',
|
|
593
|
+
autosaveActionId: '_autosave',
|
|
594
|
+
entries: [
|
|
595
|
+
{
|
|
596
|
+
parameterId: 'name',
|
|
597
|
+
type: 'input',
|
|
598
|
+
display: {
|
|
599
|
+
label: 'License Number',
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
parameterId: 'status',
|
|
604
|
+
type: 'input',
|
|
605
|
+
display: {
|
|
606
|
+
label: 'Status',
|
|
607
|
+
choicesDisplay: {
|
|
608
|
+
type: 'radioButton',
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
};
|
|
614
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => {
|
|
615
|
+
return HttpResponse.json({
|
|
616
|
+
id: 'test-license',
|
|
617
|
+
name: 'RN-123456',
|
|
618
|
+
status: 'Active',
|
|
619
|
+
});
|
|
620
|
+
}), http.get('/api/data/objects/license/instances/test-license/object', () => {
|
|
621
|
+
return HttpResponse.json(licenseObject);
|
|
622
|
+
}), http.get('/api/data/forms/licenseForm', () => {
|
|
623
|
+
return HttpResponse.json(licenseFormWithRadioButtons);
|
|
624
|
+
}), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
625
|
+
const body = (await request.json());
|
|
626
|
+
autosaveActionSpy(body);
|
|
627
|
+
return HttpResponse.json({
|
|
628
|
+
id: 'test-license',
|
|
629
|
+
name: 'RN-123456',
|
|
630
|
+
status: body.input.status,
|
|
631
|
+
});
|
|
632
|
+
}));
|
|
633
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
634
|
+
const clearButton = await screen.findByRole('button', { name: 'Clear' });
|
|
635
|
+
await user.click(clearButton);
|
|
636
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
637
|
+
actionId: '_autosave',
|
|
638
|
+
input: expect.objectContaining({
|
|
639
|
+
status: null,
|
|
640
|
+
}),
|
|
641
|
+
}));
|
|
642
|
+
});
|
|
643
|
+
it('should trigger autosave immediately when a dropdown option is selected', async () => {
|
|
644
|
+
const user = userEvent.setup();
|
|
645
|
+
const autosaveActionSpy = vi.fn();
|
|
646
|
+
const licenseFormWithDropdown = {
|
|
647
|
+
id: 'licenseForm',
|
|
648
|
+
name: 'License Form',
|
|
649
|
+
objectId: 'license',
|
|
650
|
+
actionId: '_update',
|
|
651
|
+
autosaveActionId: '_autosave',
|
|
652
|
+
entries: [
|
|
653
|
+
{
|
|
654
|
+
parameterId: 'name',
|
|
655
|
+
type: 'input',
|
|
656
|
+
display: { label: 'License Number' },
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
parameterId: 'status',
|
|
660
|
+
type: 'input',
|
|
661
|
+
display: {
|
|
662
|
+
label: 'Status',
|
|
663
|
+
choicesDisplay: { type: 'dropdown' },
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
],
|
|
667
|
+
};
|
|
668
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({ id: 'test-license', name: 'RN-123456', status: 'Active' })), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithDropdown)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
669
|
+
const body = (await request.json());
|
|
670
|
+
autosaveActionSpy(body);
|
|
671
|
+
return HttpResponse.json({ id: 'test-license', name: 'RN-123456', status: body.input.status });
|
|
672
|
+
}));
|
|
673
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
674
|
+
// Open the dropdown/combobox and choose an option
|
|
675
|
+
const combobox = await screen.findByRole('combobox', { name: 'Status' });
|
|
676
|
+
await user.click(combobox);
|
|
677
|
+
const inactiveOption = await screen.findByRole('option', { name: 'Inactive' });
|
|
678
|
+
await user.click(inactiveOption);
|
|
679
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith({
|
|
680
|
+
actionId: '_autosave',
|
|
681
|
+
input: expect.objectContaining({
|
|
682
|
+
status: 'Inactive',
|
|
683
|
+
}),
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
it('should trigger autosave when dropdown selection is cleared', async () => {
|
|
687
|
+
const user = userEvent.setup();
|
|
688
|
+
const autosaveActionSpy = vi.fn();
|
|
689
|
+
const licenseFormWithDropdown = {
|
|
690
|
+
id: 'licenseForm',
|
|
691
|
+
name: 'License Form',
|
|
692
|
+
objectId: 'license',
|
|
693
|
+
actionId: '_update',
|
|
694
|
+
autosaveActionId: '_autosave',
|
|
695
|
+
entries: [
|
|
696
|
+
{
|
|
697
|
+
parameterId: 'name',
|
|
698
|
+
type: 'input',
|
|
699
|
+
display: { label: 'License Number' },
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
parameterId: 'status',
|
|
703
|
+
type: 'input',
|
|
704
|
+
display: {
|
|
705
|
+
label: 'Status',
|
|
706
|
+
choicesDisplay: { type: 'dropdown' },
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
};
|
|
711
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({ id: 'test-license', name: 'RN-123456', status: 'Active' })), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithDropdown)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
712
|
+
const body = (await request.json());
|
|
713
|
+
autosaveActionSpy(body);
|
|
714
|
+
return HttpResponse.json({ id: 'test-license', name: 'RN-123456', status: body.input.status });
|
|
715
|
+
}));
|
|
716
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
717
|
+
const combobox = await screen.findByRole('combobox', { name: 'Status' });
|
|
718
|
+
await user.click(combobox);
|
|
719
|
+
// Clear button has tabindex="-1" so find it via testid
|
|
720
|
+
const clearButton = await screen.findByTestId('autocomplete-clear-button');
|
|
721
|
+
await user.click(clearButton);
|
|
722
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
723
|
+
actionId: '_autosave',
|
|
724
|
+
input: expect.objectContaining({
|
|
725
|
+
status: null,
|
|
726
|
+
}),
|
|
727
|
+
}));
|
|
728
|
+
});
|
|
729
|
+
it('should trigger autosave for multi-select when selecting an option', async () => {
|
|
730
|
+
const user = userEvent.setup();
|
|
731
|
+
const autosaveActionSpy = vi.fn();
|
|
732
|
+
const licenseFormWithMultiSelect = {
|
|
733
|
+
id: 'licenseForm',
|
|
734
|
+
name: 'License Form',
|
|
735
|
+
objectId: 'license',
|
|
736
|
+
actionId: '_update',
|
|
737
|
+
autosaveActionId: '_autosave',
|
|
738
|
+
entries: [
|
|
739
|
+
{
|
|
740
|
+
parameterId: 'name',
|
|
741
|
+
type: 'input',
|
|
742
|
+
display: { label: 'License Number' },
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
type: 'inputField',
|
|
746
|
+
input: {
|
|
747
|
+
id: 'categories',
|
|
748
|
+
type: 'array',
|
|
749
|
+
enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
|
|
750
|
+
},
|
|
751
|
+
display: {
|
|
752
|
+
label: 'Categories',
|
|
753
|
+
choicesDisplay: { type: 'dropdown' },
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
};
|
|
758
|
+
const licenseInstanceInitial = { id: 'test-license', name: 'RN-123456' };
|
|
759
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json(licenseInstanceInitial)), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
760
|
+
const body = (await request.json());
|
|
761
|
+
autosaveActionSpy(body);
|
|
762
|
+
return HttpResponse.json({
|
|
763
|
+
id: 'test-license',
|
|
764
|
+
name: body.input.name ?? 'RN-123456',
|
|
765
|
+
categories: body.input.categories,
|
|
766
|
+
});
|
|
767
|
+
}));
|
|
768
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
769
|
+
// Open the multi-select combobox and choose an additional option
|
|
770
|
+
const combobox = await screen.findByRole('combobox', { name: 'Categories' });
|
|
771
|
+
await user.click(combobox);
|
|
772
|
+
const plumbingOption = await screen.findByRole('option', { name: 'Electrical' });
|
|
773
|
+
await user.click(plumbingOption);
|
|
774
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith({
|
|
775
|
+
actionId: '_autosave',
|
|
776
|
+
input: expect.objectContaining({ categories: ['Electrical'] }),
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
it('should trigger autosave for multi-select when adding an option', async () => {
|
|
780
|
+
const user = userEvent.setup();
|
|
781
|
+
const autosaveActionSpy = vi.fn();
|
|
782
|
+
const licenseFormWithMultiSelect = {
|
|
783
|
+
id: 'licenseForm',
|
|
784
|
+
name: 'License Form',
|
|
785
|
+
objectId: 'license',
|
|
786
|
+
actionId: '_update',
|
|
787
|
+
autosaveActionId: '_autosave',
|
|
788
|
+
entries: [
|
|
789
|
+
{
|
|
790
|
+
parameterId: 'name',
|
|
791
|
+
type: 'input',
|
|
792
|
+
display: { label: 'License Number' },
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
type: 'inputField',
|
|
796
|
+
input: {
|
|
797
|
+
id: 'categories',
|
|
798
|
+
type: 'array',
|
|
799
|
+
enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
|
|
800
|
+
},
|
|
801
|
+
display: {
|
|
802
|
+
label: 'Categories',
|
|
803
|
+
choicesDisplay: { type: 'dropdown' },
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
],
|
|
807
|
+
};
|
|
808
|
+
const licenseInstanceInitial = { id: 'test-license', name: 'RN-123456', categories: ['Electrical'] };
|
|
809
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json(licenseInstanceInitial)), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
810
|
+
const body = (await request.json());
|
|
811
|
+
autosaveActionSpy(body);
|
|
812
|
+
return HttpResponse.json({
|
|
813
|
+
id: 'test-license',
|
|
814
|
+
name: body.input.name ?? 'RN-123456',
|
|
815
|
+
categories: body.input.categories,
|
|
816
|
+
});
|
|
817
|
+
}));
|
|
818
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
819
|
+
// Open the multi-select combobox and choose an additional option
|
|
820
|
+
const combobox = await screen.findByRole('combobox', { name: 'Categories' });
|
|
821
|
+
await user.click(combobox);
|
|
822
|
+
const plumbingOption = await screen.findByRole('option', { name: 'Plumbing' });
|
|
823
|
+
await user.click(plumbingOption);
|
|
824
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith({
|
|
825
|
+
actionId: '_autosave',
|
|
826
|
+
input: expect.objectContaining({
|
|
827
|
+
categories: expect.arrayContaining(['Electrical', 'Plumbing']),
|
|
828
|
+
}),
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
it('should trigger autosave when a multi-select option is removed', async () => {
|
|
832
|
+
const user = userEvent.setup();
|
|
833
|
+
const autosaveActionSpy = vi.fn();
|
|
834
|
+
const licenseFormWithMultiSelect = {
|
|
835
|
+
id: 'licenseForm',
|
|
836
|
+
name: 'License Form',
|
|
837
|
+
objectId: 'license',
|
|
838
|
+
actionId: '_update',
|
|
839
|
+
autosaveActionId: '_autosave',
|
|
840
|
+
entries: [
|
|
841
|
+
{
|
|
842
|
+
parameterId: 'name',
|
|
843
|
+
type: 'input',
|
|
844
|
+
display: { label: 'License Number' },
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
type: 'inputField',
|
|
848
|
+
input: {
|
|
849
|
+
id: 'categories',
|
|
850
|
+
type: 'array',
|
|
851
|
+
enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
|
|
852
|
+
},
|
|
853
|
+
display: {
|
|
854
|
+
label: 'Categories',
|
|
855
|
+
choicesDisplay: { type: 'dropdown' },
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
],
|
|
859
|
+
};
|
|
860
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({
|
|
861
|
+
id: 'test-license',
|
|
862
|
+
name: 'RN-123456',
|
|
863
|
+
categories: ['Electrical', 'Plumbing'],
|
|
864
|
+
})), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
865
|
+
const body = (await request.json());
|
|
866
|
+
autosaveActionSpy(body);
|
|
867
|
+
return HttpResponse.json({
|
|
868
|
+
id: 'test-license',
|
|
869
|
+
name: 'RN-123456',
|
|
870
|
+
categories: body.input.categories,
|
|
871
|
+
});
|
|
872
|
+
}));
|
|
873
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
874
|
+
const electricalChipLabel = await screen.findByRole('button', { name: 'Electrical' });
|
|
875
|
+
// Mui autoamtically provides a test id.
|
|
876
|
+
const deleteIcon = await within(electricalChipLabel).findByTestId('CancelIcon');
|
|
877
|
+
await user.click(deleteIcon);
|
|
878
|
+
// Autosave should be called with only Plumbing remaining
|
|
879
|
+
await waitFor(() => {
|
|
880
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith({
|
|
881
|
+
actionId: '_autosave',
|
|
882
|
+
input: expect.objectContaining({
|
|
883
|
+
categories: ['Plumbing'],
|
|
884
|
+
}),
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
it('should trigger autosave when a single multi-select option is removed via the dropdown', async () => {
|
|
889
|
+
const user = userEvent.setup();
|
|
890
|
+
const autosaveActionSpy = vi.fn();
|
|
891
|
+
const licenseFormWithMultiSelect = {
|
|
892
|
+
id: 'licenseForm',
|
|
893
|
+
name: 'License Form',
|
|
894
|
+
objectId: 'license',
|
|
895
|
+
actionId: '_update',
|
|
896
|
+
autosaveActionId: '_autosave',
|
|
897
|
+
entries: [
|
|
898
|
+
{
|
|
899
|
+
parameterId: 'name',
|
|
900
|
+
type: 'input',
|
|
901
|
+
display: { label: 'License Number' },
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
type: 'inputField',
|
|
905
|
+
input: {
|
|
906
|
+
id: 'categories',
|
|
907
|
+
type: 'array',
|
|
908
|
+
enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
|
|
909
|
+
},
|
|
910
|
+
display: {
|
|
911
|
+
label: 'Categories',
|
|
912
|
+
choicesDisplay: { type: 'dropdown' },
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
],
|
|
916
|
+
};
|
|
917
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({
|
|
918
|
+
id: 'test-license',
|
|
919
|
+
name: 'RN-123456',
|
|
920
|
+
categories: ['Electrical', 'Plumbing'],
|
|
921
|
+
})), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
922
|
+
const body = (await request.json());
|
|
923
|
+
autosaveActionSpy(body);
|
|
924
|
+
return HttpResponse.json({
|
|
925
|
+
id: 'test-license',
|
|
926
|
+
name: 'RN-123456',
|
|
927
|
+
categories: body.input.categories,
|
|
928
|
+
});
|
|
929
|
+
}));
|
|
930
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
931
|
+
// Open the multi-select combobox and unselect the 'Plumbing' option
|
|
932
|
+
const combobox = await screen.findByRole('combobox', { name: 'Categories' });
|
|
933
|
+
await user.click(combobox);
|
|
934
|
+
// Click the option to toggle/unselect it (initially selected)
|
|
935
|
+
const plumbingOption = await screen.findByRole('option', { name: 'Plumbing' });
|
|
936
|
+
await user.click(plumbingOption);
|
|
937
|
+
// Expect autosave called with categories array no longer containing 'Plumbing'
|
|
938
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith({
|
|
939
|
+
actionId: '_autosave',
|
|
940
|
+
input: expect.objectContaining({ categories: ['Electrical'] }),
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
it('should trigger autosave when multi-select is cleared', async () => {
|
|
944
|
+
const user = userEvent.setup();
|
|
945
|
+
const autosaveActionSpy = vi.fn();
|
|
946
|
+
const licenseFormWithMultiSelect = {
|
|
947
|
+
id: 'licenseForm',
|
|
948
|
+
name: 'License Form',
|
|
949
|
+
objectId: 'license',
|
|
950
|
+
actionId: '_update',
|
|
951
|
+
autosaveActionId: '_autosave',
|
|
952
|
+
entries: [
|
|
953
|
+
{
|
|
954
|
+
parameterId: 'name',
|
|
955
|
+
type: 'input',
|
|
956
|
+
display: { label: 'License Number' },
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
type: 'inputField',
|
|
960
|
+
input: {
|
|
961
|
+
id: 'categories',
|
|
962
|
+
type: 'array',
|
|
963
|
+
enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
|
|
964
|
+
},
|
|
965
|
+
display: {
|
|
966
|
+
label: 'Categories',
|
|
967
|
+
choicesDisplay: { type: 'dropdown' },
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
],
|
|
971
|
+
};
|
|
972
|
+
server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({
|
|
973
|
+
id: 'test-license',
|
|
974
|
+
name: 'RN-123456',
|
|
975
|
+
categories: ['Electrical', 'Plumbing'],
|
|
976
|
+
})), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
|
|
977
|
+
const body = (await request.json());
|
|
978
|
+
autosaveActionSpy(body);
|
|
979
|
+
return HttpResponse.json({
|
|
980
|
+
id: 'test-license',
|
|
981
|
+
name: 'RN-123456',
|
|
982
|
+
categories: body.input.categories,
|
|
983
|
+
});
|
|
984
|
+
}));
|
|
985
|
+
render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
|
|
986
|
+
// Open the multi-select combobox and click the clear button
|
|
987
|
+
const combobox = await screen.findByRole('combobox', { name: 'Categories' });
|
|
988
|
+
await user.click(combobox);
|
|
989
|
+
// Clear button has tabindex="-1" so find via testid
|
|
990
|
+
const clearButton = await screen.findByTestId('autocomplete-clear-button');
|
|
991
|
+
await user.click(clearButton);
|
|
992
|
+
expect(autosaveActionSpy).toHaveBeenCalledWith({
|
|
993
|
+
actionId: '_autosave',
|
|
994
|
+
input: expect.objectContaining({
|
|
995
|
+
categories: expect.arrayContaining([]),
|
|
996
|
+
}),
|
|
997
|
+
});
|
|
998
|
+
});
|
|
534
999
|
});
|
|
535
1000
|
it('should display a submit button', async () => {
|
|
536
1001
|
const form = {
|
|
@@ -160,6 +160,12 @@ export const licenseObject = {
|
|
|
160
160
|
type: 'string',
|
|
161
161
|
enum: ['Active', 'Inactive'],
|
|
162
162
|
},
|
|
163
|
+
{
|
|
164
|
+
id: 'categories',
|
|
165
|
+
name: 'Categories',
|
|
166
|
+
type: 'array',
|
|
167
|
+
enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
|
|
168
|
+
},
|
|
163
169
|
{
|
|
164
170
|
id: 'licenseType',
|
|
165
171
|
name: 'License Type',
|
|
@@ -188,6 +194,13 @@ export const licenseObject = {
|
|
|
188
194
|
id: 'status',
|
|
189
195
|
name: 'Status',
|
|
190
196
|
type: 'string',
|
|
197
|
+
enum: ['Active', 'Inactive'],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'categories',
|
|
201
|
+
name: 'Categories',
|
|
202
|
+
type: 'array',
|
|
203
|
+
enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
|
|
191
204
|
},
|
|
192
205
|
{
|
|
193
206
|
id: 'address.line1',
|
|
@@ -236,6 +249,13 @@ export const licenseObject = {
|
|
|
236
249
|
id: 'status',
|
|
237
250
|
name: 'Status',
|
|
238
251
|
type: 'string',
|
|
252
|
+
enum: ['Active', 'Inactive'],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'categories',
|
|
256
|
+
name: 'Categories',
|
|
257
|
+
type: 'array',
|
|
258
|
+
enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
|
|
239
259
|
},
|
|
240
260
|
{
|
|
241
261
|
id: 'address.line1',
|
|
@@ -255,6 +255,21 @@ CriteriaBuilderRelatedObject.args = {
|
|
|
255
255
|
},
|
|
256
256
|
],
|
|
257
257
|
},
|
|
258
|
+
{
|
|
259
|
+
id: 'dynamic.id',
|
|
260
|
+
name: 'Dynamic ID',
|
|
261
|
+
type: 'string',
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'dynamic.name',
|
|
265
|
+
name: 'Dynamic Name',
|
|
266
|
+
type: 'string',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: 'dynamic.objectId',
|
|
270
|
+
name: 'Dynamic Object ID',
|
|
271
|
+
type: 'string',
|
|
272
|
+
},
|
|
258
273
|
{
|
|
259
274
|
id: 'name',
|
|
260
275
|
name: 'Name',
|
|
@@ -526,6 +541,21 @@ CriteriaBuilderRelatedObject.args = {
|
|
|
526
541
|
type: 'string',
|
|
527
542
|
required: false,
|
|
528
543
|
},
|
|
544
|
+
{
|
|
545
|
+
id: 'dynamic.id',
|
|
546
|
+
name: 'Dynamic ID',
|
|
547
|
+
type: 'string',
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
id: 'dynamic.name',
|
|
551
|
+
name: 'Dynamic Name',
|
|
552
|
+
type: 'string',
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
id: 'dynamic.objectId',
|
|
556
|
+
name: 'Dynamic Object Id',
|
|
557
|
+
type: 'string',
|
|
558
|
+
},
|
|
529
559
|
],
|
|
530
560
|
},
|
|
531
561
|
},
|