@mohsinonxrm/pptb-fetchxml-studio 1.0.0 → 1.0.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.
Files changed (78) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +1 -1
  3. package/dist/assets/index-DD9fzkbz.js +232 -0
  4. package/dist/index.html +70 -0
  5. package/index.html +0 -1
  6. package/npm-shrinkwrap.json +6329 -0
  7. package/package.json +95 -51
  8. package/CHANGELOG.md +0 -120
  9. package/eslint.config.js +0 -23
  10. package/public/vite.svg +0 -1
  11. package/src/app/AppShell.tsx +0 -1733
  12. package/src/assets/20px-Microsoft_Office_Excel_(2025/342/200/223present).svg.png +0 -0
  13. package/src/assets/react.svg +0 -1
  14. package/src/features/fetchxml/api/dataverseMetadata.ts +0 -192
  15. package/src/features/fetchxml/api/excelExport.ts +0 -318
  16. package/src/features/fetchxml/api/formattedValues.ts +0 -107
  17. package/src/features/fetchxml/api/pptbClient.ts +0 -2635
  18. package/src/features/fetchxml/model/displaySettings.ts +0 -18
  19. package/src/features/fetchxml/model/fetchxml.ts +0 -346
  20. package/src/features/fetchxml/model/fetchxmlIntellisense.ts +0 -583
  21. package/src/features/fetchxml/model/fetchxmlParser.ts +0 -708
  22. package/src/features/fetchxml/model/layoutxml.ts +0 -372
  23. package/src/features/fetchxml/model/nodes.ts +0 -255
  24. package/src/features/fetchxml/model/operators.ts +0 -624
  25. package/src/features/fetchxml/model/treeUtils.ts +0 -174
  26. package/src/features/fetchxml/state/builderStore.tsx +0 -1160
  27. package/src/features/fetchxml/state/cache.ts +0 -393
  28. package/src/features/fetchxml/ui/Dialogs/BulkDeleteDialog.tsx +0 -352
  29. package/src/features/fetchxml/ui/Dialogs/DeleteConfirmDialog.tsx +0 -396
  30. package/src/features/fetchxml/ui/Dialogs/SaveViewDialog.tsx +0 -612
  31. package/src/features/fetchxml/ui/Dialogs/SolutionPicker.tsx +0 -114
  32. package/src/features/fetchxml/ui/Dialogs/WorkflowPickerDialog.tsx +0 -437
  33. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/AttributeEditor.tsx +0 -214
  34. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ConditionEditor.tsx +0 -533
  35. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FetchOptionsEditor.tsx +0 -296
  36. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FilterEditor.tsx +0 -106
  37. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/LinkEntityEditor.tsx +0 -433
  38. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/OrderEditor.tsx +0 -161
  39. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ValueOfPicker.tsx +0 -194
  40. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel.tsx +0 -225
  41. package/src/features/fetchxml/ui/LeftPane/TreeView.tsx +0 -604
  42. package/src/features/fetchxml/ui/RightPane/AddColumnsPanel.tsx +0 -800
  43. package/src/features/fetchxml/ui/RightPane/ColumnConfigDialog.tsx +0 -170
  44. package/src/features/fetchxml/ui/RightPane/DataGridCellRenderers.tsx +0 -356
  45. package/src/features/fetchxml/ui/RightPane/EditColumnsPanel.tsx +0 -372
  46. package/src/features/fetchxml/ui/RightPane/FetchXmlEditor.tsx +0 -399
  47. package/src/features/fetchxml/ui/RightPane/FormattedValueUtils.ts +0 -189
  48. package/src/features/fetchxml/ui/RightPane/LayoutXmlViewer.tsx +0 -171
  49. package/src/features/fetchxml/ui/RightPane/PreviewTabs.tsx +0 -471
  50. package/src/features/fetchxml/ui/RightPane/ResultsCommandBar.tsx +0 -430
  51. package/src/features/fetchxml/ui/RightPane/ResultsGrid.tsx +0 -983
  52. package/src/features/fetchxml/ui/RightPane/XmlTextArea.tsx +0 -65
  53. package/src/features/fetchxml/ui/Settings/SettingsDrawer.tsx +0 -175
  54. package/src/features/fetchxml/ui/Toolbar/EntitySelector.tsx +0 -788
  55. package/src/features/fetchxml/ui/Toolbar/LoadViewPicker.tsx +0 -274
  56. package/src/features/fetchxml/ui/Toolbar/SaveViewButton.tsx +0 -174
  57. package/src/main.tsx +0 -11
  58. package/src/shared/components/AttributePicker.tsx +0 -138
  59. package/src/shared/components/BetweenInputPicker.tsx +0 -120
  60. package/src/shared/components/BooleanValuePicker.tsx +0 -165
  61. package/src/shared/components/DateTimeInputPicker.tsx +0 -170
  62. package/src/shared/components/DynamicValueList.tsx +0 -128
  63. package/src/shared/components/ExcelIcon.tsx +0 -30
  64. package/src/shared/components/MultiValuePicker.tsx +0 -178
  65. package/src/shared/components/NumericInputPicker.tsx +0 -156
  66. package/src/shared/components/OptionSetValuePicker.tsx +0 -150
  67. package/src/shared/components/RelationshipPicker.tsx +0 -268
  68. package/src/shared/contexts/ThemeContext.tsx +0 -19
  69. package/src/shared/hooks/useAccessMode.ts +0 -77
  70. package/src/shared/hooks/useLazyMetadata.ts +0 -132
  71. package/src/shared/hooks/usePptbContext.ts +0 -170
  72. package/src/shared/hooks/usePublisherFilter.ts +0 -214
  73. package/src/shared/hooks/useSolutionFilter.ts +0 -162
  74. package/src/shared/utils/debug.ts +0 -87
  75. package/tsconfig.app.json +0 -28
  76. package/tsconfig.json +0 -7
  77. package/tsconfig.node.json +0 -26
  78. package/vite.config.ts +0 -7
@@ -1,1733 +0,0 @@
1
- /**
2
- * Main application shell with Fluent UI theming and layout
3
- */
4
-
5
- import { useState, useEffect, useMemo, useCallback } from "react";
6
- import {
7
- FluentProvider,
8
- webLightTheme,
9
- webDarkTheme,
10
- makeStyles,
11
- tokens,
12
- } from "@fluentui/react-components";
13
- import { usePptbContext } from "../shared/hooks/usePptbContext";
14
- import { useLazyMetadata } from "../shared/hooks/useLazyMetadata";
15
- import { PreviewTabs } from "../features/fetchxml/ui/RightPane/PreviewTabs";
16
- import type { RelatedEntityColumn } from "../features/fetchxml/ui/RightPane/AddColumnsPanel";
17
- import { EntitySelector } from "../features/fetchxml/ui/Toolbar/EntitySelector";
18
- import { SaveViewButton } from "../features/fetchxml/ui/Toolbar/SaveViewButton";
19
- import { TreeView } from "../features/fetchxml/ui/LeftPane/TreeView";
20
- import { PropertiesPanel } from "../features/fetchxml/ui/LeftPane/PropertiesPanel";
21
- import { BuilderProvider, useBuilder } from "../features/fetchxml/state/builderStore";
22
- import { ThemeProvider } from "../shared/contexts/ThemeContext";
23
- import { DeleteConfirmDialog } from "../features/fetchxml/ui/Dialogs/DeleteConfirmDialog";
24
- import { BulkDeleteDialog } from "../features/fetchxml/ui/Dialogs/BulkDeleteDialog";
25
- import { WorkflowPickerDialog } from "../features/fetchxml/ui/Dialogs/WorkflowPickerDialog";
26
- import {
27
- executeFetchXml,
28
- executeSystemView,
29
- executePersonalView,
30
- whoAmI,
31
- isDataverseAvailable,
32
- exportToExcel,
33
- downloadBase64File,
34
- checkPrivilegeByName,
35
- getEnvironmentUrl,
36
- buildRecordUrl,
37
- deleteRecord,
38
- deleteRecordsBatch,
39
- checkBulkDeletePrivilege,
40
- submitBulkDelete,
41
- submitBulkDeleteFromFetchXml,
42
- checkWorkflowPrivileges,
43
- getOnDemandWorkflows,
44
- executeWorkflowBatch,
45
- checkDeletePrivilege,
46
- } from "../features/fetchxml/api/pptbClient";
47
- import { exportToExcelLocal, downloadExcelFile } from "../features/fetchxml/api/excelExport";
48
- import type {
49
- AttributeMetadata,
50
- LoadedViewInfo,
51
- EntityMetadata,
52
- RelationshipMetadata,
53
- WorkflowInfo,
54
- BatchDeleteProgress,
55
- BatchDeleteResult,
56
- WorkflowBatchProgress,
57
- } from "../features/fetchxml/api/pptbClient";
58
- import { generateFetchXml, addPagingToFetchXml } from "../features/fetchxml/model/fetchxml";
59
- import { generateLayoutXml } from "../features/fetchxml/model/layoutxml";
60
- import { collectAttributesFromFetchXml } from "../features/fetchxml/model/layoutxml";
61
- import type { QueryResult } from "../features/fetchxml/ui/RightPane/ResultsGrid";
62
- import { SettingsDrawer } from "../features/fetchxml/ui/Settings/SettingsDrawer";
63
- import {
64
- defaultDisplaySettings,
65
- type DisplaySettings,
66
- } from "../features/fetchxml/model/displaySettings";
67
-
68
- // ⚠️ IMPORTANT: makeStyles must be called OUTSIDE the component
69
- // but tokens will automatically update when FluentProvider theme changes
70
- // because Fluent UI v9 uses CSS variables under the hood
71
- const useStyles = makeStyles({
72
- root: {
73
- display: "flex",
74
- flexDirection: "row",
75
- height: "100%",
76
- width: "100%",
77
- overflow: "hidden",
78
- position: "absolute",
79
- top: 0,
80
- left: 0,
81
- right: 0,
82
- bottom: 0,
83
- },
84
- leftPane: {
85
- display: "flex",
86
- flexDirection: "column",
87
- overflow: "hidden",
88
- minWidth: "300px",
89
- maxWidth: "800px",
90
- height: "100%",
91
- },
92
- leftPaneTop: {
93
- display: "flex",
94
- flexDirection: "column",
95
- overflow: "hidden",
96
- minHeight: 0, // Important for flex child with overflow
97
- },
98
- horizontalResizeHandle: {
99
- height: "6px",
100
- cursor: "ns-resize",
101
- backgroundColor: tokens.colorNeutralBackground3,
102
- display: "flex",
103
- alignItems: "center",
104
- justifyContent: "center",
105
- position: "relative",
106
- flexShrink: 0,
107
- ":hover": {
108
- backgroundColor: tokens.colorNeutralBackground3Hover,
109
- },
110
- ":active": {
111
- backgroundColor: tokens.colorBrandBackgroundPressed,
112
- },
113
- },
114
- horizontalGrip: {
115
- fontSize: "10px",
116
- color: tokens.colorNeutralForeground3,
117
- userSelect: "none",
118
- pointerEvents: "none",
119
- display: "flex",
120
- gap: "3px",
121
- alignItems: "center",
122
- },
123
- verticalResizeHandle: {
124
- width: "6px",
125
- cursor: "ew-resize",
126
- backgroundColor: tokens.colorNeutralBackground3,
127
- display: "flex",
128
- alignItems: "center",
129
- justifyContent: "center",
130
- position: "relative",
131
- flexShrink: 0,
132
- ":hover": {
133
- backgroundColor: tokens.colorNeutralBackground3Hover,
134
- },
135
- ":active": {
136
- backgroundColor: tokens.colorBrandBackgroundPressed,
137
- },
138
- },
139
- verticalGrip: {
140
- fontSize: "10px",
141
- color: tokens.colorNeutralForeground3,
142
- userSelect: "none",
143
- pointerEvents: "none",
144
- display: "flex",
145
- flexDirection: "column",
146
- gap: "2px",
147
- lineHeight: "0.5",
148
- },
149
- leftPaneBottom: {
150
- display: "flex",
151
- flexDirection: "column",
152
- minHeight: "100px",
153
- overflow: "hidden",
154
- minWidth: 0, // Important for flex child with overflow
155
- },
156
- propertiesContent: {
157
- flex: 1,
158
- overflow: "hidden",
159
- },
160
- rightPane: {
161
- flex: 1,
162
- display: "flex",
163
- flexDirection: "column",
164
- overflow: "hidden",
165
- minWidth: 0,
166
- },
167
- toolbar: {
168
- padding: "12px 16px",
169
- borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
170
- borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
171
- display: "flex",
172
- gap: "8px",
173
- alignItems: "center",
174
- flexShrink: 0,
175
- },
176
- placeholder: {
177
- padding: "16px",
178
- color: tokens.colorNeutralForeground3,
179
- display: "flex",
180
- alignItems: "center",
181
- justifyContent: "center",
182
- height: "100%",
183
- },
184
- });
185
-
186
- export function AppShell() {
187
- const { theme } = usePptbContext();
188
- // Use theme from PPTB host context
189
- const isDark = theme === "dark";
190
-
191
- console.log("🎨 AppShell rendering:");
192
- console.log(" - theme from context:", theme);
193
- console.log(" - isDark:", isDark);
194
- console.log(" - will use Fluent theme:", isDark ? "webDarkTheme" : "webLightTheme");
195
- console.log(" - tokens.colorNeutralBackground1:", tokens.colorNeutralBackground1);
196
- console.log(" - tokens.colorNeutralForeground1:", tokens.colorNeutralForeground1);
197
-
198
- return (
199
- <FluentProvider theme={isDark ? webDarkTheme : webLightTheme}>
200
- <ThemeProvider isDark={isDark}>
201
- <BuilderProvider>
202
- <AppContent />
203
- </BuilderProvider>
204
- </ThemeProvider>
205
- </FluentProvider>
206
- );
207
- }
208
-
209
- /**
210
- * Collect all unique entity logical names from a FetchXML query
211
- * Includes root entity and all link-entities (recursively)
212
- */
213
- function collectEntitiesFromFetchQuery(
214
- fetchQuery: import("../features/fetchxml/model/nodes").FetchNode | null
215
- ): string[] {
216
- if (!fetchQuery?.entity?.name) return [];
217
-
218
- const entities = new Set<string>();
219
- entities.add(fetchQuery.entity.name);
220
-
221
- const collectFromLinks = (
222
- links: import("../features/fetchxml/model/nodes").LinkEntityNode[] | undefined
223
- ) => {
224
- links?.forEach((link) => {
225
- if (link.name) {
226
- entities.add(link.name);
227
- }
228
- if (link.links) {
229
- collectFromLinks(link.links);
230
- }
231
- });
232
- };
233
-
234
- collectFromLinks(fetchQuery.entity.links);
235
-
236
- return Array.from(entities);
237
- }
238
-
239
- function AppContent() {
240
- const styles = useStyles();
241
- const builder = useBuilder();
242
- const { loadAttributes, loadEntityMetadata, loadRelationships } = useLazyMetadata();
243
-
244
- // State for query execution
245
- const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
246
- const [isExecuting, setIsExecuting] = useState(false);
247
- const [isLoadingMore, setIsLoadingMore] = useState(false);
248
- // Multi-entity attribute metadata: Map<entityLogicalName, Map<attributeLogicalName, AttributeMetadata>>
249
- const [attributeMetadata, setAttributeMetadata] = useState<
250
- Map<string, Map<string, AttributeMetadata>>
251
- >(new Map());
252
- // State for entity metadata (for layoutxml generation)
253
- const [entityMetadata, setEntityMetadata] = useState<EntityMetadata | null>(null);
254
- // State for lookup relationships (many-to-one) and one-to-many relationships
255
- const [lookupRelationships, setLookupRelationships] = useState<RelationshipMetadata[]>([]);
256
- const [oneToManyRelationships, setOneToManyRelationships] = useState<RelationshipMetadata[]>([]);
257
- const [isLoadingRelationships, setIsLoadingRelationships] = useState(false);
258
-
259
- // Paging state for infinite scroll / Retrieve All
260
- // Kept separate from queryResult to track progress across page fetches
261
- const [pagingState, setPagingState] = useState<{
262
- currentPage: number;
263
- pagingCookie?: string;
264
- moreRecords: boolean;
265
- isRetrieveAllInProgress: boolean;
266
- } | null>(null);
267
-
268
- // Export to Excel state
269
- const [exportStatus, setExportStatus] = useState<{
270
- isExporting: boolean;
271
- error?: string;
272
- hasPrivilege: boolean;
273
- privilegeChecked: boolean;
274
- }>({
275
- isExporting: false,
276
- hasPrivilege: false,
277
- privilegeChecked: false,
278
- });
279
-
280
- // Record action privileges state
281
- const [recordActionPrivileges, setRecordActionPrivileges] = useState<{
282
- canDelete: boolean;
283
- canBulkDelete: boolean;
284
- canRunWorkflow: boolean;
285
- privilegesChecked: boolean;
286
- }>({
287
- canDelete: false,
288
- canBulkDelete: false,
289
- canRunWorkflow: false,
290
- privilegesChecked: false,
291
- });
292
-
293
- // Dialog states for record actions
294
- const [deleteDialogState, setDeleteDialogState] = useState<{
295
- open: boolean;
296
- recordIds: string[];
297
- recordName?: string;
298
- isBatchDelete?: boolean; // Using batch DELETE for 4-100 records
299
- }>({ open: false, recordIds: [] });
300
-
301
- // Progress tracking for batch delete
302
- const [deleteProgress, setDeleteProgress] = useState<BatchDeleteProgress | null>(null);
303
-
304
- const [bulkDeleteDialogState, setBulkDeleteDialogState] = useState<{
305
- open: boolean;
306
- recordIds: string[];
307
- isAllRecords?: boolean;
308
- totalViewRecords?: number;
309
- }>({ open: false, recordIds: [] });
310
-
311
- const [workflowDialogState, setWorkflowDialogState] = useState<{
312
- open: boolean;
313
- recordIds: string[];
314
- preSelectedWorkflow?: WorkflowInfo;
315
- }>({ open: false, recordIds: [] });
316
-
317
- // Currently selected record IDs (from ResultsGrid)
318
- const [selectedRecordIds, setSelectedRecordIds] = useState<string[]>([]);
319
-
320
- // Settings drawer state
321
- const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
322
- const [displaySettings, setDisplaySettings] = useState<DisplaySettings>(defaultDisplaySettings);
323
-
324
- // State for resizable split
325
- const [topHeight, setTopHeight] = useState(58); // Percentage of left pane height for tree/properties
326
- const [leftPaneWidth, setLeftPaneWidth] = useState(480); // Width of left pane in pixels
327
- const [isDraggingHorizontal, setIsDraggingHorizontal] = useState(false); // Tree/Properties resize
328
- const [isDraggingVertical, setIsDraggingVertical] = useState(false); // Left/Right pane resize
329
-
330
- // Handle horizontal resize (tree/properties split)
331
- useEffect(() => {
332
- if (!isDraggingHorizontal) return;
333
-
334
- const handleMouseMove = (e: MouseEvent) => {
335
- const leftPane = document.querySelector("[data-left-pane]") as HTMLElement;
336
- if (!leftPane) return;
337
-
338
- const rect = leftPane.getBoundingClientRect();
339
- const newTopHeight = ((e.clientY - rect.top) / rect.height) * 100;
340
-
341
- // Constrain between 30% and 80%
342
- if (newTopHeight >= 30 && newTopHeight <= 80) {
343
- setTopHeight(newTopHeight);
344
- }
345
- };
346
-
347
- const handleMouseUp = () => {
348
- setIsDraggingHorizontal(false);
349
- };
350
-
351
- document.addEventListener("mousemove", handleMouseMove);
352
- document.addEventListener("mouseup", handleMouseUp);
353
-
354
- return () => {
355
- document.removeEventListener("mousemove", handleMouseMove);
356
- document.removeEventListener("mouseup", handleMouseUp);
357
- };
358
- }, [isDraggingHorizontal]);
359
-
360
- // Handle vertical resize (left/right pane split)
361
- useEffect(() => {
362
- if (!isDraggingVertical) return;
363
-
364
- const handleMouseMove = (e: MouseEvent) => {
365
- const appRoot = document.querySelector("[data-app-root]") as HTMLElement;
366
- if (!appRoot) return;
367
-
368
- const rect = appRoot.getBoundingClientRect();
369
- const newLeftPaneWidth = e.clientX - rect.left;
370
-
371
- // Constrain between 300px and 800px
372
- if (newLeftPaneWidth >= 300 && newLeftPaneWidth <= 800) {
373
- setLeftPaneWidth(newLeftPaneWidth);
374
- }
375
- };
376
-
377
- const handleMouseUp = () => {
378
- setIsDraggingVertical(false);
379
- };
380
-
381
- document.addEventListener("mousemove", handleMouseMove);
382
- document.addEventListener("mouseup", handleMouseUp);
383
-
384
- return () => {
385
- document.removeEventListener("mousemove", handleMouseMove);
386
- document.removeEventListener("mouseup", handleMouseUp);
387
- };
388
- }, [isDraggingVertical]);
389
-
390
- // Check Dataverse API on mount
391
- useEffect(() => {
392
- console.log("=== PPTB FetchXML Studio - Dataverse API Check ===");
393
- console.log("Dataverse API Available:", isDataverseAvailable());
394
-
395
- if (isDataverseAvailable()) {
396
- console.log("window.dataverseAPI methods:", Object.keys(window.dataverseAPI || {}));
397
-
398
- // Call WhoAmI to verify connection
399
- whoAmI()
400
- .then((result) => {
401
- if (result) {
402
- console.log("✅ WhoAmI Success:", {
403
- UserId: result.UserId,
404
- BusinessUnitId: result.BusinessUnitId,
405
- OrganizationId: result.OrganizationId,
406
- });
407
- } else {
408
- console.warn("⚠️ WhoAmI returned null - API may not be fully initialized");
409
- }
410
- })
411
- .catch((error) => {
412
- console.error("❌ WhoAmI Error:", error);
413
- });
414
- } else {
415
- console.error("❌ Dataverse API not available on window object");
416
- }
417
-
418
- console.log("===================================================");
419
- }, []);
420
-
421
- // Check export privilege on mount
422
- useEffect(() => {
423
- const checkExportPrivilege = async () => {
424
- try {
425
- const user = await whoAmI();
426
- if (user) {
427
- const hasPrivilege = await checkPrivilegeByName(user.UserId, "prvExportToExcel");
428
- setExportStatus((prev) => ({
429
- ...prev,
430
- hasPrivilege,
431
- privilegeChecked: true,
432
- }));
433
- console.log(`📊 Export to Excel privilege: ${hasPrivilege ? "GRANTED" : "DENIED"}`);
434
- } else {
435
- setExportStatus((prev) => ({
436
- ...prev,
437
- hasPrivilege: false,
438
- privilegeChecked: true,
439
- }));
440
- }
441
- } catch (error) {
442
- console.error("Failed to check export privilege:", error);
443
- setExportStatus((prev) => ({
444
- ...prev,
445
- hasPrivilege: false,
446
- privilegeChecked: true,
447
- }));
448
- }
449
- };
450
-
451
- if (isDataverseAvailable()) {
452
- checkExportPrivilege();
453
- }
454
- }, []);
455
-
456
- // Check record action privileges when entity changes
457
- const entityLogicalName = builder.fetchQuery?.entity.name;
458
- useEffect(() => {
459
- if (!entityLogicalName || !isDataverseAvailable()) {
460
- setRecordActionPrivileges({
461
- canDelete: false,
462
- canBulkDelete: false,
463
- canRunWorkflow: false,
464
- privilegesChecked: false,
465
- });
466
- return;
467
- }
468
-
469
- const checkRecordPrivileges = async () => {
470
- try {
471
- // Check delete privilege for this entity
472
- const canDelete = await checkDeletePrivilege(entityLogicalName);
473
-
474
- // Check bulk delete privilege
475
- const canBulkDelete = await checkBulkDeletePrivilege();
476
-
477
- // Check workflow privileges
478
- const canRunWorkflow = await checkWorkflowPrivileges();
479
-
480
- setRecordActionPrivileges({
481
- canDelete,
482
- canBulkDelete,
483
- canRunWorkflow,
484
- privilegesChecked: true,
485
- });
486
-
487
- console.log(`🔐 Record action privileges for ${entityLogicalName}:`, {
488
- canDelete,
489
- canBulkDelete,
490
- canRunWorkflow,
491
- });
492
- } catch (error) {
493
- console.error("Failed to check record action privileges:", error);
494
- setRecordActionPrivileges({
495
- canDelete: false,
496
- canBulkDelete: false,
497
- canRunWorkflow: false,
498
- privilegesChecked: true,
499
- });
500
- }
501
- };
502
-
503
- checkRecordPrivileges();
504
- }, [entityLogicalName]);
505
-
506
- // Collect entities from FetchXML and memoize to avoid unnecessary reloads
507
- const entitiesInQuery = useMemo(() => {
508
- return collectEntitiesFromFetchQuery(builder.fetchQuery);
509
- }, [builder.fetchQuery]);
510
-
511
- // Create a stable key for the entities list to minimize effect re-runs
512
- const entitiesKey = useMemo(() => entitiesInQuery.sort().join(","), [entitiesInQuery]);
513
-
514
- // Load attribute metadata for all entities in the FetchXML query
515
- // This includes the root entity and all link-entities
516
- useEffect(() => {
517
- if (entitiesInQuery.length === 0) {
518
- setAttributeMetadata(new Map());
519
- setEntityMetadata(null);
520
- return;
521
- }
522
-
523
- const rootEntityName = entitiesInQuery[0];
524
-
525
- // Load attributes for all entities in the query
526
- Promise.all(
527
- entitiesInQuery.map(async (entityName) => {
528
- try {
529
- const attributes = await loadAttributes(entityName);
530
- const attrMap = new Map<string, AttributeMetadata>();
531
- attributes.forEach((attr) => {
532
- attrMap.set(attr.LogicalName, attr);
533
- });
534
- return { entityName, attrMap };
535
- } catch (error) {
536
- console.error(`Failed to load attributes for ${entityName}:`, error);
537
- return { entityName, attrMap: new Map<string, AttributeMetadata>() };
538
- }
539
- })
540
- ).then((results) => {
541
- const multiEntityMap = new Map<string, Map<string, AttributeMetadata>>();
542
- results.forEach(({ entityName, attrMap }) => {
543
- multiEntityMap.set(entityName, attrMap);
544
- });
545
- setAttributeMetadata(multiEntityMap);
546
- });
547
-
548
- // Load entity metadata (for ObjectTypeCode and PrimaryIdAttribute)
549
- loadEntityMetadata(rootEntityName)
550
- .then((entity) => {
551
- setEntityMetadata(entity);
552
- })
553
- .catch((error) => {
554
- console.error("Failed to load entity metadata:", error);
555
- setEntityMetadata(null);
556
- });
557
-
558
- // Load relationships (many-to-one and one-to-many) for the Add Columns panel
559
- setIsLoadingRelationships(true);
560
- loadRelationships(rootEntityName)
561
- .then((relationships) => {
562
- // Store lookup relationships (many-to-one)
563
- setLookupRelationships(relationships.manyToOne);
564
- // Store one-to-many relationships for 1-N column support
565
- setOneToManyRelationships(relationships.oneToMany);
566
- })
567
- .catch((error) => {
568
- console.error("Failed to load relationships:", error);
569
- setLookupRelationships([]);
570
- setOneToManyRelationships([]);
571
- })
572
- .finally(() => {
573
- setIsLoadingRelationships(false);
574
- });
575
- // eslint-disable-next-line react-hooks/exhaustive-deps
576
- }, [entitiesKey, loadAttributes, loadEntityMetadata, loadRelationships]);
577
-
578
- // Clear query results when the fetch query structure changes (entity change, view clear, etc.)
579
- // We use the entity node id as a proxy - it changes when entity is re-selected or view is cleared
580
- useEffect(() => {
581
- setQueryResult(null);
582
- }, [builder.fetchQuery?.entity?.id]);
583
-
584
- // Sync layout with FetchXML when needed (e.g., after adding/removing attributes)
585
- // Also sync when columnConfig is null but there are attributes (ensures columns button works)
586
- useEffect(() => {
587
- const hasAttributes = (builder.fetchQuery?.entity?.attributes?.length ?? 0) > 0;
588
- const needsSync = builder.layoutNeedsSync || (hasAttributes && !builder.columnConfig);
589
-
590
- if (needsSync && builder.fetchQuery) {
591
- // Build attribute type map from metadata for better column widths
592
- // Use root entity's attribute metadata
593
- const attributeTypeMap = new Map<string, string>();
594
- const rootEntityName = builder.fetchQuery.entity?.name;
595
- if (attributeMetadata && rootEntityName) {
596
- const rootEntityAttrs = attributeMetadata.get(rootEntityName);
597
- rootEntityAttrs?.forEach((attr, name) => {
598
- attributeTypeMap.set(name, attr.AttributeType || "");
599
- });
600
- }
601
- builder.syncLayoutWithFetchXml(attributeTypeMap);
602
- }
603
- }, [builder.layoutNeedsSync, builder.fetchQuery, builder.columnConfig, attributeMetadata]);
604
-
605
- // Generate FetchXML from builder state
606
- const fetchXml = builder.fetchQuery ? generateFetchXml(builder.fetchQuery) : "";
607
-
608
- // Check if current query is an aggregate query (disables delete/workflow buttons)
609
- const isAggregateQuery = builder.fetchQuery?.options?.aggregate === true;
610
-
611
- // Check if query has 1-N or N-N relationships that cause row duplication
612
- // When these relationships exist, record commands should be disabled
613
- const hasOneToManyRelationship = useMemo(() => {
614
- if (!builder.fetchQuery?.entity?.links) return false;
615
-
616
- const checkLinks = (links: typeof builder.fetchQuery.entity.links): boolean => {
617
- for (const link of links) {
618
- // Check if this link-entity is a 1-N or N-N relationship
619
- if (link.relationshipType === "1N" || link.relationshipType === "NN") {
620
- return true;
621
- }
622
- // Check nested link-entities
623
- if (link.links && checkLinks(link.links)) {
624
- return true;
625
- }
626
- }
627
- return false;
628
- };
629
-
630
- return checkLinks(builder.fetchQuery.entity.links);
631
- }, [builder.fetchQuery]);
632
-
633
- // Generate LayoutXML from column config for saving
634
- const layoutXml = useMemo(() => {
635
- if (!builder.columnConfig || !entityMetadata) {
636
- return "";
637
- }
638
- return generateLayoutXml({
639
- ...builder.columnConfig,
640
- objectTypeCode: entityMetadata.ObjectTypeCode,
641
- primaryIdAttribute: entityMetadata.PrimaryIdAttribute,
642
- });
643
- }, [builder.columnConfig, entityMetadata]);
644
-
645
- // Handle save view completion - update loaded view state
646
- const handleSaveViewComplete = (
647
- viewId: string,
648
- viewType: "system" | "personal",
649
- viewName: string
650
- ) => {
651
- // Update the builder's loaded view state so subsequent saves overwrite the same view
652
- if (entityMetadata) {
653
- builder.setLoadedView({
654
- id: viewId,
655
- type: viewType,
656
- entitySetName: entityMetadata.EntitySetName,
657
- name: viewName,
658
- });
659
- }
660
- console.log(`✅ View saved: ${viewName} (${viewType}) - ${viewId}`);
661
- };
662
-
663
- /**
664
- * Build columns array from FetchXML and result data
665
- * Handles lookup field naming conventions and ensures all requested columns are present
666
- */
667
- const buildColumnsFromResult = (
668
- records: Record<string, unknown>[],
669
- fetchQuery: typeof builder.fetchQuery
670
- ): string[] => {
671
- // Start with columns from the result data (these have actual values)
672
- const resultKeys = records.length > 0 ? Object.keys(records[0]) : [];
673
- const resultKeySet = new Set(resultKeys.filter((k) => !k.includes("@")));
674
-
675
- if (fetchQuery) {
676
- // Get columns from FetchXML - this includes all requested attributes
677
- const fetchXmlColumns = collectAttributesFromFetchXml(fetchQuery);
678
- const fetchXmlColumnNames = fetchXmlColumns.map((col) => col.name);
679
-
680
- // Build the final column list, handling lookup field naming conventions
681
- const columnSet = new Set<string>();
682
- const columns: string[] = [];
683
-
684
- for (const colName of fetchXmlColumnNames) {
685
- if (resultKeySet.has(colName)) {
686
- columns.push(colName);
687
- columnSet.add(colName);
688
- } else if (resultKeySet.has(`_${colName}_value`)) {
689
- columns.push(`_${colName}_value`);
690
- columnSet.add(`_${colName}_value`);
691
- columnSet.add(colName);
692
- } else {
693
- columns.push(colName);
694
- columnSet.add(colName);
695
- }
696
- }
697
-
698
- // Add any result columns not already handled
699
- for (const key of resultKeys) {
700
- if (!columnSet.has(key) && !key.includes("@")) {
701
- columns.push(key);
702
- columnSet.add(key);
703
- }
704
- }
705
-
706
- return columns;
707
- } else {
708
- return resultKeys.filter((k) => !k.includes("@"));
709
- }
710
- };
711
-
712
- /**
713
- * Execute the query and handle Retrieve All if enabled
714
- */
715
- const handleExecute = async () => {
716
- if (!fetchXml) return;
717
-
718
- setIsExecuting(true);
719
- setQueryResult(null);
720
- setPagingState(null);
721
-
722
- const startTime = performance.now();
723
- const entityLogicalName = builder.fetchQuery?.entity.name;
724
- const retrieveAll = builder.retrieveAllRecords;
725
-
726
- // Check if user has set a 'top' limit in FetchXML - if so, we respect it and don't enable paging
727
- const hasTopLimit = builder.fetchQuery?.options?.top !== undefined;
728
- // Get page size from fetch options (count), default to 5000 if not set
729
- const pageSize = builder.fetchQuery?.options?.count;
730
-
731
- try {
732
- // Determine execution method based on loaded view state
733
- let result;
734
- const loadedView = builder.loadedView;
735
- let useViewExecution = false;
736
-
737
- if (loadedView) {
738
- const isUnmodified =
739
- fetchXml.replace(/\s+/g, "") === loadedView.originalFetchXml.replace(/\s+/g, "");
740
-
741
- if (isUnmodified) {
742
- useViewExecution = true;
743
- console.log(
744
- `📋 Executing ${loadedView.type} view "${loadedView.name}" via ${
745
- loadedView.type === "system" ? "savedQuery" : "userQuery"
746
- }=${loadedView.id}`
747
- );
748
-
749
- if (loadedView.type === "system") {
750
- result = await executeSystemView(loadedView.entitySetName, loadedView.id);
751
- } else {
752
- result = await executePersonalView(loadedView.entitySetName, loadedView.id);
753
- }
754
- }
755
- }
756
-
757
- // If not using view execution (either no view or view was modified), use FetchXML
758
- if (!result) {
759
- console.log(
760
- loadedView
761
- ? `📝 View "${loadedView.name}" was modified - executing via fetchXmlQuery`
762
- : "📡 Executing FetchXML query"
763
- );
764
- result = await executeFetchXml(fetchXml);
765
- }
766
-
767
- const executionTimeMs = Math.round(performance.now() - startTime);
768
- const columns = buildColumnsFromResult(result.records, builder.fetchQuery);
769
- const rows = result.records.map((record) => ({ ...record }));
770
-
771
- // Set initial result
772
- setQueryResult({
773
- columns,
774
- rows,
775
- totalRecordCount: result.totalRecordCount,
776
- moreRecords: result.moreRecords,
777
- pagingCookie: result.pagingCookie,
778
- entityLogicalName,
779
- executionTimeMs,
780
- });
781
-
782
- // Initialize paging state
783
- setPagingState({
784
- currentPage: 1,
785
- pagingCookie: result.pagingCookie,
786
- moreRecords: result.moreRecords ?? false,
787
- isRetrieveAllInProgress: retrieveAll && (result.moreRecords ?? false) && !hasTopLimit,
788
- });
789
-
790
- setIsExecuting(false);
791
-
792
- // If Retrieve All is enabled and there are more records, start progressive loading
793
- // Only for FetchXML execution, not for view execution (which doesn't support paging)
794
- // Don't do Retrieve All if user set a 'top' limit - Dataverse handles the limit
795
- if (retrieveAll && result.moreRecords && !useViewExecution && !hasTopLimit) {
796
- await loadAllPages(
797
- fetchXml,
798
- columns,
799
- rows,
800
- result.pagingCookie,
801
- 2,
802
- entityLogicalName,
803
- pageSize
804
- );
805
- }
806
- } catch (error) {
807
- console.error("Failed to execute FetchXML:", error);
808
- setQueryResult({ columns: [], rows: [] });
809
- setIsExecuting(false);
810
- }
811
- };
812
-
813
- /**
814
- * Progressive loading of all pages (Retrieve All)
815
- * Updates results as each page is fetched
816
- */
817
- const loadAllPages = async (
818
- baseFetchXml: string,
819
- columns: string[],
820
- initialRows: Record<string, unknown>[],
821
- pagingCookie: string | undefined,
822
- startPage: number,
823
- entityLogicalName: string | undefined,
824
- pageSize?: number
825
- ) => {
826
- let allRows = [...initialRows];
827
- let currentPagingCookie = pagingCookie;
828
- let page = startPage;
829
- let hasMore = true;
830
-
831
- setIsLoadingMore(true);
832
-
833
- try {
834
- while (hasMore) {
835
- console.log(`📄 Retrieve All: Fetching page ${page}...`);
836
-
837
- // Add paging parameters to FetchXML (page, paging-cookie, and count for page size)
838
- const pagedFetchXml = addPagingToFetchXml(
839
- baseFetchXml,
840
- page,
841
- currentPagingCookie,
842
- pageSize
843
- );
844
- const result = await executeFetchXml(pagedFetchXml);
845
-
846
- // Append new rows
847
- allRows = [...allRows, ...result.records];
848
-
849
- // Update the grid progressively
850
- setQueryResult((prev) => ({
851
- columns: prev?.columns || columns,
852
- rows: allRows,
853
- totalRecordCount: prev?.totalRecordCount,
854
- moreRecords: result.moreRecords,
855
- pagingCookie: result.pagingCookie,
856
- entityLogicalName,
857
- executionTimeMs: prev?.executionTimeMs,
858
- }));
859
-
860
- // Update paging state
861
- currentPagingCookie = result.pagingCookie;
862
- hasMore = result.moreRecords ?? false;
863
-
864
- setPagingState({
865
- currentPage: page,
866
- pagingCookie: currentPagingCookie,
867
- moreRecords: hasMore,
868
- isRetrieveAllInProgress: hasMore,
869
- });
870
-
871
- page++;
872
- }
873
-
874
- console.log(`✅ Retrieve All complete: ${allRows.length} total records`);
875
- } catch (error) {
876
- console.error("Failed during Retrieve All:", error);
877
- } finally {
878
- setIsLoadingMore(false);
879
- setPagingState((prev) => (prev ? { ...prev, isRetrieveAllInProgress: false } : null));
880
- }
881
- };
882
-
883
- /**
884
- * Load more records (for infinite scroll when Retrieve All is OFF)
885
- */
886
- const handleLoadMore = async () => {
887
- if (!fetchXml || !pagingState || !pagingState.moreRecords || isLoadingMore) return;
888
- if (pagingState.isRetrieveAllInProgress) return; // Don't allow manual load during Retrieve All
889
-
890
- // Don't load more if user has set a 'top' limit
891
- if (builder.fetchQuery?.options?.top !== undefined) return;
892
-
893
- setIsLoadingMore(true);
894
-
895
- // Get page size from fetch options to maintain consistent page sizes
896
- const pageSize = builder.fetchQuery?.options?.count;
897
-
898
- try {
899
- const nextPage = pagingState.currentPage + 1;
900
- console.log(
901
- `📄 Loading page ${nextPage}${pagingState.pagingCookie ? " with paging cookie" : ""}...`
902
- );
903
-
904
- // Add paging parameters: page number, paging cookie (required for reliable paging), and count (page size)
905
- const pagedFetchXml = addPagingToFetchXml(
906
- fetchXml,
907
- nextPage,
908
- pagingState.pagingCookie,
909
- pageSize
910
- );
911
- const result = await executeFetchXml(pagedFetchXml);
912
-
913
- // Append new rows to existing result
914
- setQueryResult((prev) => {
915
- if (!prev) return prev;
916
- return {
917
- ...prev,
918
- rows: [...prev.rows, ...result.records],
919
- moreRecords: result.moreRecords,
920
- pagingCookie: result.pagingCookie,
921
- };
922
- });
923
-
924
- setPagingState({
925
- currentPage: nextPage,
926
- pagingCookie: result.pagingCookie,
927
- moreRecords: result.moreRecords ?? false,
928
- isRetrieveAllInProgress: false,
929
- });
930
-
931
- console.log(`✅ Page ${nextPage} loaded: ${result.records.length} records`);
932
- } catch (error) {
933
- console.error("Failed to load more records:", error);
934
- } finally {
935
- setIsLoadingMore(false);
936
- }
937
- };
938
-
939
- /**
940
- * Export to Excel using Dataverse ExportToExcel API
941
- * Requires a saved view (system or personal) and prvExportToExcel privilege
942
- */
943
- const handleExport = async () => {
944
- // Check privilege first
945
- if (!exportStatus.hasPrivilege) {
946
- setExportStatus((prev) => ({
947
- ...prev,
948
- error:
949
- "You don't have permission to export to Excel. Contact your administrator to request the 'Export to Excel' privilege.",
950
- }));
951
- return;
952
- }
953
-
954
- // Check if we have a saved view
955
- if (!builder.loadedView) {
956
- setExportStatus((prev) => ({
957
- ...prev,
958
- error: "Export to Excel requires a saved view. Please save your query as a view first.",
959
- }));
960
- return;
961
- }
962
-
963
- if (!fetchXml || !layoutXml) {
964
- setExportStatus((prev) => ({
965
- ...prev,
966
- error: "Export to Excel requires FetchXML and LayoutXML",
967
- }));
968
- return;
969
- }
970
-
971
- // Clear any previous error and set exporting state
972
- setExportStatus((prev) => ({
973
- ...prev,
974
- isExporting: true,
975
- error: undefined,
976
- }));
977
-
978
- try {
979
- console.log(
980
- `📤 Exporting to Excel via ${builder.loadedView.type} view "${builder.loadedView.name}"...`
981
- );
982
-
983
- // Use view name for filename
984
- const viewName = builder.loadedView.name;
985
-
986
- const result = await exportToExcel(
987
- builder.loadedView.id,
988
- builder.loadedView.type,
989
- fetchXml,
990
- layoutXml,
991
- viewName
992
- );
993
-
994
- // Trigger download
995
- downloadBase64File(result.excelFile, result.filename);
996
-
997
- console.log(`✅ Export complete: ${result.filename}`);
998
-
999
- // Clear exporting state on success
1000
- setExportStatus((prev) => ({
1001
- ...prev,
1002
- isExporting: false,
1003
- }));
1004
- } catch (error) {
1005
- console.error("Export to Excel failed:", error);
1006
- setExportStatus((prev) => ({
1007
- ...prev,
1008
- isExporting: false,
1009
- error: error instanceof Error ? error.message : "Export to Excel failed. Please try again.",
1010
- }));
1011
- }
1012
- };
1013
-
1014
- /**
1015
- * Export to Excel locally using exceljs
1016
- * Exports the current result set with native Excel data types
1017
- * No view required - works directly with query results
1018
- */
1019
- const handleExportLocal = async () => {
1020
- if (!queryResult || !queryResult.rows.length) {
1021
- setExportStatus((prev) => ({
1022
- ...prev,
1023
- error: "No data to export. Execute a query first.",
1024
- }));
1025
- return;
1026
- }
1027
-
1028
- setExportStatus((prev) => ({
1029
- ...prev,
1030
- isExporting: true,
1031
- error: undefined,
1032
- }));
1033
-
1034
- try {
1035
- console.log(`📤 Exporting ${queryResult.rows.length} records locally...`);
1036
-
1037
- // Build column display names map
1038
- const columnDisplayNames = new Map<string, string>();
1039
-
1040
- // Use actual column names from queryResult (includes _value format for lookups)
1041
- const columns = queryResult.columns;
1042
-
1043
- for (const col of columns) {
1044
- // Try to get display name from attribute metadata
1045
- let displayName = col;
1046
-
1047
- if (attributeMetadata && entityLogicalName) {
1048
- const entityAttrs = attributeMetadata.get(entityLogicalName);
1049
- if (entityAttrs) {
1050
- // Direct attribute lookup
1051
- const attr = entityAttrs.get(col);
1052
- if (attr?.DisplayName?.UserLocalizedLabel?.Label) {
1053
- displayName = attr.DisplayName.UserLocalizedLabel.Label;
1054
- }
1055
- // Handle lookup columns (_primarycontactid_value -> primarycontactid)
1056
- if (col.startsWith("_") && col.endsWith("_value")) {
1057
- const baseAttr = col.slice(1, -6);
1058
- const lookupAttr = entityAttrs.get(baseAttr);
1059
- if (lookupAttr?.DisplayName?.UserLocalizedLabel?.Label) {
1060
- displayName = lookupAttr.DisplayName.UserLocalizedLabel.Label;
1061
- }
1062
- }
1063
- }
1064
- }
1065
-
1066
- columnDisplayNames.set(col, displayName);
1067
- }
1068
-
1069
- // Generate filename from entity or view name
1070
- const fileName =
1071
- builder.loadedView?.name ||
1072
- entityMetadata?.DisplayName?.UserLocalizedLabel?.Label ||
1073
- entityLogicalName ||
1074
- "export";
1075
-
1076
- const { buffer, fileName: finalFileName } = await exportToExcelLocal({
1077
- records: queryResult.rows,
1078
- columns,
1079
- columnDisplayNames,
1080
- attributeMetadata,
1081
- entityName: entityLogicalName,
1082
- fileName,
1083
- displaySettings,
1084
- });
1085
-
1086
- // Trigger download
1087
- downloadExcelFile(buffer, finalFileName);
1088
-
1089
- console.log(`✅ Local export complete: ${finalFileName}`);
1090
-
1091
- setExportStatus((prev) => ({
1092
- ...prev,
1093
- isExporting: false,
1094
- }));
1095
- } catch (error) {
1096
- console.error("Local export failed:", error);
1097
- setExportStatus((prev) => ({
1098
- ...prev,
1099
- isExporting: false,
1100
- error: error instanceof Error ? error.message : "Local export failed. Please try again.",
1101
- }));
1102
- }
1103
- };
1104
-
1105
- // Clear export error after 10 seconds
1106
- useEffect(() => {
1107
- if (exportStatus.error) {
1108
- const timer = setTimeout(() => {
1109
- setExportStatus((prev) => ({ ...prev, error: undefined }));
1110
- }, 10000);
1111
- return () => clearTimeout(timer);
1112
- }
1113
- }, [exportStatus.error]);
1114
-
1115
- // ============ RECORD ACTION HANDLERS ============
1116
-
1117
- /**
1118
- * Handle selection change from ResultsGrid
1119
- */
1120
- const handleSelectionChange = useCallback((recordIds: string[]) => {
1121
- setSelectedRecordIds(recordIds);
1122
- }, []);
1123
-
1124
- /**
1125
- * Get selected record IDs - called by command bar actions
1126
- */
1127
- const getSelectedRecordIds = useCallback(() => {
1128
- return selectedRecordIds;
1129
- }, [selectedRecordIds]);
1130
-
1131
- /**
1132
- * Open record in a new browser tab
1133
- */
1134
- const handleOpenRecord = useCallback(
1135
- async (recordIds: string[]) => {
1136
- if (recordIds.length === 0 || !entityLogicalName) return;
1137
-
1138
- const envUrl = await getEnvironmentUrl();
1139
- if (!envUrl) {
1140
- console.error("Failed to get environment URL");
1141
- return;
1142
- }
1143
-
1144
- // Open each selected record in a new tab
1145
- for (const recordId of recordIds) {
1146
- const url = buildRecordUrl(entityLogicalName, recordId, envUrl);
1147
- window.open(url, "_blank");
1148
- }
1149
- },
1150
- [entityLogicalName]
1151
- );
1152
-
1153
- /**
1154
- * Copy record URL(s) to clipboard
1155
- */
1156
- const handleCopyRecordUrl = useCallback(
1157
- async (recordIds: string[]) => {
1158
- if (recordIds.length === 0 || !entityLogicalName) return;
1159
-
1160
- const envUrl = await getEnvironmentUrl();
1161
- if (!envUrl) {
1162
- console.error("Failed to get environment URL");
1163
- return;
1164
- }
1165
-
1166
- const urls = recordIds.map((id) => buildRecordUrl(entityLogicalName, id, envUrl));
1167
- const textToCopy = urls.join("\n");
1168
-
1169
- try {
1170
- await navigator.clipboard.writeText(textToCopy);
1171
- console.log(`📋 Copied ${urls.length} record URL(s) to clipboard`);
1172
- } catch (error) {
1173
- console.error("Failed to copy to clipboard:", error);
1174
- }
1175
- },
1176
- [entityLogicalName]
1177
- );
1178
-
1179
- /**
1180
- * Open delete confirmation dialog
1181
- * Handles 3 scenarios:
1182
- * 1. No selection → Bulk delete ALL records from view (with warning)
1183
- * 2. 1-100 records → Batch DELETE requests (with progress)
1184
- * 3. >100 records → Bulk delete with FetchXML IN operator
1185
- */
1186
- const handleDeleteRecords = useCallback(
1187
- (recordIds: string[]) => {
1188
- if (!entityLogicalName) return;
1189
-
1190
- // Scenario 1: No selection - delete ALL records from view
1191
- if (recordIds.length === 0) {
1192
- if (!recordActionPrivileges.canBulkDelete) {
1193
- console.warn("Bulk delete privilege required to delete all records from view");
1194
- return;
1195
- }
1196
- const totalRecords = queryResult?.rows.length || 0;
1197
- setBulkDeleteDialogState({
1198
- open: true,
1199
- recordIds: [],
1200
- isAllRecords: true,
1201
- totalViewRecords: totalRecords,
1202
- });
1203
- return;
1204
- }
1205
-
1206
- // Scenario 3: More than 100 records - use bulk delete with IN operator
1207
- if (recordIds.length > 100 && recordActionPrivileges.canBulkDelete) {
1208
- setBulkDeleteDialogState({
1209
- open: true,
1210
- recordIds,
1211
- isAllRecords: false,
1212
- });
1213
- return;
1214
- }
1215
-
1216
- // Scenario 2: 1-100 records - use batch DELETE or direct delete
1217
- const recordName =
1218
- recordIds.length === 1
1219
- ? (queryResult?.rows.find(
1220
- (row) => row[entityMetadata?.PrimaryIdAttribute || ""] === recordIds[0]
1221
- )?.[entityMetadata?.PrimaryNameAttribute || ""] as string | undefined)
1222
- : undefined;
1223
-
1224
- // Use batch delete for 4+ records (more efficient than sequential)
1225
- const isBatchDelete = recordIds.length >= 4;
1226
- setDeleteDialogState({ open: true, recordIds, recordName, isBatchDelete });
1227
- },
1228
- [entityLogicalName, recordActionPrivileges.canBulkDelete, queryResult, entityMetadata]
1229
- );
1230
-
1231
- /**
1232
- * Explicitly trigger bulk delete dialog (from command bar menu)
1233
- * Bypasses the normal auto-routing logic and always uses bulk delete
1234
- */
1235
- const handleBulkDeleteRecords = useCallback(
1236
- (recordIds: string[]) => {
1237
- if (!entityLogicalName || !recordActionPrivileges.canBulkDelete) return;
1238
-
1239
- const isAllRecords = recordIds.length === 0;
1240
- const totalRecords = queryResult?.rows.length || 0;
1241
-
1242
- setBulkDeleteDialogState({
1243
- open: true,
1244
- recordIds,
1245
- isAllRecords,
1246
- totalViewRecords: isAllRecords ? totalRecords : undefined,
1247
- });
1248
- },
1249
- [entityLogicalName, recordActionPrivileges.canBulkDelete, queryResult]
1250
- );
1251
-
1252
- /**
1253
- * Execute delete for records (called from DeleteConfirmDialog)
1254
- * Supports both sequential delete (1-3 records) and batch delete (4-100 records)
1255
- */
1256
- const handleConfirmDelete = useCallback(async (): Promise<BatchDeleteResult | void> => {
1257
- if (!entityLogicalName || deleteDialogState.recordIds.length === 0) return;
1258
-
1259
- const recordIds = deleteDialogState.recordIds;
1260
- setDeleteProgress(null);
1261
-
1262
- // For batch delete (4+ records), use $batch endpoint
1263
- if (deleteDialogState.isBatchDelete) {
1264
- const result = await deleteRecordsBatch(
1265
- entityLogicalName, // Use logical name, API will pluralize
1266
- recordIds,
1267
- (progress) => setDeleteProgress(progress)
1268
- );
1269
-
1270
- if (result.succeeded > 0) {
1271
- console.log(`✅ Batch deleted ${result.succeeded} of ${recordIds.length} records`);
1272
- // Re-execute query to refresh results
1273
- handleExecute();
1274
- }
1275
-
1276
- return result;
1277
- }
1278
-
1279
- // Sequential delete for 1-3 records
1280
- let successCount = 0;
1281
- const errors: string[] = [];
1282
-
1283
- for (const recordId of recordIds) {
1284
- try {
1285
- await deleteRecord(entityLogicalName, recordId);
1286
- successCount++;
1287
- } catch (error) {
1288
- errors.push(
1289
- `Failed to delete ${recordId}: ${error instanceof Error ? error.message : String(error)}`
1290
- );
1291
- }
1292
- }
1293
-
1294
- if (successCount > 0) {
1295
- console.log(`✅ Deleted ${successCount} of ${recordIds.length} records`);
1296
- // Re-execute query to refresh results
1297
- handleExecute();
1298
- }
1299
-
1300
- if (errors.length > 0) {
1301
- console.error("Delete errors:", errors);
1302
- return { succeeded: successCount, failed: errors.length, errors };
1303
- }
1304
- }, [
1305
- entityLogicalName,
1306
- deleteDialogState.recordIds,
1307
- deleteDialogState.isBatchDelete,
1308
- handleExecute,
1309
- ]);
1310
-
1311
- /**
1312
- * Submit bulk delete job (called from BulkDeleteDialog)
1313
- * Supports both selected records (IN operator) and all records from view
1314
- */
1315
- const handleConfirmBulkDelete = useCallback(
1316
- async (jobName: string) => {
1317
- if (!entityLogicalName || !entityMetadata) {
1318
- throw new Error("Missing entity information for bulk delete");
1319
- }
1320
-
1321
- // If deleting ALL records from view (no selection)
1322
- if (bulkDeleteDialogState.isAllRecords) {
1323
- // Use the current FetchXML directly from builder state
1324
- if (!builder.fetchQuery) {
1325
- throw new Error("No FetchXML query available");
1326
- }
1327
- const currentFetchXml = generateFetchXml(builder.fetchQuery);
1328
- const result = await submitBulkDeleteFromFetchXml(currentFetchXml, jobName);
1329
- console.log(`📤 Bulk delete job (all records) submitted: ${result.asyncOperationId}`);
1330
- return result;
1331
- }
1332
-
1333
- // If deleting specific selected records
1334
- if (bulkDeleteDialogState.recordIds.length === 0) {
1335
- throw new Error("No records selected for bulk delete");
1336
- }
1337
-
1338
- const recordIds = bulkDeleteDialogState.recordIds;
1339
- const primaryIdAttribute = entityMetadata.PrimaryIdAttribute;
1340
-
1341
- const result = await submitBulkDelete(
1342
- entityLogicalName,
1343
- primaryIdAttribute,
1344
- recordIds,
1345
- jobName
1346
- );
1347
-
1348
- console.log(`📤 Bulk delete job submitted: ${result.asyncOperationId}`);
1349
- return result;
1350
- },
1351
- [
1352
- entityLogicalName,
1353
- entityMetadata,
1354
- bulkDeleteDialogState.recordIds,
1355
- bulkDeleteDialogState.isAllRecords,
1356
- builder.fetchQuery,
1357
- ]
1358
- );
1359
-
1360
- /**
1361
- * Run a specific workflow directly (from menu button)
1362
- * Opens the workflow dialog with the workflow pre-selected for confirmation
1363
- */
1364
- const handleRunSpecificWorkflow = useCallback((workflow: WorkflowInfo, recordIds: string[]) => {
1365
- if (recordIds.length === 0) return;
1366
- // Open the workflow dialog with the selected workflow pre-selected
1367
- setWorkflowDialogState({ open: true, recordIds, preSelectedWorkflow: workflow });
1368
- }, []);
1369
-
1370
- /**
1371
- * Fetch available workflows for the entity
1372
- */
1373
- const handleFetchWorkflows = useCallback(async (): Promise<WorkflowInfo[]> => {
1374
- if (!entityLogicalName) return [];
1375
- return getOnDemandWorkflows(entityLogicalName);
1376
- }, [entityLogicalName]);
1377
-
1378
- /**
1379
- * Execute workflow on selected records
1380
- */
1381
- const handleExecuteWorkflow = useCallback(
1382
- async (
1383
- workflowId: string,
1384
- recordIds: string[],
1385
- onProgress: (progress: WorkflowBatchProgress) => void
1386
- ): Promise<{ succeeded: number; failed: number; errors: string[] }> => {
1387
- if (!entityLogicalName) {
1388
- return { succeeded: 0, failed: recordIds.length, errors: ["Entity not selected"] };
1389
- }
1390
- return executeWorkflowBatch(workflowId, recordIds, entityLogicalName, onProgress);
1391
- },
1392
- [entityLogicalName]
1393
- );
1394
-
1395
- // ============ END RECORD ACTION HANDLERS ============
1396
-
1397
- return (
1398
- <div className={styles.root} data-app-root>
1399
- {/* Left Pane */}
1400
- <div className={styles.leftPane} data-left-pane style={{ width: `${leftPaneWidth}px` }}>
1401
- {/* Top: Tree View */}
1402
- <div className={styles.leftPaneTop} style={{ height: `${topHeight}%` }}>
1403
- <EntitySelector
1404
- selectedEntity={builder.fetchQuery?.entity.name || null}
1405
- onEntityChange={builder.setEntity}
1406
- onNewQuery={builder.newQuery}
1407
- onViewLoad={(viewInfo: LoadedViewInfo) => {
1408
- // Load the view's FetchXML into the tree while preserving view info
1409
- // Pass layoutxml for column configuration if available
1410
- builder.loadView(
1411
- viewInfo.originalFetchXml,
1412
- {
1413
- id: viewInfo.id,
1414
- type: viewInfo.type,
1415
- entitySetName: viewInfo.entitySetName,
1416
- name: viewInfo.name,
1417
- },
1418
- viewInfo.layoutxml
1419
- );
1420
- }}
1421
- />
1422
- {builder.fetchQuery ? (
1423
- <TreeView
1424
- fetchQuery={builder.fetchQuery}
1425
- selectedNodeId={builder.selectedNodeId}
1426
- onNodeSelect={builder.selectNode}
1427
- onAddAttribute={builder.addAttribute}
1428
- onAddAllAttributes={builder.addAllAttributes}
1429
- onAddOrder={builder.addOrder}
1430
- onAddFilter={builder.addFilter}
1431
- onAddSubfilter={builder.addSubfilter}
1432
- onAddCondition={builder.addCondition}
1433
- onAddLinkEntity={builder.addLinkEntity}
1434
- onRemoveNode={builder.removeNode}
1435
- />
1436
- ) : (
1437
- <div className={styles.placeholder}>Select an entity to begin</div>
1438
- )}
1439
- </div>
1440
- {/* Resize Handle */}
1441
- <div
1442
- className={styles.horizontalResizeHandle}
1443
- onMouseDown={() => setIsDraggingHorizontal(true)}
1444
- title="Drag to resize"
1445
- >
1446
- <span className={styles.horizontalGrip}>
1447
- <span>•</span>
1448
- <span>•</span>
1449
- <span>•</span>
1450
- </span>
1451
- </div>{" "}
1452
- {/* Bottom: Properties Panel */}
1453
- <div className={styles.leftPaneBottom} style={{ height: `${100 - topHeight}%` }}>
1454
- <div className={styles.toolbar}>
1455
- <span style={{ fontWeight: 600 }}>Properties</span>
1456
- </div>
1457
- <div className={styles.propertiesContent}>
1458
- <PropertiesPanel
1459
- selectedNode={builder.selectedNode}
1460
- fetchQuery={builder.fetchQuery}
1461
- onNodeUpdate={builder.updateNode}
1462
- />
1463
- </div>
1464
- </div>
1465
- </div>
1466
- {/* Vertical Resize Handle */}
1467
- <div
1468
- className={styles.verticalResizeHandle}
1469
- onMouseDown={() => setIsDraggingVertical(true)}
1470
- title="Drag to resize"
1471
- >
1472
- <span className={styles.verticalGrip}>
1473
- <span>•</span>
1474
- <span>•</span>
1475
- <span>•</span>
1476
- </span>
1477
- </div>{" "}
1478
- {/* Right Pane */}
1479
- <div className={styles.rightPane}>
1480
- <PreviewTabs
1481
- xml={fetchXml}
1482
- layoutXml={layoutXml}
1483
- result={queryResult}
1484
- isExecuting={isExecuting}
1485
- isLoadingMore={isLoadingMore}
1486
- onExecute={handleExecute}
1487
- onExport={handleExport}
1488
- onExportLocal={handleExportLocal}
1489
- canExport={!!builder.loadedView && exportStatus.hasPrivilege}
1490
- isExporting={exportStatus.isExporting}
1491
- exportError={exportStatus.error}
1492
- onDismissExportError={() => setExportStatus((prev) => ({ ...prev, error: undefined }))}
1493
- exportDisabledReason={
1494
- !builder.loadedView
1495
- ? "Save as a view first to enable export"
1496
- : !exportStatus.hasPrivilege
1497
- ? "You don't have the prvExportToExcel privilege"
1498
- : undefined
1499
- }
1500
- onParseToTree={builder.loadFetchXml}
1501
- attributeMetadata={attributeMetadata}
1502
- fetchQuery={builder.fetchQuery}
1503
- columnConfig={builder.columnConfig}
1504
- onColumnResize={builder.updateColumnWidth}
1505
- onLoadMore={handleLoadMore}
1506
- entityDisplayName={
1507
- entityMetadata?.DisplayName?.UserLocalizedLabel?.Label || entityMetadata?.LogicalName
1508
- }
1509
- lookupRelationships={lookupRelationships}
1510
- oneToManyRelationships={oneToManyRelationships}
1511
- isLoadingRelationships={isLoadingRelationships}
1512
- onLoadRelatedAttributes={loadAttributes}
1513
- saveViewButton={
1514
- <SaveViewButton
1515
- fetchXml={fetchXml}
1516
- layoutXml={layoutXml}
1517
- entityLogicalName={builder.fetchQuery?.entity?.name || ""}
1518
- objectTypeCode={entityMetadata?.ObjectTypeCode || 0}
1519
- primaryIdAttribute={entityMetadata?.PrimaryIdAttribute || ""}
1520
- loadedView={builder.loadedView}
1521
- onSaveComplete={handleSaveViewComplete}
1522
- disabled={!fetchXml || !entityMetadata}
1523
- />
1524
- }
1525
- onReorderColumns={(columns) => {
1526
- // Set the column config with new order
1527
- if (builder.columnConfig) {
1528
- builder.setColumnConfig({
1529
- ...builder.columnConfig,
1530
- columns,
1531
- });
1532
- }
1533
- }}
1534
- onRemoveColumn={(columnName) => {
1535
- // Remove the attribute from the FetchXML
1536
- if (builder.fetchQuery?.entity.id) {
1537
- // Find the attribute node by name and remove it
1538
- const attr = builder.fetchQuery.entity.attributes.find((a) => a.name === columnName);
1539
- if (attr) {
1540
- builder.removeNode(attr.id);
1541
- }
1542
- }
1543
- }}
1544
- onAddColumn={(attributeName) => {
1545
- // Add the attribute to the root entity
1546
- if (builder.fetchQuery?.entity.id) {
1547
- builder.addAttributeByName(builder.fetchQuery.entity.id, attributeName);
1548
- }
1549
- }}
1550
- onAddRelatedColumns={(relatedColumns: RelatedEntityColumn[]) => {
1551
- // Add related entity columns - this requires creating link-entities
1552
- if (!builder.fetchQuery?.entity.id) return;
1553
-
1554
- // Group by relationship to create link-entities
1555
- const byRelationship = new Map<string, RelatedEntityColumn[]>();
1556
- for (const col of relatedColumns) {
1557
- const key = col.relationship.SchemaName;
1558
- if (!byRelationship.has(key)) {
1559
- byRelationship.set(key, []);
1560
- }
1561
- byRelationship.get(key)!.push(col);
1562
- }
1563
-
1564
- // For each relationship, find or create link-entity and add attributes
1565
- for (const [, cols] of byRelationship) {
1566
- const rel = cols[0].relationship;
1567
- const relType = cols[0].relationshipType;
1568
-
1569
- let fromAttr: string;
1570
- let toAttr: string;
1571
- let relatedEntity: string;
1572
- let linkType: "inner" | "outer";
1573
- let relTypeForBuilder: "N1" | "1N";
1574
-
1575
- if (relType === "N1") {
1576
- // For N:1 (Many-to-One) lookup relationships:
1577
- // - ReferencingAttribute = FK on current entity (e.g., primarycontactid on account)
1578
- // - ReferencedAttribute = PK on related entity (e.g., contactid on contact)
1579
- // In FetchXML link-entity:
1580
- // - from = attribute on the LINKED entity (the PK)
1581
- // - to = attribute on the PARENT entity (the FK)
1582
- fromAttr = rel.ReferencedAttribute; // PK on linked (contact.contactid)
1583
- toAttr = rel.ReferencingAttribute; // FK on parent (account.primarycontactid)
1584
- relatedEntity = rel.ReferencedEntity; // contact
1585
- linkType = "outer"; // N:1 typically use outer join to not filter parent records
1586
- relTypeForBuilder = "N1";
1587
- } else {
1588
- // For 1:N (One-to-Many) relationships:
1589
- // - ReferencingEntity = the related entity (the "many" side that has the FK)
1590
- // - ReferencedEntity = the current entity (the "one" side)
1591
- // - ReferencingAttribute = FK on the related entity
1592
- // - ReferencedAttribute = PK on the current entity
1593
- // In FetchXML link-entity:
1594
- // - from = FK on the LINKED entity (the FK to parent)
1595
- // - to = PK on the PARENT entity
1596
- fromAttr = rel.ReferencingAttribute; // FK on linked entity
1597
- toAttr = rel.ReferencedAttribute; // PK on parent (e.g., accountid)
1598
- relatedEntity = rel.ReferencingEntity; // The related entity (e.g., contact)
1599
- linkType = "outer"; // 1:N use outer join to include parent records without children
1600
- relTypeForBuilder = "1N";
1601
- }
1602
-
1603
- // Check if link-entity already exists for this relationship
1604
- const existingLinkEntity = builder.fetchQuery.entity.links.find(
1605
- (le) => le.from === fromAttr && le.to === toAttr && le.name === relatedEntity
1606
- );
1607
-
1608
- let linkEntityId: string;
1609
-
1610
- if (existingLinkEntity) {
1611
- linkEntityId = existingLinkEntity.id;
1612
- } else {
1613
- // Create the link-entity with config - returns the ID immediately
1614
- linkEntityId = builder.addLinkEntityWithConfig(
1615
- builder.fetchQuery.entity.id,
1616
- relatedEntity,
1617
- fromAttr,
1618
- toAttr,
1619
- linkType,
1620
- relTypeForBuilder
1621
- );
1622
- }
1623
-
1624
- // Add attributes to the link-entity using the ID
1625
- for (const col of cols) {
1626
- builder.addAttributeByName(linkEntityId, col.attribute.LogicalName);
1627
- }
1628
- }
1629
- }}
1630
- onSortChange={(data) => {
1631
- // Extract attribute name from columnId (may have entityName prefix or be an alias)
1632
- let attribute = data.columnId;
1633
- let entityName = data.entityName;
1634
-
1635
- // If columnId contains a dot, it's a link-entity column (alias.attribute)
1636
- if (attribute.includes(".")) {
1637
- const parts = attribute.split(".");
1638
- entityName = parts[0];
1639
- attribute = parts[1];
1640
- }
1641
-
1642
- // Handle lookup field naming (_attributename_value -> attributename)
1643
- if (attribute.startsWith("_") && attribute.endsWith("_value")) {
1644
- attribute = attribute.slice(1, -6);
1645
- }
1646
-
1647
- // Check if this is an aliased column on root entity - need to find original attribute name
1648
- // FetchXML order-by uses the original attribute name, not the alias
1649
- if (!entityName && builder.fetchQuery?.entity?.attributes) {
1650
- const aliasedAttr = builder.fetchQuery.entity.attributes.find(
1651
- (a) => a.alias === attribute
1652
- );
1653
- if (aliasedAttr) {
1654
- attribute = aliasedAttr.name;
1655
- }
1656
- }
1657
-
1658
- builder.setSort(
1659
- attribute,
1660
- data.direction === "descending",
1661
- data.isMultiSort,
1662
- entityName
1663
- );
1664
- }}
1665
- // Record action handlers
1666
- onOpenRecord={handleOpenRecord}
1667
- onCopyRecordUrl={handleCopyRecordUrl}
1668
- onDeleteRecords={handleDeleteRecords}
1669
- onBulkDeleteRecords={handleBulkDeleteRecords}
1670
- onRunSpecificWorkflow={handleRunSpecificWorkflow}
1671
- canDelete={recordActionPrivileges.canDelete}
1672
- canBulkDelete={recordActionPrivileges.canBulkDelete}
1673
- canRunWorkflow={recordActionPrivileges.canRunWorkflow}
1674
- onFetchWorkflows={handleFetchWorkflows}
1675
- isAggregateQuery={isAggregateQuery}
1676
- hasOneToManyRelationship={hasOneToManyRelationship}
1677
- onSelectionChange={handleSelectionChange}
1678
- getSelectedRecordIds={getSelectedRecordIds}
1679
- onOpenSettings={() => setSettingsDrawerOpen(true)}
1680
- displaySettings={displaySettings}
1681
- />
1682
- </div>
1683
- {/* Delete Confirmation Dialog */}
1684
- <DeleteConfirmDialog
1685
- open={deleteDialogState.open}
1686
- recordName={deleteDialogState.recordName}
1687
- entityDisplayName={
1688
- entityMetadata?.DisplayName?.UserLocalizedLabel?.Label || entityLogicalName || "record"
1689
- }
1690
- recordCount={deleteDialogState.recordIds.length}
1691
- onClose={() => {
1692
- setDeleteDialogState({ open: false, recordIds: [] });
1693
- setDeleteProgress(null);
1694
- }}
1695
- onConfirm={handleConfirmDelete}
1696
- onProgress={deleteProgress ?? undefined}
1697
- />
1698
- {/* Bulk Delete Dialog */}
1699
- <BulkDeleteDialog
1700
- open={bulkDeleteDialogState.open}
1701
- recordCount={bulkDeleteDialogState.recordIds.length}
1702
- entityDisplayName={
1703
- entityMetadata?.DisplayName?.UserLocalizedLabel?.Label || entityLogicalName || "records"
1704
- }
1705
- isAllRecords={bulkDeleteDialogState.isAllRecords}
1706
- totalViewRecords={bulkDeleteDialogState.totalViewRecords}
1707
- onClose={() => setBulkDeleteDialogState({ open: false, recordIds: [] })}
1708
- onConfirm={handleConfirmBulkDelete}
1709
- />
1710
- {/* Workflow Picker Dialog */}
1711
- <WorkflowPickerDialog
1712
- open={workflowDialogState.open}
1713
- recordCount={workflowDialogState.recordIds.length}
1714
- entityDisplayName={
1715
- entityMetadata?.DisplayName?.UserLocalizedLabel?.Label || entityLogicalName || "records"
1716
- }
1717
- preSelectedWorkflow={workflowDialogState.preSelectedWorkflow}
1718
- onClose={() => setWorkflowDialogState({ open: false, recordIds: [] })}
1719
- onFetchWorkflows={handleFetchWorkflows}
1720
- onExecute={(workflow, onProgress) =>
1721
- handleExecuteWorkflow(workflow.workflowid, workflowDialogState.recordIds, onProgress)
1722
- }
1723
- />
1724
- {/* Settings Drawer */}
1725
- <SettingsDrawer
1726
- open={settingsDrawerOpen}
1727
- settings={displaySettings}
1728
- onClose={() => setSettingsDrawerOpen(false)}
1729
- onSettingsChange={setDisplaySettings}
1730
- />
1731
- </div>
1732
- );
1733
- }