@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.
- package/CHANGELOG.md +120 -0
- package/README.md +250 -0
- package/eslint.config.js +23 -0
- package/index.html +71 -0
- package/package.json +53 -0
- package/public/vite.svg +1 -0
- package/src/app/AppShell.tsx +1733 -0
- package/src/assets/20px-Microsoft_Office_Excel_(2025/342/200/223present).svg.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/features/fetchxml/api/dataverseMetadata.ts +192 -0
- package/src/features/fetchxml/api/excelExport.ts +318 -0
- package/src/features/fetchxml/api/formattedValues.ts +107 -0
- package/src/features/fetchxml/api/pptbClient.ts +2635 -0
- package/src/features/fetchxml/model/displaySettings.ts +18 -0
- package/src/features/fetchxml/model/fetchxml.ts +346 -0
- package/src/features/fetchxml/model/fetchxmlIntellisense.ts +583 -0
- package/src/features/fetchxml/model/fetchxmlParser.ts +708 -0
- package/src/features/fetchxml/model/layoutxml.ts +372 -0
- package/src/features/fetchxml/model/nodes.ts +255 -0
- package/src/features/fetchxml/model/operators.ts +624 -0
- package/src/features/fetchxml/model/treeUtils.ts +174 -0
- package/src/features/fetchxml/state/builderStore.tsx +1160 -0
- package/src/features/fetchxml/state/cache.ts +393 -0
- package/src/features/fetchxml/ui/Dialogs/BulkDeleteDialog.tsx +352 -0
- package/src/features/fetchxml/ui/Dialogs/DeleteConfirmDialog.tsx +396 -0
- package/src/features/fetchxml/ui/Dialogs/SaveViewDialog.tsx +612 -0
- package/src/features/fetchxml/ui/Dialogs/SolutionPicker.tsx +114 -0
- package/src/features/fetchxml/ui/Dialogs/WorkflowPickerDialog.tsx +437 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/AttributeEditor.tsx +214 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ConditionEditor.tsx +533 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FetchOptionsEditor.tsx +296 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FilterEditor.tsx +106 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/LinkEntityEditor.tsx +433 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/OrderEditor.tsx +161 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ValueOfPicker.tsx +194 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel.tsx +225 -0
- package/src/features/fetchxml/ui/LeftPane/TreeView.tsx +604 -0
- package/src/features/fetchxml/ui/RightPane/AddColumnsPanel.tsx +800 -0
- package/src/features/fetchxml/ui/RightPane/ColumnConfigDialog.tsx +170 -0
- package/src/features/fetchxml/ui/RightPane/DataGridCellRenderers.tsx +356 -0
- package/src/features/fetchxml/ui/RightPane/EditColumnsPanel.tsx +372 -0
- package/src/features/fetchxml/ui/RightPane/FetchXmlEditor.tsx +399 -0
- package/src/features/fetchxml/ui/RightPane/FormattedValueUtils.ts +189 -0
- package/src/features/fetchxml/ui/RightPane/LayoutXmlViewer.tsx +171 -0
- package/src/features/fetchxml/ui/RightPane/PreviewTabs.tsx +471 -0
- package/src/features/fetchxml/ui/RightPane/ResultsCommandBar.tsx +430 -0
- package/src/features/fetchxml/ui/RightPane/ResultsGrid.tsx +983 -0
- package/src/features/fetchxml/ui/RightPane/XmlTextArea.tsx +65 -0
- package/src/features/fetchxml/ui/Settings/SettingsDrawer.tsx +175 -0
- package/src/features/fetchxml/ui/Toolbar/EntitySelector.tsx +788 -0
- package/src/features/fetchxml/ui/Toolbar/LoadViewPicker.tsx +274 -0
- package/src/features/fetchxml/ui/Toolbar/SaveViewButton.tsx +174 -0
- package/src/main.tsx +11 -0
- package/src/shared/components/AttributePicker.tsx +138 -0
- package/src/shared/components/BetweenInputPicker.tsx +120 -0
- package/src/shared/components/BooleanValuePicker.tsx +165 -0
- package/src/shared/components/DateTimeInputPicker.tsx +170 -0
- package/src/shared/components/DynamicValueList.tsx +128 -0
- package/src/shared/components/ExcelIcon.tsx +30 -0
- package/src/shared/components/MultiValuePicker.tsx +178 -0
- package/src/shared/components/NumericInputPicker.tsx +156 -0
- package/src/shared/components/OptionSetValuePicker.tsx +150 -0
- package/src/shared/components/RelationshipPicker.tsx +268 -0
- package/src/shared/contexts/ThemeContext.tsx +19 -0
- package/src/shared/hooks/useAccessMode.ts +77 -0
- package/src/shared/hooks/useLazyMetadata.ts +132 -0
- package/src/shared/hooks/usePptbContext.ts +170 -0
- package/src/shared/hooks/usePublisherFilter.ts +214 -0
- package/src/shared/hooks/useSolutionFilter.ts +162 -0
- package/src/shared/utils/debug.ts +87 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +7 -0
|
@@ -0,0 +1,1733 @@
|
|
|
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
|
+
}
|