@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.
@@ -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: ({ fetchObject, handleTreePropertySelect, rootObject, value }: PropertyTreeProps) => React.JSX.Element;
11
+ declare const PropertyTree: (props: PropertyTreeProps) => React.JSX.Element;
10
12
  export default PropertyTree;
@@ -1,160 +1,83 @@
1
- import { ChevronRight, ExpandMore } from '@mui/icons-material';
1
+ import { ClickAwayListener } from '@mui/material';
2
2
  import React, { useEffect, useState } from 'react';
3
- import { Autocomplete, MenuItem, TreeView } from '../../core';
3
+ import { Autocomplete, RichTreeView } from '../../core';
4
+ import { Box } from '../../layout';
4
5
  import { OverflowTextField } from '../OverflowTextField';
5
- import PropertyTreeItem from './PropertyTreeItem';
6
- import { fetchDisplayNamePath, findPropertyById, setIdPaths, truncateNamePath, updateTreeNode } from './utils';
7
- const PropertyTree = ({ fetchObject, handleTreePropertySelect, rootObject, value }) => {
8
- const [expandedNodes, setExpandedNodes] = useState([]);
9
- const [objectPropertyNamePathMap, setObjectPropertyNamePathMap] = useState({});
10
- const [objectProperties, setObjectProperties] = useState(rootObject.properties ?? []);
11
- const [propertyOptions, setPropertyOptions] = useState([]);
12
- const NAME_PATH_LIMIT = 35;
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
- const flattendProperties = flattenObjectProperties(objectProperties);
36
- setPropertyOptions(flattendProperties);
37
- if (objectProperties) {
38
- const fetchDisplayNames = async () => {
39
- const results = await Promise.all(flattendProperties.map(async (property) => {
40
- const objectPropertyNamePath = await fetchDisplayNamePath(property.id, rootObject, fetchObject);
41
- return { [property.id]: objectPropertyNamePath };
42
- }));
43
- let newObjectPropertyNamePathMap = {};
44
- results.forEach((result) => {
45
- newObjectPropertyNamePathMap = { ...newObjectPropertyNamePathMap, ...result };
46
- });
47
- setObjectPropertyNamePathMap(newObjectPropertyNamePathMap);
48
- //Fetch the display name path for the original value
49
- if (originalValue) {
50
- if (originalValue.includes('.')) {
51
- const parts = originalValue.split('.');
52
- if (parts.length >= 2) {
53
- if (!newObjectPropertyNamePathMap[originalValue]) {
54
- const fieldNamePath = await fetchDisplayNamePath(originalValue, rootObject, fetchObject);
55
- newObjectPropertyNamePathMap = {
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 updateObjectProperties = (nodeId, newChildren) => {
76
- setObjectProperties((prevProperties) => updateTreeNode(prevProperties, nodeId, (node) => ({ ...node, children: newChildren })));
37
+ const handleUpdateNodeChildren = (nodeId, children) => {
38
+ setOptions((prevOptions) => updateTreeNode(prevOptions, nodeId, (node) => ({ ...node, children })));
77
39
  };
78
- const handleLoadData = async (nodeId) => {
79
- const availableProperty = findPropertyById(objectProperties, nodeId);
80
- if (!availableProperty || !availableProperty.objectId)
81
- return;
82
- const object = await fetchObject(availableProperty.objectId);
83
- if (object?.properties) {
84
- const properties = object.properties.filter((prop) => !['collection', 'image', 'documents'].includes(prop.type));
85
- // this step adds path specific ids to the user and address fields
86
- const newChildren = setIdPaths(properties, nodeId);
87
- updateObjectProperties(availableProperty.id, newChildren);
88
- }
89
- };
90
- const handleNodeSelect = (event, nodeId) => {
91
- const node = findPropertyById(objectProperties, nodeId);
92
- if (node?.children) {
93
- event.preventDefault();
94
- event.stopPropagation();
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
- ? (objectPropertyNamePathMap[option] ?? '')
127
- : (objectPropertyNamePathMap[option.value] ?? '');
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 && objectPropertyNamePathMap[value];
131
- return (React.createElement(OverflowTextField, { ...params, "aria-label": fullDisplayName, value: fullDisplayName, size: "small", placeholder: "Select a property", variant: "outlined" }));
132
- }, isOptionEqualToValue: (option, val) => {
133
- if (typeof val === 'string') {
134
- return option.value === val;
135
- }
136
- return option.value === val?.value;
137
- }, renderOption: (props, option) => {
138
- // Find the corresponding property in objectProperties
139
- const property = objectProperties.find((prop) => prop.id === option.value);
140
- if (property) {
141
- if (property.type === 'object') {
142
- return (React.createElement("li", { ...props },
143
- React.createElement(TreeView, { role: "menu", "aria-label": "Property Tree", defaultCollapseIcon: React.createElement(ExpandMore, null), defaultExpandIcon: React.createElement(ChevronRight, null), onNodeToggle: handleNodeToggle, expanded: expandedNodes, onNodeSelect: handleNodeSelect, sx: { width: '100%' } },
144
- React.createElement(PropertyTreeItem, { key: option.value, treeData: objectProperties, property: property, fetchObject: fetchObject, onClick: handleTreeItemClick }))));
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 { ExpandedProperty, Obj } from '../../../types';
3
- type PropertyTreeItemProps = {
4
- property: ExpandedProperty | undefined;
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
- treeData: ExpandedProperty[];
7
- onClick: (property: string) => void;
11
+ handleTreePropertySelect: (propertyId: string) => Promise<void>;
8
12
  };
9
- declare const PropertyTreeItem: ({ property, fetchObject, treeData, onClick }: PropertyTreeItemProps) => React.JSX.Element | null;
10
- export default PropertyTreeItem;
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
- const PropertyTreeItem = ({ property, fetchObject, treeData, onClick }) => {
5
- if (!property) {
6
- return null;
7
- }
8
- const hasChildren = property.children && property.children.length > 0;
9
- if (hasChildren) {
10
- // Parent node
11
- return (React.createElement(TreeItem, { nodeId: property.id, role: "menu", label: property.name, onClick: () => onClick(property.id), sx: {
12
- paddingTop: '6px',
13
- paddingBottom: '6px',
14
- '.MuiCollapse-root': {
15
- 'MuiTreeItem-group': {
16
- width: '100%',
17
- },
18
- },
19
- '& .MuiTreeItem-content': {
20
- '&:hover': {
21
- backgroundColor: 'transparent',
22
- },
23
- },
24
- '& .MuiTreeItem-group .MuiTreeItem-content:hover': {
25
- backgroundColor: 'rgba(0, 0, 0, 0.04)',
26
- },
27
- } }, property.children?.map((child) => (React.createElement(PropertyTreeItem, { onClick: onClick, treeData: treeData, key: child.id, property: child, fetchObject: fetchObject })))));
28
- }
29
- else {
30
- // Leaf node, render as MenuItem
31
- return (React.createElement(MenuItem, { sx: {
32
- '&:hover': {
33
- backgroundColor: 'transparent',
34
- },
35
- '.MuiTreeItem-group &': {
36
- '&:hover': {
37
- backgroundColor: 'rgba(0, 0, 0, 0.04)',
38
- },
39
- },
40
- }, onClick: () => onClick(property.id), key: property.id, value: property.id }, property.name));
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 { ExpandedProperty, Obj, ObjectProperty } from '../../../types';
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 {ExpandedProperty[]} tree - The tree structure to update.
8
+ * @param {TreeItem[]} tree - The tree structure to update.
8
9
  * @param {string} nodeId - The ID of the node to update.
9
- * @param {(node: ExpandedProperty) => ExpandedProperty} updater - The function to apply to the node.
10
- * @returns {ExpandedProperty[]} - The updated tree structure.
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: ExpandedProperty[], nodeId: string, updater: (node: ExpandedProperty) => ExpandedProperty) => ExpandedProperty[];
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 {ExpandedProperty[]} tree - The tree structure to update.
5
+ * @param {TreeItem[]} tree - The tree structure to update.
6
6
  * @param {string} nodeId - The ID of the node to update.
7
- * @param {(node: ExpandedProperty) => ExpandedProperty} updater - The function to apply to the node.
8
- * @returns {ExpandedProperty[]} - The updated tree structure.
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
- displayOption === 'radioButton' && onChange && !readOnly && value && (React.createElement(Box, { sx: {
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' ? true : false, id: id, sortBy: sortBy, renderInput: (params) => (React.createElement(TextField, { ...params, value: value, fullWidth: true, onBlur: onBlur, inputProps: {
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
- // Verify autosave was eventually called and the final call contains the updated city
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
- // Verify autosave was eventually called and the final call contains the expected address values
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoke-platform/ui-components",
3
- "version": "1.10.1-dev.0",
3
+ "version": "1.10.1-dev.2",
4
4
  "description": "",
5
5
  "main": "dist/published/index.js",
6
6
  "module": "dist/published/index.js",