@mohsinonxrm/pptb-fetchxml-studio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +250 -0
  3. package/eslint.config.js +23 -0
  4. package/index.html +71 -0
  5. package/package.json +53 -0
  6. package/public/vite.svg +1 -0
  7. package/src/app/AppShell.tsx +1733 -0
  8. package/src/assets/20px-Microsoft_Office_Excel_(2025/342/200/223present).svg.png +0 -0
  9. package/src/assets/react.svg +1 -0
  10. package/src/features/fetchxml/api/dataverseMetadata.ts +192 -0
  11. package/src/features/fetchxml/api/excelExport.ts +318 -0
  12. package/src/features/fetchxml/api/formattedValues.ts +107 -0
  13. package/src/features/fetchxml/api/pptbClient.ts +2635 -0
  14. package/src/features/fetchxml/model/displaySettings.ts +18 -0
  15. package/src/features/fetchxml/model/fetchxml.ts +346 -0
  16. package/src/features/fetchxml/model/fetchxmlIntellisense.ts +583 -0
  17. package/src/features/fetchxml/model/fetchxmlParser.ts +708 -0
  18. package/src/features/fetchxml/model/layoutxml.ts +372 -0
  19. package/src/features/fetchxml/model/nodes.ts +255 -0
  20. package/src/features/fetchxml/model/operators.ts +624 -0
  21. package/src/features/fetchxml/model/treeUtils.ts +174 -0
  22. package/src/features/fetchxml/state/builderStore.tsx +1160 -0
  23. package/src/features/fetchxml/state/cache.ts +393 -0
  24. package/src/features/fetchxml/ui/Dialogs/BulkDeleteDialog.tsx +352 -0
  25. package/src/features/fetchxml/ui/Dialogs/DeleteConfirmDialog.tsx +396 -0
  26. package/src/features/fetchxml/ui/Dialogs/SaveViewDialog.tsx +612 -0
  27. package/src/features/fetchxml/ui/Dialogs/SolutionPicker.tsx +114 -0
  28. package/src/features/fetchxml/ui/Dialogs/WorkflowPickerDialog.tsx +437 -0
  29. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/AttributeEditor.tsx +214 -0
  30. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ConditionEditor.tsx +533 -0
  31. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FetchOptionsEditor.tsx +296 -0
  32. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FilterEditor.tsx +106 -0
  33. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/LinkEntityEditor.tsx +433 -0
  34. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/OrderEditor.tsx +161 -0
  35. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ValueOfPicker.tsx +194 -0
  36. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel.tsx +225 -0
  37. package/src/features/fetchxml/ui/LeftPane/TreeView.tsx +604 -0
  38. package/src/features/fetchxml/ui/RightPane/AddColumnsPanel.tsx +800 -0
  39. package/src/features/fetchxml/ui/RightPane/ColumnConfigDialog.tsx +170 -0
  40. package/src/features/fetchxml/ui/RightPane/DataGridCellRenderers.tsx +356 -0
  41. package/src/features/fetchxml/ui/RightPane/EditColumnsPanel.tsx +372 -0
  42. package/src/features/fetchxml/ui/RightPane/FetchXmlEditor.tsx +399 -0
  43. package/src/features/fetchxml/ui/RightPane/FormattedValueUtils.ts +189 -0
  44. package/src/features/fetchxml/ui/RightPane/LayoutXmlViewer.tsx +171 -0
  45. package/src/features/fetchxml/ui/RightPane/PreviewTabs.tsx +471 -0
  46. package/src/features/fetchxml/ui/RightPane/ResultsCommandBar.tsx +430 -0
  47. package/src/features/fetchxml/ui/RightPane/ResultsGrid.tsx +983 -0
  48. package/src/features/fetchxml/ui/RightPane/XmlTextArea.tsx +65 -0
  49. package/src/features/fetchxml/ui/Settings/SettingsDrawer.tsx +175 -0
  50. package/src/features/fetchxml/ui/Toolbar/EntitySelector.tsx +788 -0
  51. package/src/features/fetchxml/ui/Toolbar/LoadViewPicker.tsx +274 -0
  52. package/src/features/fetchxml/ui/Toolbar/SaveViewButton.tsx +174 -0
  53. package/src/main.tsx +11 -0
  54. package/src/shared/components/AttributePicker.tsx +138 -0
  55. package/src/shared/components/BetweenInputPicker.tsx +120 -0
  56. package/src/shared/components/BooleanValuePicker.tsx +165 -0
  57. package/src/shared/components/DateTimeInputPicker.tsx +170 -0
  58. package/src/shared/components/DynamicValueList.tsx +128 -0
  59. package/src/shared/components/ExcelIcon.tsx +30 -0
  60. package/src/shared/components/MultiValuePicker.tsx +178 -0
  61. package/src/shared/components/NumericInputPicker.tsx +156 -0
  62. package/src/shared/components/OptionSetValuePicker.tsx +150 -0
  63. package/src/shared/components/RelationshipPicker.tsx +268 -0
  64. package/src/shared/contexts/ThemeContext.tsx +19 -0
  65. package/src/shared/hooks/useAccessMode.ts +77 -0
  66. package/src/shared/hooks/useLazyMetadata.ts +132 -0
  67. package/src/shared/hooks/usePptbContext.ts +170 -0
  68. package/src/shared/hooks/usePublisherFilter.ts +214 -0
  69. package/src/shared/hooks/useSolutionFilter.ts +162 -0
  70. package/src/shared/utils/debug.ts +87 -0
  71. package/tsconfig.app.json +28 -0
  72. package/tsconfig.json +7 -0
  73. package/tsconfig.node.json +26 -0
  74. package/vite.config.ts +7 -0
@@ -0,0 +1,274 @@
1
+ /**
2
+ * LoadViewPicker - Grouped combobox for selecting System or Personal Views
3
+ * Only visible when root entity is selected in tree
4
+ * Fetches views for the selected entity and allows loading view's FetchXML into the tree
5
+ */
6
+
7
+ import { useState, useEffect, useMemo, useCallback, type ChangeEvent } from "react";
8
+ import {
9
+ Combobox,
10
+ Option,
11
+ OptionGroup,
12
+ makeStyles,
13
+ Spinner,
14
+ tokens,
15
+ type ComboboxProps,
16
+ } from "@fluentui/react-components";
17
+ import {
18
+ getAllViews,
19
+ type SavedView,
20
+ type EntityMetadata,
21
+ type LoadedViewInfo,
22
+ } from "../../api/pptbClient";
23
+ import { debugLog } from "../../../../shared/utils/debug";
24
+
25
+ const useStyles = makeStyles({
26
+ container: {
27
+ display: "flex",
28
+ flexDirection: "column",
29
+ gap: "4px",
30
+ flex: 1,
31
+ minWidth: "200px",
32
+ maxWidth: "350px",
33
+ },
34
+ label: {
35
+ fontSize: "14px",
36
+ fontWeight: 600,
37
+ color: tokens.colorNeutralForeground2,
38
+ },
39
+ combobox: {
40
+ width: "100%",
41
+ },
42
+ loadingOption: {
43
+ display: "flex",
44
+ alignItems: "center",
45
+ gap: "8px",
46
+ padding: "8px",
47
+ },
48
+ noViewsOption: {
49
+ fontStyle: "italic",
50
+ color: tokens.colorNeutralForeground3,
51
+ },
52
+ defaultBadge: {
53
+ fontSize: "10px",
54
+ backgroundColor: tokens.colorBrandBackground,
55
+ color: tokens.colorNeutralForegroundOnBrand,
56
+ padding: "1px 4px",
57
+ borderRadius: "4px",
58
+ marginLeft: "6px",
59
+ },
60
+ });
61
+
62
+ interface LoadViewPickerProps {
63
+ /** Currently selected entity metadata (with ObjectTypeCode) */
64
+ selectedEntityMetadata: EntityMetadata | null;
65
+ /** Callback when a view is selected - provides the view info for execution optimization */
66
+ onViewSelect: (viewInfo: LoadedViewInfo) => void;
67
+ /** Callback when the view selection is cleared */
68
+ onViewClear?: () => void;
69
+ /** Optional: disable the picker */
70
+ disabled?: boolean;
71
+ }
72
+
73
+ export function LoadViewPicker({
74
+ selectedEntityMetadata,
75
+ onViewSelect,
76
+ onViewClear,
77
+ disabled = false,
78
+ }: LoadViewPickerProps) {
79
+ const styles = useStyles();
80
+
81
+ // State for views
82
+ const [systemViews, setSystemViews] = useState<SavedView[]>([]);
83
+ const [personalViews, setPersonalViews] = useState<SavedView[]>([]);
84
+ const [loading, setLoading] = useState(false);
85
+ const [error, setError] = useState<string | null>(null);
86
+
87
+ // Combobox state - track both input value and selected view name
88
+ const [inputValue, setInputValue] = useState("");
89
+ const [selectedViewName, setSelectedViewName] = useState<string | null>(null);
90
+
91
+ // Fetch views when entity changes
92
+ useEffect(() => {
93
+ if (!selectedEntityMetadata) {
94
+ setSystemViews([]);
95
+ setPersonalViews([]);
96
+ setError(null);
97
+ setSelectedViewName(null);
98
+ setInputValue("");
99
+ return;
100
+ }
101
+
102
+ const fetchViews = async () => {
103
+ setLoading(true);
104
+ setError(null);
105
+ setSelectedViewName(null);
106
+ setInputValue("");
107
+
108
+ try {
109
+ debugLog("viewAPI", `Loading views for ${selectedEntityMetadata.LogicalName}`);
110
+
111
+ const { systemViews: sysViews, personalViews: persViews } = await getAllViews(
112
+ selectedEntityMetadata.LogicalName
113
+ );
114
+
115
+ setSystemViews(sysViews);
116
+ setPersonalViews(persViews);
117
+
118
+ debugLog(
119
+ "viewAPI",
120
+ `Loaded ${sysViews.length} system views and ${persViews.length} personal views`
121
+ );
122
+ } catch (err) {
123
+ const message = err instanceof Error ? err.message : "Failed to load views";
124
+ setError(message);
125
+ console.error("LoadViewPicker: Failed to fetch views:", err);
126
+ } finally {
127
+ setLoading(false);
128
+ }
129
+ };
130
+
131
+ fetchViews();
132
+ }, [selectedEntityMetadata]);
133
+
134
+ // Combine all views for filtering
135
+ const allViews = useMemo(() => {
136
+ return [...systemViews, ...personalViews];
137
+ }, [systemViews, personalViews]);
138
+
139
+ // Filter options based on input value (only when typing, not when showing selected)
140
+ const filterQuery = inputValue && inputValue !== selectedViewName ? inputValue : "";
141
+
142
+ const filteredSystemViews = useMemo(() => {
143
+ if (!filterQuery.trim()) return systemViews;
144
+ const lowerQuery = filterQuery.toLowerCase();
145
+ return systemViews.filter((v) => v.name.toLowerCase().includes(lowerQuery));
146
+ }, [systemViews, filterQuery]);
147
+
148
+ const filteredPersonalViews = useMemo(() => {
149
+ if (!filterQuery.trim()) return personalViews;
150
+ const lowerQuery = filterQuery.toLowerCase();
151
+ return personalViews.filter((v) => v.name.toLowerCase().includes(lowerQuery));
152
+ }, [personalViews, filterQuery]);
153
+
154
+ // Handle selection
155
+ const handleOptionSelect = useCallback<NonNullable<ComboboxProps["onOptionSelect"]>>(
156
+ (_event, data) => {
157
+ // Handle clear action - when clearable button is clicked, optionValue is empty
158
+ if (!data.optionValue) {
159
+ setSelectedViewName(null);
160
+ setInputValue("");
161
+ onViewClear?.();
162
+ debugLog("viewAPI", "View selection cleared");
163
+ return;
164
+ }
165
+
166
+ if (!selectedEntityMetadata) return;
167
+
168
+ const view = allViews.find((v) => v.id === data.optionValue);
169
+ if (view) {
170
+ // Create LoadedViewInfo with all needed data for execution optimization
171
+ const viewInfo: LoadedViewInfo = {
172
+ id: view.id,
173
+ type: view.type,
174
+ originalFetchXml: view.fetchxml,
175
+ entitySetName: selectedEntityMetadata.EntitySetName,
176
+ name: view.name,
177
+ layoutxml: view.layoutxml, // Include layoutxml for column configuration
178
+ };
179
+ onViewSelect(viewInfo);
180
+ debugLog("viewAPI", `Selected view: ${view.name} (${view.type}) - ID: ${view.id}`);
181
+ // Set the selected view name to display in the combobox
182
+ setSelectedViewName(view.name);
183
+ setInputValue(view.name);
184
+ }
185
+ },
186
+ [allViews, onViewSelect, onViewClear, selectedEntityMetadata]
187
+ );
188
+
189
+ // Handle input change for filtering
190
+ const handleInputChange = useCallback(
191
+ (event: ChangeEvent<HTMLInputElement>) => {
192
+ setInputValue(event.target.value);
193
+ // Clear selection when user starts typing something different
194
+ if (event.target.value !== selectedViewName) {
195
+ setSelectedViewName(null);
196
+ }
197
+ },
198
+ [selectedViewName]
199
+ );
200
+
201
+ // Don't render if no entity selected
202
+ if (!selectedEntityMetadata) {
203
+ return null;
204
+ }
205
+
206
+ const hasViews = systemViews.length > 0 || personalViews.length > 0;
207
+ const hasFilteredViews = filteredSystemViews.length > 0 || filteredPersonalViews.length > 0;
208
+
209
+ return (
210
+ <div className={styles.container}>
211
+ <label className={styles.label}>Load View</label>
212
+ <Combobox
213
+ className={styles.combobox}
214
+ placeholder={loading ? "Loading views..." : "Select a view to load"}
215
+ disabled={disabled || loading || !hasViews}
216
+ value={inputValue}
217
+ onChange={handleInputChange}
218
+ onOptionSelect={handleOptionSelect}
219
+ clearable={!!selectedViewName}
220
+ freeform
221
+ >
222
+ {loading && (
223
+ <Option key="loading" value="" text="Loading..." disabled>
224
+ <div className={styles.loadingOption}>
225
+ <Spinner size="tiny" />
226
+ <span>Loading views...</span>
227
+ </div>
228
+ </Option>
229
+ )}
230
+
231
+ {!loading && error && (
232
+ <Option key="error" value="" text="Error" disabled>
233
+ <span style={{ color: tokens.colorPaletteRedForeground1 }}>{error}</span>
234
+ </Option>
235
+ )}
236
+
237
+ {!loading && !error && !hasViews && (
238
+ <Option key="no-views" value="" text="No views available" disabled>
239
+ <span className={styles.noViewsOption}>No views available</span>
240
+ </Option>
241
+ )}
242
+
243
+ {!loading && !error && hasViews && !hasFilteredViews && filterQuery && (
244
+ <Option key="no-match" value="" text="No matching views" disabled>
245
+ <span className={styles.noViewsOption}>No matching views</span>
246
+ </Option>
247
+ )}
248
+
249
+ {!loading && !error && filteredSystemViews.length > 0 && (
250
+ <OptionGroup label="System Views">
251
+ {filteredSystemViews.map((view) => (
252
+ <Option key={view.id} value={view.id} text={view.name}>
253
+ <span>
254
+ {view.name}
255
+ {view.isDefault && <span className={styles.defaultBadge}>Default</span>}
256
+ </span>
257
+ </Option>
258
+ ))}
259
+ </OptionGroup>
260
+ )}
261
+
262
+ {!loading && !error && filteredPersonalViews.length > 0 && (
263
+ <OptionGroup label="Personal Views">
264
+ {filteredPersonalViews.map((view) => (
265
+ <Option key={view.id} value={view.id} text={view.name}>
266
+ {view.name}
267
+ </Option>
268
+ ))}
269
+ </OptionGroup>
270
+ )}
271
+ </Combobox>
272
+ </div>
273
+ );
274
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * SaveViewButton - SplitButton for saving views to Dataverse
3
+ * Supports Save, Save As, and Save and Publish actions
4
+ * Handles both System Views (savedquery) and Personal Views (userquery)
5
+ */
6
+
7
+ import { useState, useCallback } from "react";
8
+ import {
9
+ Menu,
10
+ MenuTrigger,
11
+ MenuList,
12
+ MenuItem,
13
+ MenuPopover,
14
+ SplitButton,
15
+ makeStyles,
16
+ tokens,
17
+ Tooltip,
18
+ type MenuButtonProps,
19
+ } from "@fluentui/react-components";
20
+ import { SaveRegular, SaveArrowRightRegular, SendRegular } from "@fluentui/react-icons";
21
+ import { SaveViewDialog, type SaveViewDialogMode } from "../Dialogs/SaveViewDialog";
22
+
23
+ const useStyles = makeStyles({
24
+ splitButton: {
25
+ minWidth: "110px",
26
+ },
27
+ menuItem: {
28
+ display: "flex",
29
+ alignItems: "center",
30
+ gap: "8px",
31
+ },
32
+ menuIcon: {
33
+ fontSize: "16px",
34
+ color: tokens.colorNeutralForeground2,
35
+ },
36
+ });
37
+
38
+ interface SaveViewButtonProps {
39
+ /** Current FetchXML to save */
40
+ fetchXml: string;
41
+ /** Current LayoutXML configuration */
42
+ layoutXml: string;
43
+ /** Entity logical name for the view */
44
+ entityLogicalName: string;
45
+ /** Entity object type code for LayoutXML */
46
+ objectTypeCode: number;
47
+ /** Primary ID attribute for LayoutXML */
48
+ primaryIdAttribute: string;
49
+ /** Loaded view info (for Save/overwrite) */
50
+ loadedView: {
51
+ id: string;
52
+ type: "system" | "personal";
53
+ name: string;
54
+ } | null;
55
+ /** Callback after successful save */
56
+ onSaveComplete?: (viewId: string, viewType: "system" | "personal", viewName: string) => void;
57
+ /** Disabled state */
58
+ disabled?: boolean;
59
+ }
60
+
61
+ export function SaveViewButton({
62
+ fetchXml,
63
+ layoutXml,
64
+ entityLogicalName,
65
+ objectTypeCode,
66
+ primaryIdAttribute,
67
+ loadedView,
68
+ onSaveComplete,
69
+ disabled = false,
70
+ }: SaveViewButtonProps) {
71
+ const styles = useStyles();
72
+ const [dialogOpen, setDialogOpen] = useState(false);
73
+ const [dialogMode, setDialogMode] = useState<SaveViewDialogMode>("save");
74
+ const [shouldPublish, setShouldPublish] = useState(false);
75
+
76
+ const handleSave = useCallback(() => {
77
+ setShouldPublish(false);
78
+ setDialogMode("save");
79
+ setDialogOpen(true);
80
+ }, []);
81
+
82
+ const handleSaveAs = useCallback(() => {
83
+ setShouldPublish(false);
84
+ setDialogMode("saveAs");
85
+ setDialogOpen(true);
86
+ }, []);
87
+
88
+ const handleSaveAndPublish = useCallback(() => {
89
+ setShouldPublish(true);
90
+ setDialogMode("save");
91
+ setDialogOpen(true);
92
+ }, []);
93
+
94
+ const handleDialogClose = useCallback(() => {
95
+ setDialogOpen(false);
96
+ }, []);
97
+
98
+ const handleDialogSaveComplete = useCallback(
99
+ (viewId: string, viewType: "system" | "personal", viewName: string) => {
100
+ setDialogOpen(false);
101
+ onSaveComplete?.(viewId, viewType, viewName);
102
+ },
103
+ [onSaveComplete]
104
+ );
105
+
106
+ // Primary button text based on state
107
+ const primaryText = loadedView ? "Save" : "Save As";
108
+ const primaryIcon = loadedView ? <SaveRegular /> : <SaveArrowRightRegular />;
109
+
110
+ return (
111
+ <>
112
+ <Menu positioning="below-end">
113
+ <MenuTrigger disableButtonEnhancement>
114
+ {(triggerProps: MenuButtonProps) => (
115
+ <Tooltip
116
+ content={loadedView ? `Save changes to "${loadedView.name}"` : "Save as new view"}
117
+ relationship="description"
118
+ >
119
+ <SplitButton
120
+ className={styles.splitButton}
121
+ appearance="primary"
122
+ icon={primaryIcon}
123
+ menuButton={triggerProps}
124
+ primaryActionButton={{
125
+ onClick: loadedView ? handleSave : handleSaveAs,
126
+ }}
127
+ disabled={disabled}
128
+ >
129
+ {primaryText}
130
+ </SplitButton>
131
+ </Tooltip>
132
+ )}
133
+ </MenuTrigger>
134
+ <MenuPopover>
135
+ <MenuList>
136
+ {loadedView && (
137
+ <MenuItem icon={<SaveRegular className={styles.menuIcon} />} onClick={handleSave}>
138
+ <span className={styles.menuItem}>Save</span>
139
+ </MenuItem>
140
+ )}
141
+ <MenuItem
142
+ icon={<SaveArrowRightRegular className={styles.menuIcon} />}
143
+ onClick={handleSaveAs}
144
+ >
145
+ <span className={styles.menuItem}>Save As...</span>
146
+ </MenuItem>
147
+ {loadedView?.type === "system" && (
148
+ <MenuItem
149
+ icon={<SendRegular className={styles.menuIcon} />}
150
+ onClick={handleSaveAndPublish}
151
+ >
152
+ <span className={styles.menuItem}>Save and Publish</span>
153
+ </MenuItem>
154
+ )}
155
+ </MenuList>
156
+ </MenuPopover>
157
+ </Menu>
158
+
159
+ <SaveViewDialog
160
+ open={dialogOpen}
161
+ onClose={handleDialogClose}
162
+ onSaveComplete={handleDialogSaveComplete}
163
+ mode={dialogMode}
164
+ shouldPublish={shouldPublish}
165
+ fetchXml={fetchXml}
166
+ layoutXml={layoutXml}
167
+ entityLogicalName={entityLogicalName}
168
+ objectTypeCode={objectTypeCode}
169
+ primaryIdAttribute={primaryIdAttribute}
170
+ loadedView={loadedView}
171
+ />
172
+ </>
173
+ );
174
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,11 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { AppShell } from "./app/AppShell";
4
+ // Import debug utility to expose functions to window
5
+ import "./shared/utils/debug";
6
+
7
+ createRoot(document.getElementById("root")!).render(
8
+ <StrictMode>
9
+ <AppShell />
10
+ </StrictMode>
11
+ );
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Shared Attribute Picker component using Fluent UI v9 Combobox
3
+ * Loads attributes from metadata and displays as searchable dropdown
4
+ */
5
+
6
+ import { useState, useEffect } from "react";
7
+ import { Combobox, useComboboxFilter, useId, type ComboboxProps } from "@fluentui/react-components";
8
+ import { loadEntityAttributes } from "../../features/fetchxml/api/dataverseMetadata";
9
+ import type { AttributeMetadata } from "../../features/fetchxml/api/pptbClient";
10
+
11
+ interface AttributePickerProps {
12
+ entityLogicalName: string;
13
+ value: string;
14
+ onChange: (logicalName: string) => void;
15
+ placeholder?: string;
16
+ disabled?: boolean;
17
+ /** Filter to only show attributes of these types */
18
+ filterByTypes?: string[];
19
+ }
20
+
21
+ export function AttributePicker({
22
+ entityLogicalName,
23
+ value,
24
+ onChange,
25
+ placeholder = "Select or type attribute name",
26
+ disabled = false,
27
+ filterByTypes,
28
+ }: AttributePickerProps) {
29
+ const comboId = useId("attribute-combobox");
30
+ const [attributes, setAttributes] = useState<AttributeMetadata[]>([]);
31
+ const [loading, setLoading] = useState(false);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [query, setQuery] = useState<string>("");
34
+
35
+ // Load attributes when entity changes
36
+ useEffect(() => {
37
+ if (!entityLogicalName) {
38
+ setAttributes([]);
39
+ return;
40
+ }
41
+
42
+ setLoading(true);
43
+ setError(null);
44
+
45
+ loadEntityAttributes(entityLogicalName)
46
+ .then((attrs: AttributeMetadata[]) => {
47
+ // Apply type filtering if specified
48
+ let filteredAttrs = attrs;
49
+ if (filterByTypes && filterByTypes.length > 0) {
50
+ filteredAttrs = attrs.filter((a) => filterByTypes.includes(a.AttributeType || ""));
51
+ }
52
+ setAttributes(filteredAttrs);
53
+ setLoading(false);
54
+ })
55
+ .catch((err: unknown) => {
56
+ console.error("Failed to load attributes:", err);
57
+ setError("Failed to load attributes");
58
+ setLoading(false);
59
+ });
60
+ }, [entityLogicalName, filterByTypes]);
61
+
62
+ // Sync query with value when value changes or attributes load
63
+ useEffect(() => {
64
+ if (value && value !== "new_attribute" && attributes.length > 0) {
65
+ const attr = attributes.find((a) => a.LogicalName === value);
66
+ if (attr) {
67
+ const displayName = attr.DisplayName?.UserLocalizedLabel?.Label || attr.SchemaName;
68
+ setQuery(`${displayName} (${attr.LogicalName})`);
69
+ } else {
70
+ // If attribute not found in metadata, just show the logical name
71
+ setQuery(value);
72
+ }
73
+ } else if (!value || value === "new_attribute") {
74
+ setQuery("");
75
+ }
76
+ }, [value, attributes]);
77
+
78
+ // Convert attributes to options for filtering
79
+ const options = attributes.map((attr) => {
80
+ const displayName = attr.DisplayName?.UserLocalizedLabel?.Label || attr.SchemaName;
81
+ const label = `${displayName} (${attr.LogicalName})`;
82
+ return {
83
+ children: label,
84
+ value: attr.LogicalName,
85
+ };
86
+ });
87
+
88
+ // Use Fluent UI filtering
89
+ const filteredChildren = useComboboxFilter(query, options, {
90
+ noOptionsMessage: "No attributes match your search.",
91
+ });
92
+
93
+ const onOptionSelect: ComboboxProps["onOptionSelect"] = (_e, data) => {
94
+ if (data.optionValue) {
95
+ setQuery(data.optionText ?? "");
96
+ onChange(data.optionValue);
97
+ } else {
98
+ // Clear button clicked - clear the attribute
99
+ setQuery("");
100
+ onChange("");
101
+ }
102
+ };
103
+
104
+ // Track the selected option for visual indicator (but don't populate the input)
105
+ const selectedOptions = value && value !== "new_attribute" ? [value] : [];
106
+
107
+ if (error) {
108
+ // Fallback to regular input on error
109
+ return (
110
+ <Combobox
111
+ aria-labelledby={comboId}
112
+ value={query}
113
+ onChange={(ev) => {
114
+ const inputValue = ev.target.value;
115
+ setQuery(inputValue);
116
+ onChange(inputValue);
117
+ }}
118
+ placeholder={placeholder}
119
+ disabled={disabled}
120
+ />
121
+ );
122
+ }
123
+
124
+ return (
125
+ <Combobox
126
+ aria-labelledby={comboId}
127
+ onOptionSelect={onOptionSelect}
128
+ selectedOptions={selectedOptions}
129
+ value={query}
130
+ onChange={(ev) => setQuery(ev.target.value)}
131
+ placeholder={loading ? "Loading attributes..." : placeholder}
132
+ disabled={disabled || loading}
133
+ clearable
134
+ >
135
+ {filteredChildren}
136
+ </Combobox>
137
+ );
138
+ }