@mohsinonxrm/pptb-fetchxml-studio 1.0.0 → 1.0.1
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/LICENSE +661 -0
- package/dist/assets/index-DD9fzkbz.js +232 -0
- package/dist/index.html +70 -0
- package/index.html +0 -1
- package/npm-shrinkwrap.json +6329 -0
- package/package.json +95 -51
- package/CHANGELOG.md +0 -120
- package/eslint.config.js +0 -23
- package/public/vite.svg +0 -1
- package/src/app/AppShell.tsx +0 -1733
- package/src/assets/20px-Microsoft_Office_Excel_(2025/342/200/223present).svg.png +0 -0
- package/src/assets/react.svg +0 -1
- package/src/features/fetchxml/api/dataverseMetadata.ts +0 -192
- package/src/features/fetchxml/api/excelExport.ts +0 -318
- package/src/features/fetchxml/api/formattedValues.ts +0 -107
- package/src/features/fetchxml/api/pptbClient.ts +0 -2635
- package/src/features/fetchxml/model/displaySettings.ts +0 -18
- package/src/features/fetchxml/model/fetchxml.ts +0 -346
- package/src/features/fetchxml/model/fetchxmlIntellisense.ts +0 -583
- package/src/features/fetchxml/model/fetchxmlParser.ts +0 -708
- package/src/features/fetchxml/model/layoutxml.ts +0 -372
- package/src/features/fetchxml/model/nodes.ts +0 -255
- package/src/features/fetchxml/model/operators.ts +0 -624
- package/src/features/fetchxml/model/treeUtils.ts +0 -174
- package/src/features/fetchxml/state/builderStore.tsx +0 -1160
- package/src/features/fetchxml/state/cache.ts +0 -393
- package/src/features/fetchxml/ui/Dialogs/BulkDeleteDialog.tsx +0 -352
- package/src/features/fetchxml/ui/Dialogs/DeleteConfirmDialog.tsx +0 -396
- package/src/features/fetchxml/ui/Dialogs/SaveViewDialog.tsx +0 -612
- package/src/features/fetchxml/ui/Dialogs/SolutionPicker.tsx +0 -114
- package/src/features/fetchxml/ui/Dialogs/WorkflowPickerDialog.tsx +0 -437
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/AttributeEditor.tsx +0 -214
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ConditionEditor.tsx +0 -533
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FetchOptionsEditor.tsx +0 -296
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FilterEditor.tsx +0 -106
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/LinkEntityEditor.tsx +0 -433
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/OrderEditor.tsx +0 -161
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ValueOfPicker.tsx +0 -194
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel.tsx +0 -225
- package/src/features/fetchxml/ui/LeftPane/TreeView.tsx +0 -604
- package/src/features/fetchxml/ui/RightPane/AddColumnsPanel.tsx +0 -800
- package/src/features/fetchxml/ui/RightPane/ColumnConfigDialog.tsx +0 -170
- package/src/features/fetchxml/ui/RightPane/DataGridCellRenderers.tsx +0 -356
- package/src/features/fetchxml/ui/RightPane/EditColumnsPanel.tsx +0 -372
- package/src/features/fetchxml/ui/RightPane/FetchXmlEditor.tsx +0 -399
- package/src/features/fetchxml/ui/RightPane/FormattedValueUtils.ts +0 -189
- package/src/features/fetchxml/ui/RightPane/LayoutXmlViewer.tsx +0 -171
- package/src/features/fetchxml/ui/RightPane/PreviewTabs.tsx +0 -471
- package/src/features/fetchxml/ui/RightPane/ResultsCommandBar.tsx +0 -430
- package/src/features/fetchxml/ui/RightPane/ResultsGrid.tsx +0 -983
- package/src/features/fetchxml/ui/RightPane/XmlTextArea.tsx +0 -65
- package/src/features/fetchxml/ui/Settings/SettingsDrawer.tsx +0 -175
- package/src/features/fetchxml/ui/Toolbar/EntitySelector.tsx +0 -788
- package/src/features/fetchxml/ui/Toolbar/LoadViewPicker.tsx +0 -274
- package/src/features/fetchxml/ui/Toolbar/SaveViewButton.tsx +0 -174
- package/src/main.tsx +0 -11
- package/src/shared/components/AttributePicker.tsx +0 -138
- package/src/shared/components/BetweenInputPicker.tsx +0 -120
- package/src/shared/components/BooleanValuePicker.tsx +0 -165
- package/src/shared/components/DateTimeInputPicker.tsx +0 -170
- package/src/shared/components/DynamicValueList.tsx +0 -128
- package/src/shared/components/ExcelIcon.tsx +0 -30
- package/src/shared/components/MultiValuePicker.tsx +0 -178
- package/src/shared/components/NumericInputPicker.tsx +0 -156
- package/src/shared/components/OptionSetValuePicker.tsx +0 -150
- package/src/shared/components/RelationshipPicker.tsx +0 -268
- package/src/shared/contexts/ThemeContext.tsx +0 -19
- package/src/shared/hooks/useAccessMode.ts +0 -77
- package/src/shared/hooks/useLazyMetadata.ts +0 -132
- package/src/shared/hooks/usePptbContext.ts +0 -170
- package/src/shared/hooks/usePublisherFilter.ts +0 -214
- package/src/shared/hooks/useSolutionFilter.ts +0 -162
- package/src/shared/utils/debug.ts +0 -87
- package/tsconfig.app.json +0 -28
- package/tsconfig.json +0 -7
- package/tsconfig.node.json +0 -26
- package/vite.config.ts +0 -7
|
@@ -1,2635 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PPTB Dataverse API client wrapper
|
|
3
|
-
* Provides typed interface to window.dataverseAPI exposed by PPTB host
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// Extend window interface for PPTB Dataverse API
|
|
7
|
-
declare global {
|
|
8
|
-
interface Window {
|
|
9
|
-
dataverseAPI?: {
|
|
10
|
-
// CRUD operations
|
|
11
|
-
create: (entitySetName: string, data: Record<string, unknown>) => Promise<{ id: string }>;
|
|
12
|
-
retrieve: (
|
|
13
|
-
entitySetName: string,
|
|
14
|
-
id: string,
|
|
15
|
-
select?: string[]
|
|
16
|
-
) => Promise<Record<string, unknown>>;
|
|
17
|
-
update: (entitySetName: string, id: string, data: Record<string, unknown>) => Promise<void>;
|
|
18
|
-
delete: (entitySetName: string, id: string) => Promise<void>;
|
|
19
|
-
|
|
20
|
-
// Query operations
|
|
21
|
-
fetchXmlQuery: (fetchXml: string) => Promise<{
|
|
22
|
-
value: Record<string, unknown>[];
|
|
23
|
-
"@Microsoft.Dynamics.CRM.totalrecordcount"?: number;
|
|
24
|
-
"@Microsoft.Dynamics.CRM.totalrecordcountlimitexceeded"?: boolean;
|
|
25
|
-
"@Microsoft.Dynamics.CRM.morerecords"?: boolean;
|
|
26
|
-
"@Microsoft.Dynamics.CRM.pagingcookie"?: string;
|
|
27
|
-
}>;
|
|
28
|
-
retrieveMultiple: (
|
|
29
|
-
entitySetName: string,
|
|
30
|
-
query?: string
|
|
31
|
-
) => Promise<{
|
|
32
|
-
value: Record<string, unknown>[];
|
|
33
|
-
}>;
|
|
34
|
-
queryData: (odataQuery: string) => Promise<{
|
|
35
|
-
value: Record<string, unknown>[];
|
|
36
|
-
}>;
|
|
37
|
-
|
|
38
|
-
// Metadata operations
|
|
39
|
-
getEntityMetadata: (
|
|
40
|
-
entityLogicalName: string,
|
|
41
|
-
searchByLogicalName?: boolean,
|
|
42
|
-
selectColumns?: string[]
|
|
43
|
-
) => Promise<EntityMetadata>;
|
|
44
|
-
getAllEntitiesMetadata: () => Promise<{ value: EntityMetadata[] }>;
|
|
45
|
-
getEntityRelatedMetadata: (
|
|
46
|
-
entityLogicalName: string,
|
|
47
|
-
relatedPath: string,
|
|
48
|
-
selectColumns?: string[]
|
|
49
|
-
) => Promise<{ value: unknown[] }>;
|
|
50
|
-
|
|
51
|
-
// Other operations
|
|
52
|
-
execute: (options: {
|
|
53
|
-
operationName: string;
|
|
54
|
-
operationType: "action" | "function";
|
|
55
|
-
entityName?: string;
|
|
56
|
-
entityId?: string;
|
|
57
|
-
parameters?: Record<string, unknown>;
|
|
58
|
-
}) => Promise<unknown>;
|
|
59
|
-
getSolutions: (selectColumns?: string[]) => Promise<{ value: unknown[] }>;
|
|
60
|
-
|
|
61
|
-
// User context - note: WhoAmI is called via execute({ operationName: 'WhoAmI', operationType: 'function' })
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
import { debugLog } from "../../../shared/utils/debug";
|
|
67
|
-
import { responseHasFormattedValues } from "./formattedValues";
|
|
68
|
-
import { metadataCache } from "../state/cache";
|
|
69
|
-
|
|
70
|
-
export interface EntityMetadata {
|
|
71
|
-
LogicalName: string;
|
|
72
|
-
SchemaName: string;
|
|
73
|
-
DisplayName?: { UserLocalizedLabel?: { Label?: string } };
|
|
74
|
-
EntitySetName: string;
|
|
75
|
-
PrimaryIdAttribute: string;
|
|
76
|
-
PrimaryNameAttribute: string;
|
|
77
|
-
IsValidForAdvancedFind?: boolean;
|
|
78
|
-
MetadataId: string;
|
|
79
|
-
ObjectTypeCode: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface AttributeMetadata {
|
|
83
|
-
LogicalName: string;
|
|
84
|
-
SchemaName: string;
|
|
85
|
-
DisplayName?: { UserLocalizedLabel?: { Label?: string } };
|
|
86
|
-
AttributeType: string;
|
|
87
|
-
AttributeTypeName?: { Value?: string };
|
|
88
|
-
MetadataId: string;
|
|
89
|
-
IsValidForAdvancedFind?: { Value: boolean };
|
|
90
|
-
MaxLength?: number;
|
|
91
|
-
Precision?: number;
|
|
92
|
-
// For Integer, BigInt, Decimal, Double attributes
|
|
93
|
-
MinValue?: number;
|
|
94
|
-
MaxValue?: number;
|
|
95
|
-
// For DateTime attributes
|
|
96
|
-
Format?: "DateOnly" | "DateAndTime";
|
|
97
|
-
DateTimeBehavior?: { Value?: string }; // Can be "UserLocal", "DateOnly", "TimeZoneIndependent"
|
|
98
|
-
MinSupportedValue?: string; // ISO date string
|
|
99
|
-
MaxSupportedValue?: string; // ISO date string
|
|
100
|
-
// For Picklist, State, Status attributes
|
|
101
|
-
OptionSet?: {
|
|
102
|
-
Options?: Array<{
|
|
103
|
-
Value: number;
|
|
104
|
-
Label: { UserLocalizedLabel?: { Label?: string } };
|
|
105
|
-
}>;
|
|
106
|
-
// For Boolean/TwoOptions attributes
|
|
107
|
-
TrueOption?: {
|
|
108
|
-
Value: number;
|
|
109
|
-
Label: { UserLocalizedLabel?: { Label?: string } };
|
|
110
|
-
};
|
|
111
|
-
FalseOption?: {
|
|
112
|
-
Value: number;
|
|
113
|
-
Label: { UserLocalizedLabel?: { Label?: string } };
|
|
114
|
-
};
|
|
115
|
-
};
|
|
116
|
-
Targets?: string[]; // For Lookup attributes
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export interface RelationshipMetadata {
|
|
120
|
-
SchemaName: string;
|
|
121
|
-
RelationshipType: "OneToManyRelationship" | "ManyToOneRelationship" | "ManyToManyRelationship";
|
|
122
|
-
ReferencedEntity: string;
|
|
123
|
-
ReferencedAttribute: string;
|
|
124
|
-
ReferencingEntity: string;
|
|
125
|
-
ReferencingAttribute: string;
|
|
126
|
-
MetadataId: string;
|
|
127
|
-
IsValidForAdvancedFind?: boolean;
|
|
128
|
-
IsCustomRelationship?: boolean;
|
|
129
|
-
// N-N specific properties
|
|
130
|
-
Entity1LogicalName?: string;
|
|
131
|
-
Entity2LogicalName?: string;
|
|
132
|
-
Entity1IntersectAttribute?: string;
|
|
133
|
-
Entity2IntersectAttribute?: string;
|
|
134
|
-
IntersectEntityName?: string;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export interface FetchXmlResult {
|
|
138
|
-
records: Record<string, unknown>[];
|
|
139
|
-
totalRecordCount?: number;
|
|
140
|
-
totalRecordCountLimitExceeded?: boolean;
|
|
141
|
-
moreRecords?: boolean;
|
|
142
|
-
pagingCookie?: string;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export interface WhoAmIResponse {
|
|
146
|
-
UserId: string;
|
|
147
|
-
BusinessUnitId: string;
|
|
148
|
-
OrganizationId: string;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export interface PrivilegeCheckResponse {
|
|
152
|
-
RolePrivileges: Array<{
|
|
153
|
-
Depth: "Basic" | "Local" | "Deep" | "Global";
|
|
154
|
-
PrivilegeId: string;
|
|
155
|
-
BusinessUnitId: string;
|
|
156
|
-
PrivilegeName: string;
|
|
157
|
-
}>;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export interface Publisher {
|
|
161
|
-
publisherid: string;
|
|
162
|
-
friendlyname: string;
|
|
163
|
-
uniquename: string;
|
|
164
|
-
customizationprefix: string;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export interface Solution {
|
|
168
|
-
solutionid: string;
|
|
169
|
-
friendlyname: string;
|
|
170
|
-
uniquename: string;
|
|
171
|
-
solutionpackageversion?: string;
|
|
172
|
-
version?: string;
|
|
173
|
-
isvisible?: boolean;
|
|
174
|
-
ismanaged?: boolean;
|
|
175
|
-
_publisherid_value?: string;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export interface PublisherWithSolutions {
|
|
179
|
-
publisher: Publisher;
|
|
180
|
-
solutions: Solution[];
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export interface SolutionComponent {
|
|
184
|
-
msdyn_name: string; // Entity logical name
|
|
185
|
-
msdyn_displayname?: string;
|
|
186
|
-
msdyn_logicalcollectionname?: string;
|
|
187
|
-
msdyn_solutionid: string;
|
|
188
|
-
msdyn_componenttype: number;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export interface AccessSummary {
|
|
192
|
-
userId: string;
|
|
193
|
-
canReadPublisher: boolean;
|
|
194
|
-
canReadSolution: boolean;
|
|
195
|
-
canReadCustomization: boolean;
|
|
196
|
-
fullFilterMode: boolean; // all 3 privileges
|
|
197
|
-
solutionsOnlyMode: boolean; // customization + solution, no publisher
|
|
198
|
-
publishersOnlyMode: boolean; // customization + publisher, no solution
|
|
199
|
-
metadataOnlyMode: boolean; // customization only
|
|
200
|
-
noAccessMode: boolean; // no customization privilege
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Check if PPTB Dataverse API is available
|
|
205
|
-
*/
|
|
206
|
-
export function isDataverseAvailable(): boolean {
|
|
207
|
-
return typeof window !== "undefined" && !!window.dataverseAPI;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Get all entities metadata (filtered for Advanced Find if specified)
|
|
212
|
-
*/
|
|
213
|
-
export async function getAllEntities(advancedFindOnly: boolean = true): Promise<EntityMetadata[]> {
|
|
214
|
-
if (!isDataverseAvailable()) {
|
|
215
|
-
const error = new Error("PPTB Dataverse API not available");
|
|
216
|
-
console.error("pptbClient.getAllEntities:", error.message);
|
|
217
|
-
throw error;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
try {
|
|
221
|
-
// Build OData query with server-side filtering
|
|
222
|
-
let query =
|
|
223
|
-
"EntityDefinitions?$select=LogicalName,SchemaName,DisplayName,EntitySetName,PrimaryIdAttribute,PrimaryNameAttribute,IsValidForAdvancedFind,MetadataId,ObjectTypeCode";
|
|
224
|
-
|
|
225
|
-
// Add server-side filter for IsValidForAdvancedFind
|
|
226
|
-
if (advancedFindOnly) {
|
|
227
|
-
query += "&$filter=IsValidForAdvancedFind eq true";
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
debugLog("metadataAPI", `📡 GET Entities: ${query}`);
|
|
231
|
-
|
|
232
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
233
|
-
|
|
234
|
-
const entities = result.value as unknown as EntityMetadata[];
|
|
235
|
-
|
|
236
|
-
debugLog("metadataAPI", `✅ Entities retrieved: ${entities.length} entities`);
|
|
237
|
-
|
|
238
|
-
// Sort alphabetically by display name (fallback to logical name)
|
|
239
|
-
// Must be client-side - $orderby not supported for metadata queries
|
|
240
|
-
entities.sort((a, b) => {
|
|
241
|
-
const nameA = a.DisplayName?.UserLocalizedLabel?.Label || a.LogicalName;
|
|
242
|
-
const nameB = b.DisplayName?.UserLocalizedLabel?.Label || b.LogicalName;
|
|
243
|
-
return nameA.localeCompare(nameB);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
return entities;
|
|
247
|
-
} catch (error) {
|
|
248
|
-
console.error("pptbClient.getAllEntities: API call failed:", error);
|
|
249
|
-
throw error;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Get entity metadata by logical name
|
|
255
|
-
*/
|
|
256
|
-
export async function getEntityMetadata(logicalName: string): Promise<EntityMetadata> {
|
|
257
|
-
if (!isDataverseAvailable()) {
|
|
258
|
-
throw new Error("PPTB Dataverse API not available");
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return await window.dataverseAPI!.getEntityMetadata(logicalName, true);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Get entity attributes metadata (filtered server-side for Advanced Find and sorted alphabetically)
|
|
266
|
-
*/
|
|
267
|
-
export async function getEntityAttributes(
|
|
268
|
-
logicalName: string,
|
|
269
|
-
advancedFindOnly: boolean = true
|
|
270
|
-
): Promise<AttributeMetadata[]> {
|
|
271
|
-
if (!isDataverseAvailable()) {
|
|
272
|
-
throw new Error("PPTB Dataverse API not available");
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Build OData query with server-side filtering
|
|
276
|
-
let query = `EntityDefinitions(LogicalName='${logicalName}')/Attributes?$select=LogicalName,SchemaName,DisplayName,AttributeType,AttributeTypeName,MetadataId,IsValidForAdvancedFind`;
|
|
277
|
-
|
|
278
|
-
// Add server-side filter for IsValidForAdvancedFind
|
|
279
|
-
if (advancedFindOnly) {
|
|
280
|
-
query += "&$filter=IsValidForAdvancedFind/Value eq true";
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
debugLog("metadataAPI", `📡 GET Attributes for '${logicalName}': ${query}`);
|
|
284
|
-
|
|
285
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
286
|
-
|
|
287
|
-
const attributes = result.value as unknown as AttributeMetadata[];
|
|
288
|
-
|
|
289
|
-
debugLog(
|
|
290
|
-
"metadataAPI",
|
|
291
|
-
`✅ Attributes retrieved for '${logicalName}': ${attributes.length} attributes`
|
|
292
|
-
);
|
|
293
|
-
|
|
294
|
-
// Sort alphabetically by display name (fallback to logical name)
|
|
295
|
-
// Must be client-side - $orderby not supported for metadata queries
|
|
296
|
-
attributes.sort((a, b) => {
|
|
297
|
-
const nameA = a.DisplayName?.UserLocalizedLabel?.Label || a.LogicalName;
|
|
298
|
-
const nameB = b.DisplayName?.UserLocalizedLabel?.Label || b.LogicalName;
|
|
299
|
-
return nameA.localeCompare(nameB);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
return attributes;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Get a single attribute with full metadata including OptionSet expansion
|
|
307
|
-
* Required for loading picklist/boolean option values
|
|
308
|
-
* Uses type casting (BooleanAttributeMetadata or PicklistAttributeMetadata) to access OptionSet
|
|
309
|
-
*/
|
|
310
|
-
export async function getAttributeWithOptionSet(
|
|
311
|
-
entityLogicalName: string,
|
|
312
|
-
attributeLogicalName: string
|
|
313
|
-
): Promise<AttributeMetadata> {
|
|
314
|
-
if (!isDataverseAvailable()) {
|
|
315
|
-
throw new Error("PPTB Dataverse API not available");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// First, get the basic attribute to determine its type
|
|
319
|
-
const basicQuery = `EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes(LogicalName='${attributeLogicalName}')?$select=AttributeType,LogicalName,SchemaName,DisplayName,MetadataId`;
|
|
320
|
-
|
|
321
|
-
debugLog("metadataAPI", `📡 GET Attribute type for '${attributeLogicalName}': ${basicQuery}`);
|
|
322
|
-
|
|
323
|
-
const basicResult = await window.dataverseAPI!.queryData(basicQuery);
|
|
324
|
-
// Single attribute query returns object directly, not in .value array
|
|
325
|
-
const basicAttr = (basicResult.value?.[0] || basicResult) as unknown as AttributeMetadata;
|
|
326
|
-
|
|
327
|
-
if (!basicAttr || !basicAttr.LogicalName) {
|
|
328
|
-
throw new Error(`Attribute ${attributeLogicalName} not found in entity ${entityLogicalName}`);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Determine the type cast needed based on AttributeType
|
|
332
|
-
let typeCast = "";
|
|
333
|
-
if (basicAttr.AttributeType === "Boolean") {
|
|
334
|
-
typeCast = "Microsoft.Dynamics.CRM.BooleanAttributeMetadata";
|
|
335
|
-
} else if (basicAttr.AttributeType === "State") {
|
|
336
|
-
typeCast = "Microsoft.Dynamics.CRM.StateAttributeMetadata";
|
|
337
|
-
} else if (basicAttr.AttributeType === "Status") {
|
|
338
|
-
typeCast = "Microsoft.Dynamics.CRM.StatusAttributeMetadata";
|
|
339
|
-
} else if (basicAttr.AttributeType === "Picklist") {
|
|
340
|
-
typeCast = "Microsoft.Dynamics.CRM.PicklistAttributeMetadata";
|
|
341
|
-
} else {
|
|
342
|
-
// For non-optionset attributes, return the basic metadata
|
|
343
|
-
debugLog(
|
|
344
|
-
"metadataAPI",
|
|
345
|
-
`⚠️ Attribute '${attributeLogicalName}' is type '${basicAttr.AttributeType}' - no OptionSet available`
|
|
346
|
-
);
|
|
347
|
-
return basicAttr;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Build query with type cast and OptionSet expansion
|
|
351
|
-
const fullQuery = `EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes(LogicalName='${attributeLogicalName}')/${typeCast}?$expand=OptionSet`;
|
|
352
|
-
|
|
353
|
-
debugLog(
|
|
354
|
-
"metadataAPI",
|
|
355
|
-
`📡 GET Attribute with OptionSet for '${attributeLogicalName}': ${fullQuery}`
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
const fullResult = await window.dataverseAPI!.queryData(fullQuery);
|
|
359
|
-
// Single attribute query returns object directly, not in .value array
|
|
360
|
-
const fullAttr = (fullResult.value?.[0] || fullResult) as unknown as AttributeMetadata;
|
|
361
|
-
|
|
362
|
-
if (!fullAttr || !fullAttr.LogicalName) {
|
|
363
|
-
throw new Error(
|
|
364
|
-
`Failed to retrieve attribute ${attributeLogicalName} with OptionSet expansion`
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
debugLog(
|
|
369
|
-
"metadataAPI",
|
|
370
|
-
`✅ Attribute with OptionSet retrieved for '${attributeLogicalName}':`,
|
|
371
|
-
fullAttr.OptionSet
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
return fullAttr;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Get detailed attribute metadata with type-specific properties (MinValue, MaxValue, Precision, Format)
|
|
379
|
-
* Used for attributes that need validation constraints: Integer, BigInt, Decimal, Double, DateTime
|
|
380
|
-
* Uses type casting (e.g., DecimalAttributeMetadata) to retrieve type-specific properties
|
|
381
|
-
*/
|
|
382
|
-
export async function getAttributeDetailedMetadata(
|
|
383
|
-
entityLogicalName: string,
|
|
384
|
-
attributeLogicalName: string,
|
|
385
|
-
attributeType: string
|
|
386
|
-
): Promise<AttributeMetadata> {
|
|
387
|
-
if (!isDataverseAvailable()) {
|
|
388
|
-
throw new Error("PPTB Dataverse API not available");
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Determine the type cast and $select based on AttributeType
|
|
392
|
-
// NOTE: When using type casting, only select type-specific properties (not base AttributeMetadata properties)
|
|
393
|
-
// This is a Dataverse API limitation - see: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query-metadata-web-api
|
|
394
|
-
let typeCast = "";
|
|
395
|
-
let selectProps = "";
|
|
396
|
-
|
|
397
|
-
switch (attributeType) {
|
|
398
|
-
case "Integer":
|
|
399
|
-
typeCast = "Microsoft.Dynamics.CRM.IntegerAttributeMetadata";
|
|
400
|
-
selectProps = "SchemaName,MaxValue,MinValue,Format";
|
|
401
|
-
break;
|
|
402
|
-
case "BigInt":
|
|
403
|
-
typeCast = "Microsoft.Dynamics.CRM.BigIntAttributeMetadata";
|
|
404
|
-
selectProps = "SchemaName,MaxValue,MinValue";
|
|
405
|
-
break;
|
|
406
|
-
case "Decimal":
|
|
407
|
-
typeCast = "Microsoft.Dynamics.CRM.DecimalAttributeMetadata";
|
|
408
|
-
selectProps = "SchemaName,MaxValue,MinValue,Precision";
|
|
409
|
-
break;
|
|
410
|
-
case "Double":
|
|
411
|
-
case "Float": // Float is an alias for Double in Dataverse
|
|
412
|
-
typeCast = "Microsoft.Dynamics.CRM.DoubleAttributeMetadata";
|
|
413
|
-
selectProps = "SchemaName,MaxValue,MinValue,Precision";
|
|
414
|
-
break;
|
|
415
|
-
case "DateTime":
|
|
416
|
-
typeCast = "Microsoft.Dynamics.CRM.DateTimeAttributeMetadata";
|
|
417
|
-
selectProps = "SchemaName,Format,DateTimeBehavior";
|
|
418
|
-
break;
|
|
419
|
-
default: {
|
|
420
|
-
// For types that don't need detailed metadata, return early with warning
|
|
421
|
-
debugLog(
|
|
422
|
-
"metadataAPI",
|
|
423
|
-
`⚠️ Attribute '${attributeLogicalName}' is type '${attributeType}' - no detailed metadata needed`
|
|
424
|
-
);
|
|
425
|
-
// Return basic metadata query without type cast
|
|
426
|
-
const basicQuery = `EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes(LogicalName='${attributeLogicalName}')?$select=SchemaName,LogicalName,DisplayName,AttributeType,MetadataId`;
|
|
427
|
-
const basicResult = await window.dataverseAPI!.queryData(basicQuery);
|
|
428
|
-
return (basicResult.value?.[0] || basicResult) as unknown as AttributeMetadata;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Build query with type cast and specific property selection
|
|
433
|
-
const detailedQuery = `EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes(LogicalName='${attributeLogicalName}')/${typeCast}?$select=${selectProps}`;
|
|
434
|
-
|
|
435
|
-
debugLog(
|
|
436
|
-
"metadataAPI",
|
|
437
|
-
`📡 GET Detailed Attribute metadata for '${attributeLogicalName}' (${attributeType}): ${detailedQuery}`
|
|
438
|
-
);
|
|
439
|
-
|
|
440
|
-
const result = await window.dataverseAPI!.queryData(detailedQuery);
|
|
441
|
-
// Single attribute query returns object directly, not in .value array
|
|
442
|
-
const detailedAttr = (result.value?.[0] || result) as unknown as AttributeMetadata;
|
|
443
|
-
|
|
444
|
-
if (!detailedAttr || !detailedAttr.SchemaName) {
|
|
445
|
-
throw new Error(`Failed to retrieve detailed metadata for attribute ${attributeLogicalName}`);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Add LogicalName back since it's not included in $select with type casting
|
|
449
|
-
detailedAttr.LogicalName = attributeLogicalName;
|
|
450
|
-
detailedAttr.AttributeType = attributeType;
|
|
451
|
-
|
|
452
|
-
debugLog(
|
|
453
|
-
"metadataAPI",
|
|
454
|
-
`✅ Detailed attribute metadata retrieved for '${attributeLogicalName}':`,
|
|
455
|
-
detailedAttr
|
|
456
|
-
);
|
|
457
|
-
|
|
458
|
-
return detailedAttr;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Get entity relationships metadata (filtered server-side for Advanced Find and sorted alphabetically)
|
|
463
|
-
*/
|
|
464
|
-
export async function getEntityRelationships(
|
|
465
|
-
logicalName: string,
|
|
466
|
-
advancedFindOnly: boolean = true
|
|
467
|
-
): Promise<{
|
|
468
|
-
oneToMany: RelationshipMetadata[];
|
|
469
|
-
manyToOne: RelationshipMetadata[];
|
|
470
|
-
manyToMany: RelationshipMetadata[];
|
|
471
|
-
}> {
|
|
472
|
-
if (!isDataverseAvailable()) {
|
|
473
|
-
throw new Error("PPTB Dataverse API not available");
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Build queries with server-side filtering
|
|
477
|
-
const buildQuery = (relType: string) => {
|
|
478
|
-
let query = `EntityDefinitions(LogicalName='${logicalName}')/${relType}?$select=SchemaName,RelationshipType,ReferencedEntity,ReferencedAttribute,ReferencingEntity,ReferencingAttribute,MetadataId,IsValidForAdvancedFind,IsCustomRelationship`;
|
|
479
|
-
|
|
480
|
-
// Add server-side filter for IsValidForAdvancedFind
|
|
481
|
-
if (advancedFindOnly) {
|
|
482
|
-
query += "&$filter=IsValidForAdvancedFind eq true";
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
return query;
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
const buildManyToManyQuery = () => {
|
|
489
|
-
let query = `EntityDefinitions(LogicalName='${logicalName}')/ManyToManyRelationships?$select=SchemaName,RelationshipType,Entity1LogicalName,Entity2LogicalName,Entity1IntersectAttribute,Entity2IntersectAttribute,IntersectEntityName,MetadataId,IsValidForAdvancedFind,IsCustomRelationship`;
|
|
490
|
-
|
|
491
|
-
// Add server-side filter for IsValidForAdvancedFind
|
|
492
|
-
if (advancedFindOnly) {
|
|
493
|
-
query += "&$filter=IsValidForAdvancedFind eq true";
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
return query;
|
|
497
|
-
};
|
|
498
|
-
|
|
499
|
-
const oneToManyQuery = buildQuery("OneToManyRelationships");
|
|
500
|
-
const manyToOneQuery = buildQuery("ManyToOneRelationships");
|
|
501
|
-
const manyToManyQuery = buildManyToManyQuery();
|
|
502
|
-
|
|
503
|
-
debugLog("metadataAPI", `📡 GET 1:N Relationships for '${logicalName}': ${oneToManyQuery}`);
|
|
504
|
-
debugLog("metadataAPI", `📡 GET N:1 Relationships for '${logicalName}': ${manyToOneQuery}`);
|
|
505
|
-
debugLog("metadataAPI", `📡 GET N:N Relationships for '${logicalName}': ${manyToManyQuery}`);
|
|
506
|
-
|
|
507
|
-
const [oneToManyResult, manyToOneResult, manyToManyResult] = await Promise.all([
|
|
508
|
-
window.dataverseAPI!.queryData(oneToManyQuery),
|
|
509
|
-
window.dataverseAPI!.queryData(manyToOneQuery),
|
|
510
|
-
window.dataverseAPI!.queryData(manyToManyQuery),
|
|
511
|
-
]);
|
|
512
|
-
|
|
513
|
-
debugLog(
|
|
514
|
-
"metadataAPI",
|
|
515
|
-
`✅ Relationships retrieved for '${logicalName}': 1:N=${oneToManyResult.value.length}, N:1=${manyToOneResult.value.length}, N:N=${manyToManyResult.value.length}`
|
|
516
|
-
);
|
|
517
|
-
|
|
518
|
-
// Helper function to sort relationships alphabetically by SchemaName
|
|
519
|
-
// Must be client-side - $orderby not supported for metadata queries
|
|
520
|
-
const sortRelationships = (rels: unknown[]): RelationshipMetadata[] => {
|
|
521
|
-
const relationships = rels as RelationshipMetadata[];
|
|
522
|
-
relationships.sort((a, b) => a.SchemaName.localeCompare(b.SchemaName));
|
|
523
|
-
return relationships;
|
|
524
|
-
};
|
|
525
|
-
|
|
526
|
-
return {
|
|
527
|
-
oneToMany: sortRelationships(oneToManyResult.value),
|
|
528
|
-
manyToOne: sortRelationships(manyToOneResult.value),
|
|
529
|
-
manyToMany: sortRelationships(manyToManyResult.value),
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Execute FetchXML query with formatted values
|
|
535
|
-
* Formatted values are returned with @OData.Community.Display.V1.FormattedValue annotations
|
|
536
|
-
*/
|
|
537
|
-
export async function executeFetchXml(fetchXml: string): Promise<FetchXmlResult> {
|
|
538
|
-
if (!isDataverseAvailable()) {
|
|
539
|
-
throw new Error("PPTB Dataverse API not available");
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
debugLog("fetchXmlAPI", `📡 Executing FetchXML query...`);
|
|
543
|
-
debugLog("fetchXmlAPI", `FetchXML:\n${fetchXml}`);
|
|
544
|
-
|
|
545
|
-
const result = await window.dataverseAPI!.fetchXmlQuery(fetchXml);
|
|
546
|
-
|
|
547
|
-
// Log the raw response to console for inspection
|
|
548
|
-
console.log("🔍 Raw FetchXML Response:", result);
|
|
549
|
-
console.log("🔍 Sample Record (first):", result.value[0]);
|
|
550
|
-
console.log("🔍 All Record Keys:", result.value[0] ? Object.keys(result.value[0]) : []);
|
|
551
|
-
|
|
552
|
-
// Check if formatted values are included
|
|
553
|
-
const hasFormatted = responseHasFormattedValues(result.value);
|
|
554
|
-
console.log(`🔍 Response includes formatted values: ${hasFormatted}`);
|
|
555
|
-
|
|
556
|
-
if (hasFormatted) {
|
|
557
|
-
console.log("✅ Formatted values detected! Example formatted keys:");
|
|
558
|
-
const formattedKeys = Object.keys(result.value[0] || {}).filter((k) => k.includes("@OData"));
|
|
559
|
-
console.log(formattedKeys.slice(0, 5));
|
|
560
|
-
} else {
|
|
561
|
-
console.warn("⚠️ NO FORMATTED VALUES DETECTED");
|
|
562
|
-
console.warn("");
|
|
563
|
-
console.warn("📋 The PPTB host's fetchXmlQuery() needs to update the Prefer header.");
|
|
564
|
-
console.warn("");
|
|
565
|
-
console.warn("🔧 In dataverseManager.ts (around line 405), change:");
|
|
566
|
-
console.warn(' FROM: Prefer: "return=representation"');
|
|
567
|
-
console.warn(
|
|
568
|
-
' TO: Prefer: "return=representation, odata.include-annotations=\\"OData.Community.Display.V1.FormattedValue\\""'
|
|
569
|
-
);
|
|
570
|
-
console.warn("");
|
|
571
|
-
console.warn("📖 Microsoft Learn reference:");
|
|
572
|
-
console.warn(
|
|
573
|
-
" https://learn.microsoft.com/en-us/power-apps/developer/data-platform/fetchxml/select-columns?tabs=webapi#formatted-values"
|
|
574
|
-
);
|
|
575
|
-
console.warn("");
|
|
576
|
-
console.warn(
|
|
577
|
-
"💡 This will enable rich display: labels for picklists, names for lookups, formatted dates, etc."
|
|
578
|
-
);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
debugLog("fetchXmlAPI", `✅ FetchXML query executed: ${result.value.length} records retrieved`);
|
|
582
|
-
|
|
583
|
-
return {
|
|
584
|
-
records: result.value,
|
|
585
|
-
totalRecordCount: result["@Microsoft.Dynamics.CRM.totalrecordcount"],
|
|
586
|
-
totalRecordCountLimitExceeded: result["@Microsoft.Dynamics.CRM.totalrecordcountlimitexceeded"],
|
|
587
|
-
moreRecords: result["@Microsoft.Dynamics.CRM.morerecords"],
|
|
588
|
-
pagingCookie: result["@Microsoft.Dynamics.CRM.pagingcookie"],
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Execute a System View (savedquery) by its ID
|
|
594
|
-
* Uses the optimized ?savedQuery={id} parameter instead of fetching and executing fetchXml
|
|
595
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/saved-queries#use-a-saved-query-in-fetchxml
|
|
596
|
-
*/
|
|
597
|
-
export async function executeSystemView(
|
|
598
|
-
entitySetName: string,
|
|
599
|
-
savedQueryId: string
|
|
600
|
-
): Promise<FetchXmlResult> {
|
|
601
|
-
if (!isDataverseAvailable()) {
|
|
602
|
-
throw new Error("PPTB Dataverse API not available");
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
debugLog("fetchXmlAPI", `📡 Executing System View: ${savedQueryId} on ${entitySetName}`);
|
|
606
|
-
|
|
607
|
-
// Use queryData with savedQuery parameter (OData query, not FetchXML)
|
|
608
|
-
// GET [Organization URI]/api/data/v9.2/{entitySetName}?savedQuery={savedQueryId}
|
|
609
|
-
const query = `${entitySetName}?savedQuery=${savedQueryId}`;
|
|
610
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
611
|
-
|
|
612
|
-
debugLog("fetchXmlAPI", `✅ System View executed: ${result.value.length} records retrieved`);
|
|
613
|
-
|
|
614
|
-
return {
|
|
615
|
-
records: result.value,
|
|
616
|
-
// savedQuery execution doesn't return paging metadata in the same way
|
|
617
|
-
totalRecordCount: undefined,
|
|
618
|
-
moreRecords: undefined,
|
|
619
|
-
pagingCookie: undefined,
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* Execute a Personal View (userquery) by its ID
|
|
625
|
-
* Uses the optimized ?userQuery={id} parameter instead of fetching and executing fetchXml
|
|
626
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/saved-queries#use-a-saved-query-in-fetchxml
|
|
627
|
-
*/
|
|
628
|
-
export async function executePersonalView(
|
|
629
|
-
entitySetName: string,
|
|
630
|
-
userQueryId: string
|
|
631
|
-
): Promise<FetchXmlResult> {
|
|
632
|
-
if (!isDataverseAvailable()) {
|
|
633
|
-
throw new Error("PPTB Dataverse API not available");
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
debugLog("fetchXmlAPI", `📡 Executing Personal View: ${userQueryId} on ${entitySetName}`);
|
|
637
|
-
|
|
638
|
-
// Use queryData with userQuery parameter (OData query, not FetchXML)
|
|
639
|
-
// GET [Organization URI]/api/data/v9.2/{entitySetName}?userQuery={userQueryId}
|
|
640
|
-
const query = `${entitySetName}?userQuery=${userQueryId}`;
|
|
641
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
642
|
-
|
|
643
|
-
debugLog("fetchXmlAPI", `✅ Personal View executed: ${result.value.length} records retrieved`);
|
|
644
|
-
|
|
645
|
-
return {
|
|
646
|
-
records: result.value,
|
|
647
|
-
// userQuery execution doesn't return paging metadata in the same way
|
|
648
|
-
totalRecordCount: undefined,
|
|
649
|
-
moreRecords: undefined,
|
|
650
|
-
pagingCookie: undefined,
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* Call WhoAmI to get current user context
|
|
656
|
-
*/
|
|
657
|
-
export async function whoAmI(): Promise<WhoAmIResponse | null> {
|
|
658
|
-
if (!isDataverseAvailable()) {
|
|
659
|
-
console.warn("PPTB Dataverse API not available");
|
|
660
|
-
return null;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
try {
|
|
664
|
-
// Call WhoAmI using execute with proper signature from documentation
|
|
665
|
-
const result = (await window.dataverseAPI!.execute({
|
|
666
|
-
operationName: "WhoAmI",
|
|
667
|
-
operationType: "function",
|
|
668
|
-
})) as WhoAmIResponse;
|
|
669
|
-
return result;
|
|
670
|
-
} catch (error) {
|
|
671
|
-
console.error("WhoAmI failed:", error);
|
|
672
|
-
return null;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Check if user has a specific privilege using RetrieveUserPrivilegeByPrivilegeName
|
|
678
|
-
*/
|
|
679
|
-
export async function checkPrivilegeByName(
|
|
680
|
-
userId: string,
|
|
681
|
-
privilegeName: string
|
|
682
|
-
): Promise<boolean> {
|
|
683
|
-
if (!isDataverseAvailable()) {
|
|
684
|
-
return false;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
try {
|
|
688
|
-
const query = `systemusers(${userId})/Microsoft.Dynamics.CRM.RetrieveUserPrivilegeByPrivilegeName(PrivilegeName='${privilegeName}')`;
|
|
689
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
690
|
-
|
|
691
|
-
// Log raw response for debugging
|
|
692
|
-
console.log(`🔍 checkPrivilegeByName(${privilegeName}) raw response:`, result);
|
|
693
|
-
|
|
694
|
-
// RetrieveUserPrivilegeByPrivilegeName returns the response directly, not wrapped in .value
|
|
695
|
-
const response = result as unknown as PrivilegeCheckResponse;
|
|
696
|
-
|
|
697
|
-
const hasPrivilege = !!response?.RolePrivileges?.length;
|
|
698
|
-
console.log(
|
|
699
|
-
`✅ checkPrivilegeByName(${privilegeName}): ${hasPrivilege ? "GRANTED" : "DENIED"}`
|
|
700
|
-
);
|
|
701
|
-
|
|
702
|
-
return hasPrivilege;
|
|
703
|
-
} catch (error) {
|
|
704
|
-
console.error(`checkPrivilegeByName(${privilegeName}) failed:`, error);
|
|
705
|
-
return false;
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Get access summary with privilege checks and mode determination
|
|
711
|
-
*/
|
|
712
|
-
export async function getAccessSummary(): Promise<AccessSummary | null> {
|
|
713
|
-
const user = await whoAmI();
|
|
714
|
-
if (!user) {
|
|
715
|
-
return null;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
const [canReadPublisher, canReadSolution, canReadCustomization] = await Promise.all([
|
|
719
|
-
checkPrivilegeByName(user.UserId, "prvReadPublisher"),
|
|
720
|
-
checkPrivilegeByName(user.UserId, "prvReadSolution"),
|
|
721
|
-
checkPrivilegeByName(user.UserId, "prvReadCustomization"),
|
|
722
|
-
]);
|
|
723
|
-
|
|
724
|
-
return {
|
|
725
|
-
userId: user.UserId,
|
|
726
|
-
canReadPublisher,
|
|
727
|
-
canReadSolution,
|
|
728
|
-
canReadCustomization,
|
|
729
|
-
fullFilterMode: canReadCustomization && canReadSolution && canReadPublisher,
|
|
730
|
-
solutionsOnlyMode: canReadCustomization && canReadSolution && !canReadPublisher,
|
|
731
|
-
publishersOnlyMode: canReadCustomization && canReadPublisher && !canReadSolution,
|
|
732
|
-
metadataOnlyMode: canReadCustomization && !canReadSolution && !canReadPublisher,
|
|
733
|
-
noAccessMode: !canReadCustomization,
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Get publishers with their solutions in one call (Full Filter mode optimization)
|
|
739
|
-
* Uses $expand to retrieve publishers and qualifying solutions together
|
|
740
|
-
* Filters: isreadonly eq false (custom publishers only), isvisible eq true (visible solutions only)
|
|
741
|
-
*/
|
|
742
|
-
export async function getPublishersWithSolutions(): Promise<PublisherWithSolutions[]> {
|
|
743
|
-
if (!isDataverseAvailable()) {
|
|
744
|
-
throw new Error("PPTB Dataverse API not available");
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
try {
|
|
748
|
-
const query =
|
|
749
|
-
"publishers" +
|
|
750
|
-
"?$select=publisherid,friendlyname,uniquename,customizationprefix" +
|
|
751
|
-
"&$filter=isreadonly eq false and publisher_solution/any(s: s/isvisible eq true and s/solution_solutioncomponent/any(c: c/componenttype eq 1))" +
|
|
752
|
-
"&$expand=publisher_solution($select=solutionid,friendlyname,isvisible,ismanaged,uniquename,version;$filter=isvisible eq true and solution_solutioncomponent/any(c: c/componenttype eq 1))" +
|
|
753
|
-
"&$orderby=friendlyname asc";
|
|
754
|
-
|
|
755
|
-
debugLog("publisherAPI", `📡 GET Publishers with Solutions (expanded): ${query}`);
|
|
756
|
-
|
|
757
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
758
|
-
const publisherData = result.value as unknown as Array<
|
|
759
|
-
Publisher & { publisher_solution: Solution[] }
|
|
760
|
-
>;
|
|
761
|
-
|
|
762
|
-
const publishersWithSolutions: PublisherWithSolutions[] = publisherData.map((p) => ({
|
|
763
|
-
publisher: {
|
|
764
|
-
publisherid: p.publisherid,
|
|
765
|
-
friendlyname: p.friendlyname,
|
|
766
|
-
uniquename: p.uniquename,
|
|
767
|
-
customizationprefix: p.customizationprefix,
|
|
768
|
-
},
|
|
769
|
-
solutions: p.publisher_solution || [],
|
|
770
|
-
}));
|
|
771
|
-
|
|
772
|
-
debugLog(
|
|
773
|
-
"publisherAPI",
|
|
774
|
-
`✅ Publishers with Solutions retrieved: ${
|
|
775
|
-
publishersWithSolutions.length
|
|
776
|
-
} publishers, ${publishersWithSolutions.reduce(
|
|
777
|
-
(sum, p) => sum + p.solutions.length,
|
|
778
|
-
0
|
|
779
|
-
)} total solutions`
|
|
780
|
-
);
|
|
781
|
-
|
|
782
|
-
return publishersWithSolutions;
|
|
783
|
-
} catch (error) {
|
|
784
|
-
console.error("getPublishersWithSolutions: API call failed:", error);
|
|
785
|
-
throw error;
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
/**
|
|
790
|
-
* Get all publishers
|
|
791
|
-
*/
|
|
792
|
-
export async function getPublishers(): Promise<Publisher[]> {
|
|
793
|
-
if (!isDataverseAvailable()) {
|
|
794
|
-
throw new Error("PPTB Dataverse API not available");
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
try {
|
|
798
|
-
const query =
|
|
799
|
-
"publishers?$select=publisherid,friendlyname,uniquename,customizationprefix&$filter=isreadonly eq false&$orderby=friendlyname asc";
|
|
800
|
-
debugLog("publisherAPI", `📡 GET Publishers: ${query}`);
|
|
801
|
-
|
|
802
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
803
|
-
const publishers = result.value as unknown as Publisher[];
|
|
804
|
-
|
|
805
|
-
debugLog("publisherAPI", `✅ Publishers retrieved: ${publishers.length}`);
|
|
806
|
-
return publishers;
|
|
807
|
-
} catch (error) {
|
|
808
|
-
console.error("getPublishers: API call failed:", error);
|
|
809
|
-
throw error;
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
/**
|
|
814
|
-
* Get solutions filtered by publisher IDs
|
|
815
|
-
* Only returns solutions that contain entities (componenttype=1)
|
|
816
|
-
*/
|
|
817
|
-
export async function getSolutionsByPublishers(publisherIds: string[]): Promise<Solution[]> {
|
|
818
|
-
if (!isDataverseAvailable()) {
|
|
819
|
-
throw new Error("PPTB Dataverse API not available");
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
if (!publisherIds.length) {
|
|
823
|
-
return [];
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
try {
|
|
827
|
-
const SELECT =
|
|
828
|
-
"$select=solutionid,friendlyname,uniquename,solutionpackageversion,_publisherid_value,isvisible,ismanaged";
|
|
829
|
-
const HAS_ENTITIES = "solution_solutioncomponent/any(c: c/componenttype eq 1)";
|
|
830
|
-
const IS_VISIBLE = "isvisible eq true";
|
|
831
|
-
|
|
832
|
-
// Chunk publisher IDs to avoid URL length limits
|
|
833
|
-
const chunkSize = 10;
|
|
834
|
-
const chunks: string[][] = [];
|
|
835
|
-
for (let i = 0; i < publisherIds.length; i += chunkSize) {
|
|
836
|
-
chunks.push(publisherIds.slice(i, i + chunkSize));
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
const allSolutions = await Promise.all(
|
|
840
|
-
chunks.map(async (chunk) => {
|
|
841
|
-
const publisherFilter = chunk.map((id) => `_publisherid_value eq ${id}`).join(" or ");
|
|
842
|
-
const query = `solutions?${SELECT}&$filter=(${publisherFilter}) and ${IS_VISIBLE} and ${HAS_ENTITIES}&$orderby=friendlyname asc`;
|
|
843
|
-
|
|
844
|
-
debugLog("solutionAPI", `📡 GET Solutions for publishers: ${chunk.length} IDs`);
|
|
845
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
846
|
-
return result.value as unknown as Solution[];
|
|
847
|
-
})
|
|
848
|
-
);
|
|
849
|
-
|
|
850
|
-
// Flatten and dedupe
|
|
851
|
-
const solutionMap = new Map<string, Solution>();
|
|
852
|
-
allSolutions.flat().forEach((s) => solutionMap.set(s.solutionid, s));
|
|
853
|
-
const solutions = Array.from(solutionMap.values());
|
|
854
|
-
|
|
855
|
-
debugLog("solutionAPI", `✅ Solutions retrieved: ${solutions.length}`);
|
|
856
|
-
return solutions;
|
|
857
|
-
} catch (error) {
|
|
858
|
-
console.error("getSolutionsByPublishers: API call failed:", error);
|
|
859
|
-
throw error;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/**
|
|
864
|
-
* Get all solutions that contain entities (no publisher filter)
|
|
865
|
-
* Filters: isvisible eq true (visible solutions only)
|
|
866
|
-
*/
|
|
867
|
-
export async function getAllSolutionsWithEntities(): Promise<Solution[]> {
|
|
868
|
-
if (!isDataverseAvailable()) {
|
|
869
|
-
throw new Error("PPTB Dataverse API not available");
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
try {
|
|
873
|
-
const SELECT =
|
|
874
|
-
"$select=solutionid,friendlyname,uniquename,version,_publisherid_value,isvisible,ismanaged";
|
|
875
|
-
const HAS_ENTITIES = "solution_solutioncomponent/any(c: c/componenttype eq 1)";
|
|
876
|
-
const IS_VISIBLE = "isvisible eq true";
|
|
877
|
-
const query = `solutions?${SELECT}&$filter=${IS_VISIBLE} and ${HAS_ENTITIES}&$orderby=friendlyname asc`;
|
|
878
|
-
|
|
879
|
-
debugLog("solutionAPI", `📡 GET All Solutions with entities (visible only)`);
|
|
880
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
881
|
-
const solutions = result.value as unknown as Solution[];
|
|
882
|
-
|
|
883
|
-
debugLog("solutionAPI", `✅ Solutions retrieved: ${solutions.length}`);
|
|
884
|
-
return solutions;
|
|
885
|
-
} catch (error) {
|
|
886
|
-
console.error("getAllSolutionsWithEntities: API call failed:", error);
|
|
887
|
-
throw error;
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* Get solution components (entities only) for given solution IDs
|
|
893
|
-
* NOTE: Virtual entity msdyn_solutioncomponentsummaries doesn't support OR filters properly,
|
|
894
|
-
* so we query each solution individually and combine results
|
|
895
|
-
*/
|
|
896
|
-
export async function getSolutionComponents(solutionIds: string[]): Promise<SolutionComponent[]> {
|
|
897
|
-
if (!isDataverseAvailable()) {
|
|
898
|
-
throw new Error("PPTB Dataverse API not available");
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
if (!solutionIds.length) {
|
|
902
|
-
return [];
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
try {
|
|
906
|
-
const SELECT =
|
|
907
|
-
"$select=msdyn_name,msdyn_displayname,msdyn_logicalcollectionname,msdyn_solutionid,msdyn_componenttype";
|
|
908
|
-
const COMPONENT_TYPE_ENTITY = 1;
|
|
909
|
-
|
|
910
|
-
console.log("[API] getSolutionComponents - Querying solutions individually:", solutionIds);
|
|
911
|
-
|
|
912
|
-
// Query each solution individually (virtual entity doesn't support OR filters properly)
|
|
913
|
-
const allComponents = await Promise.all(
|
|
914
|
-
solutionIds.map(async (solutionId) => {
|
|
915
|
-
const query = `msdyn_solutioncomponentsummaries?${SELECT}&$filter=msdyn_componenttype eq ${COMPONENT_TYPE_ENTITY} and msdyn_solutionid eq '${solutionId}'`;
|
|
916
|
-
|
|
917
|
-
console.log("[API] getSolutionComponents - Query for solution:", { solutionId, query });
|
|
918
|
-
debugLog("solutionComponentAPI", `📡 GET Solution components for solution: ${solutionId}`);
|
|
919
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
920
|
-
console.log("[API] getSolutionComponents - Result for solution:", {
|
|
921
|
-
solutionId,
|
|
922
|
-
componentCount: result.value.length,
|
|
923
|
-
components: result.value,
|
|
924
|
-
});
|
|
925
|
-
return result.value as unknown as SolutionComponent[];
|
|
926
|
-
})
|
|
927
|
-
);
|
|
928
|
-
|
|
929
|
-
// Flatten and deduplicate by msdyn_name
|
|
930
|
-
const componentMap = new Map<string, SolutionComponent>();
|
|
931
|
-
allComponents.flat().forEach((component) => {
|
|
932
|
-
if (component.msdyn_name && !componentMap.has(component.msdyn_name)) {
|
|
933
|
-
componentMap.set(component.msdyn_name, component);
|
|
934
|
-
}
|
|
935
|
-
});
|
|
936
|
-
|
|
937
|
-
const components = Array.from(componentMap.values());
|
|
938
|
-
|
|
939
|
-
// Sort by display name
|
|
940
|
-
components.sort((a, b) => {
|
|
941
|
-
const nameA = a.msdyn_displayname || a.msdyn_name || "";
|
|
942
|
-
const nameB = b.msdyn_displayname || b.msdyn_name || "";
|
|
943
|
-
return nameA.localeCompare(nameB);
|
|
944
|
-
});
|
|
945
|
-
|
|
946
|
-
console.log("[API] getSolutionComponents - Combined results:", {
|
|
947
|
-
totalSolutions: solutionIds.length,
|
|
948
|
-
uniqueComponents: components.length,
|
|
949
|
-
componentNames: components.map((c) => c.msdyn_name),
|
|
950
|
-
});
|
|
951
|
-
|
|
952
|
-
debugLog(
|
|
953
|
-
"solutionComponentAPI",
|
|
954
|
-
`✅ Components retrieved: ${components.length} unique entities`
|
|
955
|
-
);
|
|
956
|
-
return components;
|
|
957
|
-
} catch (error) {
|
|
958
|
-
console.error("getSolutionComponents: API call failed:", error);
|
|
959
|
-
throw error;
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
/**
|
|
964
|
-
* Get ALL EntityDefinitions that are valid for Advanced Find
|
|
965
|
-
* This is called once on startup and cached globally for the session
|
|
966
|
-
*/
|
|
967
|
-
export async function getAllAdvancedFindEntities(): Promise<EntityMetadata[]> {
|
|
968
|
-
if (!isDataverseAvailable()) {
|
|
969
|
-
throw new Error("PPTB Dataverse API not available");
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// Check cache first
|
|
973
|
-
const cached = metadataCache.getAllEntityMetadata();
|
|
974
|
-
if (cached) {
|
|
975
|
-
console.log("[API] getAllAdvancedFindEntities - Cache hit:", cached.length, "entities");
|
|
976
|
-
return cached;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Check for in-flight promise
|
|
980
|
-
const inFlight = metadataCache.getAllEntityMetadataPromise();
|
|
981
|
-
if (inFlight) {
|
|
982
|
-
console.log("[API] getAllAdvancedFindEntities - Returning in-flight promise");
|
|
983
|
-
return inFlight;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
console.log("[API] getAllAdvancedFindEntities - Fetching all AF-valid entities");
|
|
987
|
-
|
|
988
|
-
try {
|
|
989
|
-
const promise = getAllEntities(true);
|
|
990
|
-
metadataCache.setAllEntityMetadataPromise(promise);
|
|
991
|
-
|
|
992
|
-
const entities = await promise;
|
|
993
|
-
console.log("[API] getAllAdvancedFindEntities - Fetched:", entities.length, "entities");
|
|
994
|
-
|
|
995
|
-
metadataCache.setAllEntityMetadata(entities);
|
|
996
|
-
metadataCache.clearAllEntityMetadataPromise();
|
|
997
|
-
|
|
998
|
-
return entities;
|
|
999
|
-
} catch (error) {
|
|
1000
|
-
metadataCache.clearAllEntityMetadataPromise();
|
|
1001
|
-
console.error("[API] getAllAdvancedFindEntities - Failed:", error);
|
|
1002
|
-
throw error;
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
/**
|
|
1007
|
-
* Filter global entity metadata cache by entity logical names
|
|
1008
|
-
* Returns entities from cache (instant, no API call)
|
|
1009
|
-
*/
|
|
1010
|
-
export function filterCachedEntitiesByNames(logicalNames: string[]): EntityMetadata[] {
|
|
1011
|
-
const allEntities = metadataCache.getAllEntityMetadata();
|
|
1012
|
-
if (!allEntities) {
|
|
1013
|
-
console.warn("[API] filterCachedEntitiesByNames - Global cache not available, returning empty");
|
|
1014
|
-
return [];
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
const nameSet = new Set(logicalNames);
|
|
1018
|
-
const filtered = allEntities.filter((entity: EntityMetadata) => nameSet.has(entity.LogicalName));
|
|
1019
|
-
|
|
1020
|
-
console.log("[API] filterCachedEntitiesByNames - Filtered:", {
|
|
1021
|
-
requestedCount: logicalNames.length,
|
|
1022
|
-
requestedNames: logicalNames,
|
|
1023
|
-
filteredCount: filtered.length,
|
|
1024
|
-
filteredNames: filtered.map((e: EntityMetadata) => e.LogicalName),
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
return filtered;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
/**
|
|
1031
|
-
* Get EntityDefinitions filtered by logical names and IsValidForAdvancedFind
|
|
1032
|
-
*/
|
|
1033
|
-
export async function getAdvancedFindEntitiesByNames(
|
|
1034
|
-
logicalNames: string[]
|
|
1035
|
-
): Promise<EntityMetadata[]> {
|
|
1036
|
-
if (!isDataverseAvailable()) {
|
|
1037
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
if (!logicalNames.length) {
|
|
1041
|
-
console.log(
|
|
1042
|
-
"[API] getAdvancedFindEntitiesByNames - Empty logical names, returning all AF entities"
|
|
1043
|
-
);
|
|
1044
|
-
// Return all AF-valid entities
|
|
1045
|
-
return getAllEntities(true);
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
console.log("[API] getAdvancedFindEntitiesByNames - Input:", {
|
|
1049
|
-
count: logicalNames.length,
|
|
1050
|
-
names: logicalNames,
|
|
1051
|
-
});
|
|
1052
|
-
|
|
1053
|
-
try {
|
|
1054
|
-
const SELECT =
|
|
1055
|
-
"$select=LogicalName,SchemaName,DisplayName,EntitySetName,PrimaryIdAttribute,PrimaryNameAttribute,IsValidForAdvancedFind,MetadataId,ObjectTypeCode";
|
|
1056
|
-
|
|
1057
|
-
// Chunk logical names to avoid URL length limits
|
|
1058
|
-
const chunkSize = 15;
|
|
1059
|
-
const chunks: string[][] = [];
|
|
1060
|
-
for (let i = 0; i < logicalNames.length; i += chunkSize) {
|
|
1061
|
-
chunks.push(logicalNames.slice(i, i + chunkSize));
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
const allEntities = await Promise.all(
|
|
1065
|
-
chunks.map(async (chunk) => {
|
|
1066
|
-
const nameFilter = chunk.map((name) => `LogicalName eq '${name}'`).join(" or ");
|
|
1067
|
-
const query = `EntityDefinitions?${SELECT}&$filter=IsValidForAdvancedFind eq true and (${nameFilter})`;
|
|
1068
|
-
|
|
1069
|
-
console.log("[API] getAdvancedFindEntitiesByNames - Query:", query);
|
|
1070
|
-
debugLog("metadataAPI", `📡 GET Entities by names: ${chunk.length} names`);
|
|
1071
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
1072
|
-
const entities = result.value as unknown as EntityMetadata[];
|
|
1073
|
-
console.log("[API] getAdvancedFindEntitiesByNames - Result:", {
|
|
1074
|
-
requestedNames: chunk,
|
|
1075
|
-
returnedCount: entities.length,
|
|
1076
|
-
returnedEntities: entities.map((e) => e.LogicalName),
|
|
1077
|
-
});
|
|
1078
|
-
return entities;
|
|
1079
|
-
})
|
|
1080
|
-
);
|
|
1081
|
-
|
|
1082
|
-
// Flatten and dedupe
|
|
1083
|
-
const entityMap = new Map<string, EntityMetadata>();
|
|
1084
|
-
allEntities.flat().forEach((e) => entityMap.set(e.LogicalName, e));
|
|
1085
|
-
const entities = Array.from(entityMap.values());
|
|
1086
|
-
|
|
1087
|
-
// Sort by display name
|
|
1088
|
-
entities.sort((a, b) => {
|
|
1089
|
-
const nameA = a.DisplayName?.UserLocalizedLabel?.Label || a.LogicalName;
|
|
1090
|
-
const nameB = b.DisplayName?.UserLocalizedLabel?.Label || b.LogicalName;
|
|
1091
|
-
return nameA.localeCompare(nameB);
|
|
1092
|
-
});
|
|
1093
|
-
|
|
1094
|
-
debugLog("metadataAPI", `✅ Entities retrieved: ${entities.length}`);
|
|
1095
|
-
return entities;
|
|
1096
|
-
} catch (error) {
|
|
1097
|
-
console.error("getAdvancedFindEntitiesByNames: API call failed:", error);
|
|
1098
|
-
throw error;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// =============================================================================
|
|
1103
|
-
// Saved View / User Query APIs
|
|
1104
|
-
// =============================================================================
|
|
1105
|
-
|
|
1106
|
-
/**
|
|
1107
|
-
* Represents a saved view (System View or Personal View)
|
|
1108
|
-
*/
|
|
1109
|
-
export interface SavedView {
|
|
1110
|
-
id: string;
|
|
1111
|
-
name: string;
|
|
1112
|
-
fetchxml: string;
|
|
1113
|
-
layoutxml?: string;
|
|
1114
|
-
type: "system" | "personal";
|
|
1115
|
-
description?: string;
|
|
1116
|
-
isDefault?: boolean;
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
/**
|
|
1120
|
-
* Information about a loaded view for execution optimization
|
|
1121
|
-
* Used to determine if the view can be executed via savedQuery/userQuery
|
|
1122
|
-
* or if it needs to fall back to fetchXmlQuery execution
|
|
1123
|
-
*/
|
|
1124
|
-
export interface LoadedViewInfo {
|
|
1125
|
-
/** View ID (savedqueryid or userqueryid) */
|
|
1126
|
-
id: string;
|
|
1127
|
-
/** View type for determining execution method */
|
|
1128
|
-
type: "system" | "personal";
|
|
1129
|
-
/** Original FetchXML from the view - used for comparison */
|
|
1130
|
-
originalFetchXml: string;
|
|
1131
|
-
/** Entity set name for the execution URL */
|
|
1132
|
-
entitySetName: string;
|
|
1133
|
-
/** View name for display purposes */
|
|
1134
|
-
name: string;
|
|
1135
|
-
/** Optional LayoutXML for column configuration */
|
|
1136
|
-
layoutxml?: string;
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
/**
|
|
1140
|
-
* Parsed layout column from LayoutXML
|
|
1141
|
-
*/
|
|
1142
|
-
export interface LayoutColumn {
|
|
1143
|
-
name: string;
|
|
1144
|
-
width: number;
|
|
1145
|
-
disableSorting?: boolean;
|
|
1146
|
-
/** For link-entity columns, the alias prefix (e.g., "accountprimarycontactidcontactcontactid") */
|
|
1147
|
-
linkEntityAlias?: string;
|
|
1148
|
-
/** The actual attribute name without alias prefix */
|
|
1149
|
-
attributeName: string;
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
/**
|
|
1153
|
-
* Parsed LayoutXML structure
|
|
1154
|
-
*/
|
|
1155
|
-
export interface ParsedLayoutXml {
|
|
1156
|
-
gridName: string;
|
|
1157
|
-
objectTypeCode: number;
|
|
1158
|
-
jumpAttribute?: string;
|
|
1159
|
-
primaryIdAttribute: string;
|
|
1160
|
-
columns: LayoutColumn[];
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
/**
|
|
1164
|
-
* Parse LayoutXML into a structured format for DataGrid column configuration
|
|
1165
|
-
* @param layoutXml The raw LayoutXML string from savedquery/userquery
|
|
1166
|
-
* @returns Parsed layout structure or null if parsing fails
|
|
1167
|
-
*/
|
|
1168
|
-
export function parseLayoutXml(layoutXml: string): ParsedLayoutXml | null {
|
|
1169
|
-
if (!layoutXml) return null;
|
|
1170
|
-
|
|
1171
|
-
try {
|
|
1172
|
-
const parser = new DOMParser();
|
|
1173
|
-
const doc = parser.parseFromString(layoutXml, "text/xml");
|
|
1174
|
-
|
|
1175
|
-
const gridElement = doc.querySelector("grid");
|
|
1176
|
-
if (!gridElement) return null;
|
|
1177
|
-
|
|
1178
|
-
const rowElement = doc.querySelector("row");
|
|
1179
|
-
if (!rowElement) return null;
|
|
1180
|
-
|
|
1181
|
-
const columns: LayoutColumn[] = [];
|
|
1182
|
-
const cellElements = doc.querySelectorAll("row > cell");
|
|
1183
|
-
|
|
1184
|
-
cellElements.forEach((cell) => {
|
|
1185
|
-
const name = cell.getAttribute("name") || "";
|
|
1186
|
-
const width = parseInt(cell.getAttribute("width") || "100", 10);
|
|
1187
|
-
const disableSorting = cell.getAttribute("disableSorting") === "1";
|
|
1188
|
-
|
|
1189
|
-
// Check if this is a link-entity column (contains a dot)
|
|
1190
|
-
let linkEntityAlias: string | undefined;
|
|
1191
|
-
let attributeName = name;
|
|
1192
|
-
|
|
1193
|
-
if (name.includes(".")) {
|
|
1194
|
-
const parts = name.split(".");
|
|
1195
|
-
linkEntityAlias = parts[0];
|
|
1196
|
-
attributeName = parts[1];
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
columns.push({
|
|
1200
|
-
name,
|
|
1201
|
-
width,
|
|
1202
|
-
disableSorting: disableSorting || undefined,
|
|
1203
|
-
linkEntityAlias,
|
|
1204
|
-
attributeName,
|
|
1205
|
-
});
|
|
1206
|
-
});
|
|
1207
|
-
|
|
1208
|
-
return {
|
|
1209
|
-
gridName: gridElement.getAttribute("name") || "resultset",
|
|
1210
|
-
objectTypeCode: parseInt(gridElement.getAttribute("object") || "0", 10),
|
|
1211
|
-
jumpAttribute: gridElement.getAttribute("jump") || undefined,
|
|
1212
|
-
primaryIdAttribute: rowElement.getAttribute("id") || "",
|
|
1213
|
-
columns,
|
|
1214
|
-
};
|
|
1215
|
-
} catch (error) {
|
|
1216
|
-
console.error("parseLayoutXml: Failed to parse LayoutXML:", error);
|
|
1217
|
-
return null;
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
/**
|
|
1222
|
-
* Generate LayoutXML from column configuration
|
|
1223
|
-
* Used when saving a customized view
|
|
1224
|
-
* @param columns The column configuration
|
|
1225
|
-
* @param objectTypeCode The entity's object type code
|
|
1226
|
-
* @param primaryIdAttribute The entity's primary ID attribute (e.g., "accountid")
|
|
1227
|
-
* @param jumpAttribute Optional attribute for the "jump" field
|
|
1228
|
-
*/
|
|
1229
|
-
export function generateLayoutXml(
|
|
1230
|
-
columns: LayoutColumn[],
|
|
1231
|
-
objectTypeCode: number,
|
|
1232
|
-
primaryIdAttribute: string,
|
|
1233
|
-
jumpAttribute?: string
|
|
1234
|
-
): string {
|
|
1235
|
-
const cellsXml = columns
|
|
1236
|
-
.map((col) => {
|
|
1237
|
-
let cellAttrs = `name="${col.name}" width="${col.width}"`;
|
|
1238
|
-
if (col.disableSorting) {
|
|
1239
|
-
cellAttrs += ` disableSorting="1"`;
|
|
1240
|
-
}
|
|
1241
|
-
return `<cell ${cellAttrs} />`;
|
|
1242
|
-
})
|
|
1243
|
-
.join("");
|
|
1244
|
-
|
|
1245
|
-
const jumpAttr = jumpAttribute ? ` jump="${jumpAttribute}"` : "";
|
|
1246
|
-
|
|
1247
|
-
return (
|
|
1248
|
-
`<grid name="resultset" object="${objectTypeCode}"${jumpAttr} select="1" icon="1" preview="1">` +
|
|
1249
|
-
`<row name="result" id="${primaryIdAttribute}">${cellsXml}</row></grid>`
|
|
1250
|
-
);
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
/**
|
|
1254
|
-
* Get System Views (savedquery) for an entity
|
|
1255
|
-
* Only returns public views (querytype = 0) that are active and not hidden
|
|
1256
|
-
* @param entityLogicalName The logical name of the entity (e.g., "account")
|
|
1257
|
-
*/
|
|
1258
|
-
export async function getSystemViews(entityLogicalName: string): Promise<SavedView[]> {
|
|
1259
|
-
if (!isDataverseAvailable()) {
|
|
1260
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
debugLog("viewAPI", `📡 GET System Views for '${entityLogicalName}'`);
|
|
1264
|
-
|
|
1265
|
-
try {
|
|
1266
|
-
// Query savedquery entity using queryData for full OData support
|
|
1267
|
-
// returnedtypecode: Entity logical name as string
|
|
1268
|
-
// querytype = 0: Public views (not lookup views, quickfind, etc.)
|
|
1269
|
-
// componentstate = 0: Published (not unpublished/deleted)
|
|
1270
|
-
// statecode = 0: Active views only
|
|
1271
|
-
const query =
|
|
1272
|
-
`savedqueries?$select=savedqueryid,name,fetchxml,layoutxml,isdefault` +
|
|
1273
|
-
`&$filter=(returnedtypecode eq '${entityLogicalName}' and querytype eq 0 and componentstate eq 0 and statecode eq 0)` +
|
|
1274
|
-
`&$orderby=isdefault desc,name asc`;
|
|
1275
|
-
|
|
1276
|
-
debugLog("viewAPI", `Query: ${query}`);
|
|
1277
|
-
|
|
1278
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
1279
|
-
|
|
1280
|
-
const views: SavedView[] = (result.value || []).map((record) => ({
|
|
1281
|
-
id: record.savedqueryid as string,
|
|
1282
|
-
name: record.name as string,
|
|
1283
|
-
fetchxml: record.fetchxml as string,
|
|
1284
|
-
layoutxml: record.layoutxml as string | undefined,
|
|
1285
|
-
type: "system" as const,
|
|
1286
|
-
isDefault: record.isdefault as boolean | undefined,
|
|
1287
|
-
}));
|
|
1288
|
-
|
|
1289
|
-
debugLog(
|
|
1290
|
-
"viewAPI",
|
|
1291
|
-
`✅ System Views retrieved for '${entityLogicalName}': ${views.length} views`
|
|
1292
|
-
);
|
|
1293
|
-
|
|
1294
|
-
return views;
|
|
1295
|
-
} catch (error) {
|
|
1296
|
-
console.error(`getSystemViews: Failed to get system views for '${entityLogicalName}':`, error);
|
|
1297
|
-
throw error;
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
/**
|
|
1302
|
-
* Get Personal Views (userquery) for an entity
|
|
1303
|
-
* Returns views owned by the current user or their teams
|
|
1304
|
-
* @param entityLogicalName The logical name of the entity (e.g., "account")
|
|
1305
|
-
*/
|
|
1306
|
-
export async function getPersonalViews(entityLogicalName: string): Promise<SavedView[]> {
|
|
1307
|
-
if (!isDataverseAvailable()) {
|
|
1308
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
debugLog("viewAPI", `📡 GET Personal Views for '${entityLogicalName}'`);
|
|
1312
|
-
|
|
1313
|
-
try {
|
|
1314
|
-
// Query userquery entity using queryData for full OData support
|
|
1315
|
-
// returnedtypecode: Entity logical name as string
|
|
1316
|
-
// statecode = 0: Active views only
|
|
1317
|
-
// querytype = 0: Saved views (not quick find)
|
|
1318
|
-
// EqualUserOrUserTeams: Views owned by current user or their teams
|
|
1319
|
-
const query =
|
|
1320
|
-
`userqueries?$select=userqueryid,name,fetchxml,layoutxml` +
|
|
1321
|
-
`&$filter=(statecode eq 0 and querytype eq 0 and returnedtypecode eq '${entityLogicalName}' and Microsoft.Dynamics.CRM.EqualUserOrUserTeams(PropertyName='ownerid'))` +
|
|
1322
|
-
`&$orderby=name asc`;
|
|
1323
|
-
|
|
1324
|
-
debugLog("viewAPI", `Query: ${query}`);
|
|
1325
|
-
|
|
1326
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
1327
|
-
|
|
1328
|
-
const views: SavedView[] = (result.value || []).map((record) => ({
|
|
1329
|
-
id: record.userqueryid as string,
|
|
1330
|
-
name: record.name as string,
|
|
1331
|
-
fetchxml: record.fetchxml as string,
|
|
1332
|
-
layoutxml: record.layoutxml as string | undefined,
|
|
1333
|
-
type: "personal" as const,
|
|
1334
|
-
}));
|
|
1335
|
-
|
|
1336
|
-
debugLog(
|
|
1337
|
-
"viewAPI",
|
|
1338
|
-
`✅ Personal Views retrieved for '${entityLogicalName}': ${views.length} views`
|
|
1339
|
-
);
|
|
1340
|
-
|
|
1341
|
-
return views;
|
|
1342
|
-
} catch (error) {
|
|
1343
|
-
console.error(
|
|
1344
|
-
`getPersonalViews: Failed to get personal views for '${entityLogicalName}':`,
|
|
1345
|
-
error
|
|
1346
|
-
);
|
|
1347
|
-
throw error;
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
/**
|
|
1352
|
-
* Get all views (System + Personal) for an entity
|
|
1353
|
-
* @param entityLogicalName The logical name of the entity
|
|
1354
|
-
*/
|
|
1355
|
-
export async function getAllViews(
|
|
1356
|
-
entityLogicalName: string
|
|
1357
|
-
): Promise<{ systemViews: SavedView[]; personalViews: SavedView[] }> {
|
|
1358
|
-
const [systemViews, personalViews] = await Promise.all([
|
|
1359
|
-
getSystemViews(entityLogicalName),
|
|
1360
|
-
getPersonalViews(entityLogicalName),
|
|
1361
|
-
]);
|
|
1362
|
-
|
|
1363
|
-
return { systemViews, personalViews };
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
// =============================================================================
|
|
1367
|
-
// View Save / Update / Validation APIs
|
|
1368
|
-
// =============================================================================
|
|
1369
|
-
|
|
1370
|
-
/**
|
|
1371
|
-
* Validator issue from ValidateFetchXmlExpression
|
|
1372
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/validatorissue
|
|
1373
|
-
*/
|
|
1374
|
-
export interface ValidatorIssue {
|
|
1375
|
-
/** Localized message text describing the issue */
|
|
1376
|
-
LocalizedMessageText: string;
|
|
1377
|
-
/** Severity level: 0 (Low), 1 (Medium), 2 (High), 3 (Critical) */
|
|
1378
|
-
Severity: 0 | 1 | 2 | 3;
|
|
1379
|
-
/** Type code categorizing the issue */
|
|
1380
|
-
TypeCode: number;
|
|
1381
|
-
/** Optional additional properties */
|
|
1382
|
-
OptionalPropertyBag?: Record<string, string>;
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
/**
|
|
1386
|
-
* Response from ValidateFetchXmlExpression function
|
|
1387
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/validatefetchxmlexpressionresponse
|
|
1388
|
-
*/
|
|
1389
|
-
export interface ValidateFetchXmlExpressionResponse {
|
|
1390
|
-
ValidationResults: {
|
|
1391
|
-
Helplink?: string;
|
|
1392
|
-
Messages: ValidatorIssue[];
|
|
1393
|
-
};
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
/**
|
|
1397
|
-
* Validate FetchXML for performance issues before save
|
|
1398
|
-
* Returns validation messages with severity levels
|
|
1399
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/validatefetchxmlexpression
|
|
1400
|
-
*/
|
|
1401
|
-
export async function validateFetchXmlExpression(
|
|
1402
|
-
fetchXml: string
|
|
1403
|
-
): Promise<ValidateFetchXmlExpressionResponse> {
|
|
1404
|
-
if (!isDataverseAvailable()) {
|
|
1405
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
debugLog("viewAPI", `📡 ValidateFetchXmlExpression - Validating FetchXML...`);
|
|
1409
|
-
|
|
1410
|
-
try {
|
|
1411
|
-
// The function requires URL-encoded FetchXML passed as a query parameter
|
|
1412
|
-
// Format: ValidateFetchXmlExpression(FetchXml=@FetchXml)?@FetchXml='encodedFetchXml'
|
|
1413
|
-
const encodedFetchXml = encodeURIComponent(fetchXml);
|
|
1414
|
-
const query = `ValidateFetchXmlExpression(FetchXml=@FetchXml)?@FetchXml='${encodedFetchXml}'`;
|
|
1415
|
-
|
|
1416
|
-
const result = (await window.dataverseAPI!.queryData(
|
|
1417
|
-
query
|
|
1418
|
-
)) as unknown as ValidateFetchXmlExpressionResponse;
|
|
1419
|
-
|
|
1420
|
-
debugLog(
|
|
1421
|
-
"viewAPI",
|
|
1422
|
-
`✅ ValidateFetchXmlExpression - Complete: ${
|
|
1423
|
-
result.ValidationResults?.Messages?.length || 0
|
|
1424
|
-
} messages`
|
|
1425
|
-
);
|
|
1426
|
-
|
|
1427
|
-
return result;
|
|
1428
|
-
} catch (error) {
|
|
1429
|
-
console.error("validateFetchXmlExpression: Failed:", error);
|
|
1430
|
-
throw error;
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
/**
|
|
1435
|
-
* Validate SavedQuery before creation/update
|
|
1436
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/validatesavedquery
|
|
1437
|
-
*/
|
|
1438
|
-
export async function validateSavedQuery(fetchXml: string, queryType: number = 0): Promise<void> {
|
|
1439
|
-
if (!isDataverseAvailable()) {
|
|
1440
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
debugLog("viewAPI", `📡 ValidateSavedQuery - Validating saved query...`);
|
|
1444
|
-
|
|
1445
|
-
try {
|
|
1446
|
-
await window.dataverseAPI!.execute({
|
|
1447
|
-
operationName: "ValidateSavedQuery",
|
|
1448
|
-
operationType: "action",
|
|
1449
|
-
parameters: {
|
|
1450
|
-
FetchXml: fetchXml,
|
|
1451
|
-
QueryType: queryType,
|
|
1452
|
-
},
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
debugLog("viewAPI", `✅ ValidateSavedQuery - Validation passed`);
|
|
1456
|
-
} catch (error) {
|
|
1457
|
-
console.error("validateSavedQuery: Validation failed:", error);
|
|
1458
|
-
throw error;
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
/**
|
|
1463
|
-
* Check if user has privileges for saving system views (savedquery)
|
|
1464
|
-
* Correct privilege names:
|
|
1465
|
-
* - prvWriteQuery: Required to create/update saved queries (system views)
|
|
1466
|
-
* - prvWriteCustomization: Required to write customizations
|
|
1467
|
-
* - prvPublishCustomization: Required to publish customizations
|
|
1468
|
-
* @returns Object with privileges for savedquery operations
|
|
1469
|
-
*/
|
|
1470
|
-
export async function checkSavedQueryPrivileges(): Promise<{
|
|
1471
|
-
canWrite: boolean;
|
|
1472
|
-
canPublish: boolean;
|
|
1473
|
-
}> {
|
|
1474
|
-
const user = await whoAmI();
|
|
1475
|
-
if (!user) {
|
|
1476
|
-
return { canWrite: false, canPublish: false };
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
// Try to check privileges, but handle gracefully if the privilege check fails
|
|
1480
|
-
let canWriteQuery = false;
|
|
1481
|
-
let canWriteCustomization = false;
|
|
1482
|
-
let canPublishCustomization = false;
|
|
1483
|
-
|
|
1484
|
-
try {
|
|
1485
|
-
// Check for prvWriteQuery privilege (create/update system views)
|
|
1486
|
-
canWriteQuery = await checkPrivilegeByName(user.UserId, "prvWriteQuery");
|
|
1487
|
-
} catch (error) {
|
|
1488
|
-
console.warn("Could not check prvWriteQuery, assuming false:", error);
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
try {
|
|
1492
|
-
// Check for prvWriteCustomization privilege
|
|
1493
|
-
canWriteCustomization = await checkPrivilegeByName(user.UserId, "prvWriteCustomization");
|
|
1494
|
-
} catch (error) {
|
|
1495
|
-
console.warn("Could not check prvWriteCustomization, assuming false:", error);
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
try {
|
|
1499
|
-
// Check for prvPublishCustomization privilege
|
|
1500
|
-
canPublishCustomization = await checkPrivilegeByName(user.UserId, "prvPublishCustomization");
|
|
1501
|
-
} catch (error) {
|
|
1502
|
-
console.warn("Could not check prvPublishCustomization, assuming false:", error);
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
// User can write system views if they have both prvWriteQuery and prvWriteCustomization
|
|
1506
|
-
const canWrite = canWriteQuery && canWriteCustomization;
|
|
1507
|
-
// User can publish if they also have prvPublishCustomization
|
|
1508
|
-
const canPublish = canWrite && canPublishCustomization;
|
|
1509
|
-
|
|
1510
|
-
debugLog(
|
|
1511
|
-
"viewAPI",
|
|
1512
|
-
`✅ SavedQuery privileges: writeQuery=${canWriteQuery}, writeCustomization=${canWriteCustomization}, publishCustomization=${canPublishCustomization} → canWrite=${canWrite}, canPublish=${canPublish}`
|
|
1513
|
-
);
|
|
1514
|
-
|
|
1515
|
-
return { canWrite, canPublish };
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
/**
|
|
1519
|
-
* Check if user has privileges for saving personal views (userquery)
|
|
1520
|
-
* Correct privilege name:
|
|
1521
|
-
* - prvWriteUserQuery: Required to create/update user queries (personal views)
|
|
1522
|
-
* @returns Whether user can write personal views
|
|
1523
|
-
*/
|
|
1524
|
-
export async function checkUserQueryPrivileges(): Promise<{
|
|
1525
|
-
canWrite: boolean;
|
|
1526
|
-
}> {
|
|
1527
|
-
const user = await whoAmI();
|
|
1528
|
-
if (!user) {
|
|
1529
|
-
return { canWrite: false };
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
let canWrite = false;
|
|
1533
|
-
|
|
1534
|
-
try {
|
|
1535
|
-
// Check for prvWriteUserQuery privilege
|
|
1536
|
-
canWrite = await checkPrivilegeByName(user.UserId, "prvWriteUserQuery");
|
|
1537
|
-
} catch (error) {
|
|
1538
|
-
console.warn("Could not check prvWriteUserQuery, assuming false:", error);
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
debugLog("viewAPI", `✅ UserQuery privileges: canWrite=${canWrite}`);
|
|
1542
|
-
|
|
1543
|
-
return { canWrite };
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
/**
|
|
1547
|
-
* Get unmanaged solutions that can receive components
|
|
1548
|
-
* Used for the solution picker when saving system views
|
|
1549
|
-
*/
|
|
1550
|
-
export async function getUnmanagedSolutions(): Promise<Solution[]> {
|
|
1551
|
-
if (!isDataverseAvailable()) {
|
|
1552
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
debugLog("viewAPI", `📡 GET Unmanaged Solutions`);
|
|
1556
|
-
|
|
1557
|
-
try {
|
|
1558
|
-
// Query unmanaged solutions (ismanaged eq false)
|
|
1559
|
-
// Exclude the Default solution (uniquename = 'Default')
|
|
1560
|
-
const query =
|
|
1561
|
-
"solutions?" +
|
|
1562
|
-
"$select=solutionid,friendlyname,uniquename,version,_publisherid_value,isvisible,ismanaged" +
|
|
1563
|
-
"&$filter=ismanaged eq false and isvisible eq true and uniquename ne 'Default'" +
|
|
1564
|
-
"&$orderby=friendlyname asc";
|
|
1565
|
-
|
|
1566
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
1567
|
-
const solutions = result.value as unknown as Solution[];
|
|
1568
|
-
|
|
1569
|
-
debugLog("viewAPI", `✅ Unmanaged Solutions retrieved: ${solutions.length}`);
|
|
1570
|
-
return solutions;
|
|
1571
|
-
} catch (error) {
|
|
1572
|
-
console.error("getUnmanagedSolutions: Failed:", error);
|
|
1573
|
-
throw error;
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
/**
|
|
1578
|
-
* Data for creating a new system view (savedquery)
|
|
1579
|
-
*/
|
|
1580
|
-
export interface CreateSavedQueryData {
|
|
1581
|
-
name: string;
|
|
1582
|
-
fetchxml: string;
|
|
1583
|
-
layoutxml: string;
|
|
1584
|
-
returnedtypecode: string; // Entity logical name
|
|
1585
|
-
description?: string;
|
|
1586
|
-
querytype?: number; // 0 = Main/Public view
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
/**
|
|
1590
|
-
* Data for creating a new personal view (userquery)
|
|
1591
|
-
*/
|
|
1592
|
-
export interface CreateUserQueryData {
|
|
1593
|
-
name: string;
|
|
1594
|
-
fetchxml: string;
|
|
1595
|
-
layoutxml: string;
|
|
1596
|
-
returnedtypecode: string; // Entity logical name
|
|
1597
|
-
description?: string;
|
|
1598
|
-
querytype?: number; // 0 = Saved view
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
/**
|
|
1602
|
-
* Create a new System View (savedquery)
|
|
1603
|
-
* @returns The ID of the created view
|
|
1604
|
-
*/
|
|
1605
|
-
export async function createSavedQuery(data: CreateSavedQueryData): Promise<string> {
|
|
1606
|
-
if (!isDataverseAvailable()) {
|
|
1607
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
debugLog("viewAPI", `📡 CREATE SavedQuery: "${data.name}"`);
|
|
1611
|
-
|
|
1612
|
-
try {
|
|
1613
|
-
const record = {
|
|
1614
|
-
name: data.name,
|
|
1615
|
-
fetchxml: data.fetchxml,
|
|
1616
|
-
layoutxml: data.layoutxml,
|
|
1617
|
-
returnedtypecode: data.returnedtypecode,
|
|
1618
|
-
description: data.description || null,
|
|
1619
|
-
querytype: data.querytype ?? 0,
|
|
1620
|
-
};
|
|
1621
|
-
|
|
1622
|
-
const result = await window.dataverseAPI!.create("savedquery", record);
|
|
1623
|
-
|
|
1624
|
-
debugLog("viewAPI", `✅ SavedQuery created: ${result.id}`);
|
|
1625
|
-
return result.id;
|
|
1626
|
-
} catch (error) {
|
|
1627
|
-
console.error("createSavedQuery: Failed:", error);
|
|
1628
|
-
throw error;
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
/**
|
|
1633
|
-
* Update an existing System View (savedquery)
|
|
1634
|
-
*/
|
|
1635
|
-
export async function updateSavedQuery(
|
|
1636
|
-
savedQueryId: string,
|
|
1637
|
-
data: Partial<CreateSavedQueryData>
|
|
1638
|
-
): Promise<void> {
|
|
1639
|
-
if (!isDataverseAvailable()) {
|
|
1640
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
debugLog("viewAPI", `📡 UPDATE SavedQuery: ${savedQueryId}`);
|
|
1644
|
-
|
|
1645
|
-
try {
|
|
1646
|
-
const record: Record<string, unknown> = {};
|
|
1647
|
-
if (data.name !== undefined) record.name = data.name;
|
|
1648
|
-
if (data.fetchxml !== undefined) record.fetchxml = data.fetchxml;
|
|
1649
|
-
if (data.layoutxml !== undefined) record.layoutxml = data.layoutxml;
|
|
1650
|
-
if (data.description !== undefined) record.description = data.description;
|
|
1651
|
-
|
|
1652
|
-
await window.dataverseAPI!.update("savedquery", savedQueryId, record);
|
|
1653
|
-
|
|
1654
|
-
debugLog("viewAPI", `✅ SavedQuery updated: ${savedQueryId}`);
|
|
1655
|
-
} catch (error) {
|
|
1656
|
-
console.error("updateSavedQuery: Failed:", error);
|
|
1657
|
-
throw error;
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
/**
|
|
1662
|
-
* Create a new Personal View (userquery)
|
|
1663
|
-
* @returns The ID of the created view
|
|
1664
|
-
*/
|
|
1665
|
-
export async function createUserQuery(data: CreateUserQueryData): Promise<string> {
|
|
1666
|
-
if (!isDataverseAvailable()) {
|
|
1667
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
debugLog("viewAPI", `📡 CREATE UserQuery: "${data.name}"`);
|
|
1671
|
-
|
|
1672
|
-
try {
|
|
1673
|
-
const record = {
|
|
1674
|
-
name: data.name,
|
|
1675
|
-
fetchxml: data.fetchxml,
|
|
1676
|
-
layoutxml: data.layoutxml,
|
|
1677
|
-
returnedtypecode: data.returnedtypecode,
|
|
1678
|
-
description: data.description || null,
|
|
1679
|
-
querytype: data.querytype ?? 0,
|
|
1680
|
-
};
|
|
1681
|
-
|
|
1682
|
-
const result = await window.dataverseAPI!.create("userquery", record);
|
|
1683
|
-
|
|
1684
|
-
debugLog("viewAPI", `✅ UserQuery created: ${result.id}`);
|
|
1685
|
-
return result.id;
|
|
1686
|
-
} catch (error) {
|
|
1687
|
-
console.error("createUserQuery: Failed:", error);
|
|
1688
|
-
throw error;
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
/**
|
|
1693
|
-
* Update an existing Personal View (userquery)
|
|
1694
|
-
*/
|
|
1695
|
-
export async function updateUserQuery(
|
|
1696
|
-
userQueryId: string,
|
|
1697
|
-
data: Partial<CreateUserQueryData>
|
|
1698
|
-
): Promise<void> {
|
|
1699
|
-
if (!isDataverseAvailable()) {
|
|
1700
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
debugLog("viewAPI", `📡 UPDATE UserQuery: ${userQueryId}`);
|
|
1704
|
-
|
|
1705
|
-
try {
|
|
1706
|
-
const record: Record<string, unknown> = {};
|
|
1707
|
-
if (data.name !== undefined) record.name = data.name;
|
|
1708
|
-
if (data.fetchxml !== undefined) record.fetchxml = data.fetchxml;
|
|
1709
|
-
if (data.layoutxml !== undefined) record.layoutxml = data.layoutxml;
|
|
1710
|
-
if (data.description !== undefined) record.description = data.description;
|
|
1711
|
-
|
|
1712
|
-
await window.dataverseAPI!.update("userquery", userQueryId, record);
|
|
1713
|
-
|
|
1714
|
-
debugLog("viewAPI", `✅ UserQuery updated: ${userQueryId}`);
|
|
1715
|
-
} catch (error) {
|
|
1716
|
-
console.error("updateUserQuery: Failed:", error);
|
|
1717
|
-
throw error;
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
/**
|
|
1722
|
-
* Add a component to a solution
|
|
1723
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/addsolutioncomponent
|
|
1724
|
-
*/
|
|
1725
|
-
export async function addSolutionComponent(
|
|
1726
|
-
componentId: string,
|
|
1727
|
-
componentType: number,
|
|
1728
|
-
solutionUniqueName: string,
|
|
1729
|
-
addRequiredComponents: boolean = false
|
|
1730
|
-
): Promise<void> {
|
|
1731
|
-
if (!isDataverseAvailable()) {
|
|
1732
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
debugLog(
|
|
1736
|
-
"viewAPI",
|
|
1737
|
-
`📡 AddSolutionComponent: ${componentId} (type=${componentType}) to "${solutionUniqueName}"`
|
|
1738
|
-
);
|
|
1739
|
-
|
|
1740
|
-
try {
|
|
1741
|
-
await window.dataverseAPI!.execute({
|
|
1742
|
-
operationName: "AddSolutionComponent",
|
|
1743
|
-
operationType: "action",
|
|
1744
|
-
parameters: {
|
|
1745
|
-
ComponentId: componentId,
|
|
1746
|
-
ComponentType: componentType,
|
|
1747
|
-
SolutionUniqueName: solutionUniqueName,
|
|
1748
|
-
AddRequiredComponents: addRequiredComponents,
|
|
1749
|
-
},
|
|
1750
|
-
});
|
|
1751
|
-
|
|
1752
|
-
debugLog("viewAPI", `✅ Component added to solution: ${componentId} → ${solutionUniqueName}`);
|
|
1753
|
-
} catch (error) {
|
|
1754
|
-
console.error("addSolutionComponent: Failed:", error);
|
|
1755
|
-
throw error;
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
/**
|
|
1760
|
-
* Component type for savedquery (system views)
|
|
1761
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/solutioncomponent
|
|
1762
|
-
*/
|
|
1763
|
-
export const COMPONENT_TYPE_SAVEDQUERY = 26;
|
|
1764
|
-
|
|
1765
|
-
/**
|
|
1766
|
-
* Publish a specific savedquery
|
|
1767
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/publishxml
|
|
1768
|
-
*/
|
|
1769
|
-
export async function publishSavedQuery(savedQueryId: string): Promise<void> {
|
|
1770
|
-
if (!isDataverseAvailable()) {
|
|
1771
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
debugLog("viewAPI", `📡 PublishXml for SavedQuery: ${savedQueryId}`);
|
|
1775
|
-
|
|
1776
|
-
try {
|
|
1777
|
-
// Build ParameterXml to publish a specific savedquery
|
|
1778
|
-
const parameterXml = `<importexportxml><savedqueries><savedquery>{${savedQueryId}}</savedquery></savedqueries></importexportxml>`;
|
|
1779
|
-
|
|
1780
|
-
await window.dataverseAPI!.execute({
|
|
1781
|
-
operationName: "PublishXml",
|
|
1782
|
-
operationType: "action",
|
|
1783
|
-
parameters: {
|
|
1784
|
-
ParameterXml: parameterXml,
|
|
1785
|
-
},
|
|
1786
|
-
});
|
|
1787
|
-
|
|
1788
|
-
debugLog("viewAPI", `✅ SavedQuery published: ${savedQueryId}`);
|
|
1789
|
-
} catch (error) {
|
|
1790
|
-
console.error("publishSavedQuery: Failed:", error);
|
|
1791
|
-
throw error;
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
/**
|
|
1796
|
-
* Check if a solution is managed by unique name
|
|
1797
|
-
* @returns true if solution is managed, false if unmanaged, null if not found
|
|
1798
|
-
*/
|
|
1799
|
-
export async function isSolutionManaged(solutionUniqueName: string): Promise<boolean | null> {
|
|
1800
|
-
if (!isDataverseAvailable()) {
|
|
1801
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
debugLog("viewAPI", `📡 Check if solution "${solutionUniqueName}" is managed`);
|
|
1805
|
-
|
|
1806
|
-
try {
|
|
1807
|
-
const query = `solutions?$select=solutionid,ismanaged&$filter=uniquename eq '${solutionUniqueName}'`;
|
|
1808
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
1809
|
-
|
|
1810
|
-
if (!result.value || result.value.length === 0) {
|
|
1811
|
-
debugLog("viewAPI", `⚠️ Solution "${solutionUniqueName}" not found`);
|
|
1812
|
-
return null;
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
const isManaged = result.value[0].ismanaged as boolean;
|
|
1816
|
-
debugLog("viewAPI", `✅ Solution "${solutionUniqueName}" ismanaged=${isManaged}`);
|
|
1817
|
-
return isManaged;
|
|
1818
|
-
} catch (error) {
|
|
1819
|
-
console.error("isSolutionManaged: Failed:", error);
|
|
1820
|
-
throw error;
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
/**
|
|
1825
|
-
* Get solution ID by unique name
|
|
1826
|
-
* @returns Solution ID or null if not found
|
|
1827
|
-
*/
|
|
1828
|
-
export async function getSolutionIdByUniqueName(
|
|
1829
|
-
solutionUniqueName: string
|
|
1830
|
-
): Promise<string | null> {
|
|
1831
|
-
if (!isDataverseAvailable()) {
|
|
1832
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
debugLog("viewAPI", `📡 Get solution ID for "${solutionUniqueName}"`);
|
|
1836
|
-
|
|
1837
|
-
try {
|
|
1838
|
-
const query = `solutions?$select=solutionid&$filter=uniquename eq '${solutionUniqueName}'`;
|
|
1839
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
1840
|
-
|
|
1841
|
-
if (!result.value || result.value.length === 0) {
|
|
1842
|
-
debugLog("viewAPI", `⚠️ Solution "${solutionUniqueName}" not found`);
|
|
1843
|
-
return null;
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
const solutionId = result.value[0].solutionid as string;
|
|
1847
|
-
debugLog("viewAPI", `✅ Solution ID: ${solutionId}`);
|
|
1848
|
-
return solutionId;
|
|
1849
|
-
} catch (error) {
|
|
1850
|
-
console.error("getSolutionIdByUniqueName: Failed:", error);
|
|
1851
|
-
throw error;
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
/**
|
|
1856
|
-
* Export to Excel using Dataverse ExportToExcel action
|
|
1857
|
-
* Requires a saved view (system or personal)
|
|
1858
|
-
*
|
|
1859
|
-
* The ExportToExcel action expects:
|
|
1860
|
-
* - View: Object with @odata.type and view ID (savedqueryid or userqueryid)
|
|
1861
|
-
* - FetchXml: The FetchXML query string
|
|
1862
|
-
* - LayoutXml: The LayoutXML for column configuration
|
|
1863
|
-
* - QueryApi: Empty string
|
|
1864
|
-
* - QueryParameters: Object with Arguments
|
|
1865
|
-
*
|
|
1866
|
-
* @param viewId - The savedqueryid (system) or userqueryid (personal)
|
|
1867
|
-
* @param viewType - Whether it's a system or personal view
|
|
1868
|
-
* @param fetchXml - The FetchXML query
|
|
1869
|
-
* @param layoutXml - The LayoutXML for column configuration
|
|
1870
|
-
* @param viewName - Name of the view for the filename
|
|
1871
|
-
* @returns Base64 encoded Excel file data
|
|
1872
|
-
*/
|
|
1873
|
-
export async function exportToExcel(
|
|
1874
|
-
viewId: string,
|
|
1875
|
-
viewType: "system" | "personal",
|
|
1876
|
-
fetchXml: string,
|
|
1877
|
-
layoutXml: string,
|
|
1878
|
-
viewName: string
|
|
1879
|
-
): Promise<{ excelFile: string; filename: string }> {
|
|
1880
|
-
if (!isDataverseAvailable()) {
|
|
1881
|
-
throw new Error("PPTB Dataverse API not available");
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
|
-
// Build the View object based on view type
|
|
1885
|
-
const viewObject =
|
|
1886
|
-
viewType === "system"
|
|
1887
|
-
? {
|
|
1888
|
-
"@odata.type": "Microsoft.Dynamics.CRM.savedquery",
|
|
1889
|
-
savedqueryid: viewId,
|
|
1890
|
-
}
|
|
1891
|
-
: {
|
|
1892
|
-
"@odata.type": "Microsoft.Dynamics.CRM.userquery",
|
|
1893
|
-
userqueryid: viewId,
|
|
1894
|
-
};
|
|
1895
|
-
|
|
1896
|
-
debugLog("exportAPI", `📡 ExportToExcel: ${viewType} view ${viewId}`);
|
|
1897
|
-
debugLog("exportAPI", `📡 View object:`, viewObject);
|
|
1898
|
-
debugLog(
|
|
1899
|
-
"exportAPI",
|
|
1900
|
-
`📡 FetchXML length: ${fetchXml.length}, LayoutXML length: ${layoutXml.length}`
|
|
1901
|
-
);
|
|
1902
|
-
|
|
1903
|
-
try {
|
|
1904
|
-
// Build the ExportToExcel request matching Dataverse format
|
|
1905
|
-
const result = (await window.dataverseAPI!.execute({
|
|
1906
|
-
operationName: "ExportToExcel",
|
|
1907
|
-
operationType: "action",
|
|
1908
|
-
parameters: {
|
|
1909
|
-
View: viewObject,
|
|
1910
|
-
FetchXml: fetchXml,
|
|
1911
|
-
LayoutXml: layoutXml,
|
|
1912
|
-
QueryApi: "",
|
|
1913
|
-
QueryParameters: {
|
|
1914
|
-
Arguments: {
|
|
1915
|
-
Count: 0,
|
|
1916
|
-
IsReadOnly: true,
|
|
1917
|
-
Keys: [],
|
|
1918
|
-
Values: [],
|
|
1919
|
-
},
|
|
1920
|
-
},
|
|
1921
|
-
},
|
|
1922
|
-
})) as { ExcelFile: string };
|
|
1923
|
-
|
|
1924
|
-
// Generate filename with view name and timestamp
|
|
1925
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1926
|
-
const filename = `${viewName} - ${timestamp}.xlsx`;
|
|
1927
|
-
|
|
1928
|
-
debugLog(
|
|
1929
|
-
"exportAPI",
|
|
1930
|
-
`✅ ExportToExcel successful, file size: ${result.ExcelFile?.length || 0} bytes`
|
|
1931
|
-
);
|
|
1932
|
-
|
|
1933
|
-
return {
|
|
1934
|
-
excelFile: result.ExcelFile,
|
|
1935
|
-
filename,
|
|
1936
|
-
};
|
|
1937
|
-
} catch (error) {
|
|
1938
|
-
console.error("exportToExcel: Failed:", error);
|
|
1939
|
-
throw error;
|
|
1940
|
-
}
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
|
-
/**
|
|
1944
|
-
* Download a Base64 encoded file
|
|
1945
|
-
* @param base64Data - The Base64 encoded file data
|
|
1946
|
-
* @param filename - The filename for the download
|
|
1947
|
-
* @param mimeType - The MIME type of the file
|
|
1948
|
-
*/
|
|
1949
|
-
export function downloadBase64File(
|
|
1950
|
-
base64Data: string,
|
|
1951
|
-
filename: string,
|
|
1952
|
-
mimeType: string = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1953
|
-
): void {
|
|
1954
|
-
try {
|
|
1955
|
-
// Convert Base64 to Blob
|
|
1956
|
-
const byteCharacters = atob(base64Data);
|
|
1957
|
-
const byteNumbers = new Array(byteCharacters.length);
|
|
1958
|
-
for (let i = 0; i < byteCharacters.length; i++) {
|
|
1959
|
-
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
1960
|
-
}
|
|
1961
|
-
const byteArray = new Uint8Array(byteNumbers);
|
|
1962
|
-
const blob = new Blob([byteArray], { type: mimeType });
|
|
1963
|
-
|
|
1964
|
-
// Create download link
|
|
1965
|
-
const url = URL.createObjectURL(blob);
|
|
1966
|
-
const link = document.createElement("a");
|
|
1967
|
-
link.href = url;
|
|
1968
|
-
link.download = filename;
|
|
1969
|
-
|
|
1970
|
-
// Trigger download
|
|
1971
|
-
document.body.appendChild(link);
|
|
1972
|
-
link.click();
|
|
1973
|
-
document.body.removeChild(link);
|
|
1974
|
-
|
|
1975
|
-
// Clean up
|
|
1976
|
-
URL.revokeObjectURL(url);
|
|
1977
|
-
|
|
1978
|
-
debugLog("exportAPI", `✅ File downloaded: ${filename}`);
|
|
1979
|
-
} catch (error) {
|
|
1980
|
-
console.error("downloadBase64File: Failed:", error);
|
|
1981
|
-
throw error;
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
// ============================================================================
|
|
1986
|
-
// RECORD ACTIONS
|
|
1987
|
-
// ============================================================================
|
|
1988
|
-
|
|
1989
|
-
/**
|
|
1990
|
-
* Get the Dataverse environment URL from the active connection
|
|
1991
|
-
*/
|
|
1992
|
-
export async function getEnvironmentUrl(): Promise<string | null> {
|
|
1993
|
-
try {
|
|
1994
|
-
if (typeof window !== "undefined" && window.toolboxAPI?.connections?.getActiveConnection) {
|
|
1995
|
-
const conn = await window.toolboxAPI.connections.getActiveConnection();
|
|
1996
|
-
return conn?.url || null;
|
|
1997
|
-
}
|
|
1998
|
-
return null;
|
|
1999
|
-
} catch (error) {
|
|
2000
|
-
console.error("getEnvironmentUrl: Failed:", error);
|
|
2001
|
-
return null;
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
/**
|
|
2006
|
-
* Build a record URL for opening in browser
|
|
2007
|
-
* @param entityName Logical name of the entity
|
|
2008
|
-
* @param recordId GUID of the record
|
|
2009
|
-
* @param environmentUrl Base URL of the environment
|
|
2010
|
-
*/
|
|
2011
|
-
export function buildRecordUrl(
|
|
2012
|
-
entityName: string,
|
|
2013
|
-
recordId: string,
|
|
2014
|
-
environmentUrl: string
|
|
2015
|
-
): string {
|
|
2016
|
-
// Remove trailing slash from environmentUrl if present to avoid double slashes
|
|
2017
|
-
const baseUrl = environmentUrl.endsWith("/") ? environmentUrl.slice(0, -1) : environmentUrl;
|
|
2018
|
-
// Standard Dynamics 365 record URL format
|
|
2019
|
-
return `${baseUrl}/main.aspx?etn=${entityName}&id=${recordId}&pagetype=entityrecord`;
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
/**
|
|
2023
|
-
* Delete a single record
|
|
2024
|
-
* @param entitySetName OData entity set name (e.g., "accounts")
|
|
2025
|
-
* @param recordId GUID of the record to delete
|
|
2026
|
-
*/
|
|
2027
|
-
export async function deleteRecord(entitySetName: string, recordId: string): Promise<void> {
|
|
2028
|
-
if (!isDataverseAvailable()) {
|
|
2029
|
-
throw new Error("PPTB Dataverse API not available");
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
debugLog("recordAPI", `📡 Deleting record: ${entitySetName}(${recordId})`);
|
|
2033
|
-
|
|
2034
|
-
try {
|
|
2035
|
-
await window.dataverseAPI!.delete(entitySetName, recordId);
|
|
2036
|
-
debugLog("recordAPI", `✅ Record deleted: ${entitySetName}(${recordId})`);
|
|
2037
|
-
} catch (error) {
|
|
2038
|
-
console.error("deleteRecord: Failed:", error);
|
|
2039
|
-
throw error;
|
|
2040
|
-
}
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
/**
|
|
2044
|
-
* Check if user has privilege for bulk delete
|
|
2045
|
-
*/
|
|
2046
|
-
export async function checkBulkDeletePrivilege(): Promise<boolean> {
|
|
2047
|
-
try {
|
|
2048
|
-
const user = await whoAmI();
|
|
2049
|
-
if (!user) return false;
|
|
2050
|
-
return await checkPrivilegeByName(user.UserId, "prvBulkDelete");
|
|
2051
|
-
} catch (error) {
|
|
2052
|
-
console.error("checkBulkDeletePrivilege: Failed:", error);
|
|
2053
|
-
return false;
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
/**
|
|
2058
|
-
* Convert FetchXML to QueryExpression using the FetchXmlToQueryExpression function
|
|
2059
|
-
* This function must be called via GET request with URL-encoded parameters
|
|
2060
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/fetchxmltoqueryexpression
|
|
2061
|
-
*/
|
|
2062
|
-
export async function fetchXmlToQueryExpression(fetchXml: string): Promise<unknown> {
|
|
2063
|
-
if (!isDataverseAvailable()) {
|
|
2064
|
-
throw new Error("PPTB Dataverse API not available");
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
debugLog("recordAPI", `📡 Converting FetchXML to QueryExpression`);
|
|
2068
|
-
|
|
2069
|
-
try {
|
|
2070
|
-
// FetchXmlToQueryExpression is a function - must be called via GET with URL-encoded FetchXml
|
|
2071
|
-
// The parameter must be properly escaped and wrapped in single quotes
|
|
2072
|
-
const encodedFetchXml = encodeURIComponent(fetchXml);
|
|
2073
|
-
const functionUrl = `FetchXmlToQueryExpression(FetchXml=@p1)?@p1='${encodedFetchXml}'`;
|
|
2074
|
-
|
|
2075
|
-
const result = await window.dataverseAPI!.queryData(functionUrl);
|
|
2076
|
-
|
|
2077
|
-
debugLog("recordAPI", `✅ Converted FetchXML to QueryExpression`, result);
|
|
2078
|
-
return (result as { Query?: unknown }).Query || result;
|
|
2079
|
-
} catch (error) {
|
|
2080
|
-
console.error("fetchXmlToQueryExpression: Failed:", error);
|
|
2081
|
-
throw error;
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
/**
|
|
2086
|
-
* Get the bulkdeleteoperationid from an asyncoperationid
|
|
2087
|
-
* The BulkDelete action returns an asyncoperationid, but we need the bulkdeleteoperationid
|
|
2088
|
-
* to link to the correct record in the UI
|
|
2089
|
-
* @param asyncOperationId The asyncoperationid returned from BulkDelete action
|
|
2090
|
-
* @returns The bulkdeleteoperationid or empty string if not found
|
|
2091
|
-
*/
|
|
2092
|
-
async function getBulkDeleteOperationId(asyncOperationId: string): Promise<string> {
|
|
2093
|
-
if (!asyncOperationId || !isDataverseAvailable()) {
|
|
2094
|
-
return "";
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
try {
|
|
2098
|
-
// Query bulkdeleteoperations to find the one with this asyncoperationid
|
|
2099
|
-
const query = `bulkdeleteoperations?$select=bulkdeleteoperationid&$filter=_asyncoperationid_value eq ${asyncOperationId}`;
|
|
2100
|
-
const result = await window.dataverseAPI!.queryData(query);
|
|
2101
|
-
|
|
2102
|
-
const operations = result.value as Array<{ bulkdeleteoperationid?: string }>;
|
|
2103
|
-
if (operations && operations.length > 0 && operations[0].bulkdeleteoperationid) {
|
|
2104
|
-
debugLog(
|
|
2105
|
-
"recordAPI",
|
|
2106
|
-
`✅ Found bulkdeleteoperationid: ${operations[0].bulkdeleteoperationid}`
|
|
2107
|
-
);
|
|
2108
|
-
return operations[0].bulkdeleteoperationid;
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
debugLog(
|
|
2112
|
-
"recordAPI",
|
|
2113
|
-
`⚠️ No bulkdeleteoperationid found for asyncoperationid: ${asyncOperationId}`
|
|
2114
|
-
);
|
|
2115
|
-
return "";
|
|
2116
|
-
} catch (error) {
|
|
2117
|
-
console.error("getBulkDeleteOperationId: Failed:", error);
|
|
2118
|
-
return "";
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
/**
|
|
2123
|
-
* Submit a bulk delete job for selected records
|
|
2124
|
-
* Uses FetchXmlToQueryExpression function to convert FetchXML to QueryExpression
|
|
2125
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/fetchxmltoqueryexpression
|
|
2126
|
-
* @param entityLogicalName Logical name of the entity
|
|
2127
|
-
* @param primaryIdAttribute Primary ID attribute name (e.g., "accountid")
|
|
2128
|
-
* @param recordIds Array of record GUIDs to delete
|
|
2129
|
-
* @param jobName Name for the bulk delete job
|
|
2130
|
-
* @returns AsyncOperationId and BulkDeleteOperationId for tracking the job
|
|
2131
|
-
*/
|
|
2132
|
-
export async function submitBulkDelete(
|
|
2133
|
-
entityLogicalName: string,
|
|
2134
|
-
primaryIdAttribute: string,
|
|
2135
|
-
recordIds: string[],
|
|
2136
|
-
jobName: string
|
|
2137
|
-
): Promise<{ asyncOperationId: string; bulkDeleteOperationId: string; jobUrl: string }> {
|
|
2138
|
-
if (!isDataverseAvailable()) {
|
|
2139
|
-
throw new Error("PPTB Dataverse API not available");
|
|
2140
|
-
}
|
|
2141
|
-
|
|
2142
|
-
debugLog(
|
|
2143
|
-
"recordAPI",
|
|
2144
|
-
`📡 Submitting bulk delete job: ${jobName} for ${recordIds.length} records`
|
|
2145
|
-
);
|
|
2146
|
-
|
|
2147
|
-
try {
|
|
2148
|
-
// Build FetchXML to select records by their IDs
|
|
2149
|
-
// Use <value> elements for the "in" operator
|
|
2150
|
-
const valuesXml = recordIds.map((id) => `<value>${id}</value>`).join("");
|
|
2151
|
-
const fetchXml = `<fetch>
|
|
2152
|
-
<entity name="${entityLogicalName}">
|
|
2153
|
-
<attribute name="${primaryIdAttribute}" />
|
|
2154
|
-
<filter>
|
|
2155
|
-
<condition attribute="${primaryIdAttribute}" operator="in">
|
|
2156
|
-
${valuesXml}
|
|
2157
|
-
</condition>
|
|
2158
|
-
</filter>
|
|
2159
|
-
</entity>
|
|
2160
|
-
</fetch>`;
|
|
2161
|
-
|
|
2162
|
-
// Convert FetchXML to QueryExpression using the Dataverse function
|
|
2163
|
-
const queryExpression = await fetchXmlToQueryExpression(fetchXml);
|
|
2164
|
-
|
|
2165
|
-
// Submit BulkDelete with the converted QueryExpression
|
|
2166
|
-
const result = (await window.dataverseAPI!.execute({
|
|
2167
|
-
operationName: "BulkDelete",
|
|
2168
|
-
operationType: "action",
|
|
2169
|
-
parameters: {
|
|
2170
|
-
QuerySet: [queryExpression],
|
|
2171
|
-
JobName: jobName,
|
|
2172
|
-
SendEmailNotification: false,
|
|
2173
|
-
ToRecipients: [],
|
|
2174
|
-
CCRecipients: [],
|
|
2175
|
-
RecurrencePattern: "",
|
|
2176
|
-
StartDateTime: new Date().toISOString(),
|
|
2177
|
-
},
|
|
2178
|
-
})) as { JobId?: string; AsyncOperationId?: string };
|
|
2179
|
-
|
|
2180
|
-
const asyncOpId = result.AsyncOperationId || result.JobId || "";
|
|
2181
|
-
|
|
2182
|
-
// Query for the bulkdeleteoperationid using the asyncoperationid
|
|
2183
|
-
const bulkDeleteOpId = await getBulkDeleteOperationId(asyncOpId);
|
|
2184
|
-
|
|
2185
|
-
// Build job tracking URL using bulkdeleteoperation entity
|
|
2186
|
-
const envUrl = await getEnvironmentUrl();
|
|
2187
|
-
const baseUrl = envUrl?.endsWith("/") ? envUrl.slice(0, -1) : envUrl;
|
|
2188
|
-
const jobUrl =
|
|
2189
|
-
baseUrl && bulkDeleteOpId
|
|
2190
|
-
? `${baseUrl}/main.aspx?pagetype=entityrecord&etn=bulkdeleteoperation&id=${bulkDeleteOpId}`
|
|
2191
|
-
: "";
|
|
2192
|
-
|
|
2193
|
-
debugLog(
|
|
2194
|
-
"recordAPI",
|
|
2195
|
-
`✅ Bulk delete job submitted: asyncOpId=${asyncOpId}, bulkDeleteOpId=${bulkDeleteOpId}`
|
|
2196
|
-
);
|
|
2197
|
-
|
|
2198
|
-
return {
|
|
2199
|
-
asyncOperationId: asyncOpId,
|
|
2200
|
-
bulkDeleteOperationId: bulkDeleteOpId,
|
|
2201
|
-
jobUrl,
|
|
2202
|
-
};
|
|
2203
|
-
} catch (error) {
|
|
2204
|
-
console.error("submitBulkDelete: Failed:", error);
|
|
2205
|
-
throw error;
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
// ============================================================================
|
|
2210
|
-
// WORKFLOW ACTIONS
|
|
2211
|
-
// ============================================================================
|
|
2212
|
-
|
|
2213
|
-
export interface WorkflowInfo {
|
|
2214
|
-
workflowid: string;
|
|
2215
|
-
name: string;
|
|
2216
|
-
description?: string;
|
|
2217
|
-
primaryentity: string;
|
|
2218
|
-
category: number; // 0 = Workflow, 2 = Business Rule, etc.
|
|
2219
|
-
type: number; // 1 = Definition, 2 = Activation, 3 = Template
|
|
2220
|
-
statuscode: number;
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
/**
|
|
2224
|
-
* Check if user has privileges to read and execute workflows
|
|
2225
|
-
* Returns true if user can both read workflows and execute them
|
|
2226
|
-
*/
|
|
2227
|
-
export async function checkWorkflowPrivileges(): Promise<boolean> {
|
|
2228
|
-
try {
|
|
2229
|
-
const user = await whoAmI();
|
|
2230
|
-
if (!user) return false;
|
|
2231
|
-
|
|
2232
|
-
const [canRead, canExecute] = await Promise.all([
|
|
2233
|
-
checkPrivilegeByName(user.UserId, "prvReadWorkflow"),
|
|
2234
|
-
checkPrivilegeByName(user.UserId, "prvWorkflowExecution"),
|
|
2235
|
-
]);
|
|
2236
|
-
|
|
2237
|
-
return canRead && canExecute;
|
|
2238
|
-
} catch (error) {
|
|
2239
|
-
console.error("checkWorkflowPrivileges: Failed:", error);
|
|
2240
|
-
return false;
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
|
-
|
|
2244
|
-
/**
|
|
2245
|
-
* Get available on-demand workflows for an entity
|
|
2246
|
-
* @param entityLogicalName Logical name of the entity
|
|
2247
|
-
* @returns List of workflows that can be run on-demand
|
|
2248
|
-
*/
|
|
2249
|
-
export async function getOnDemandWorkflows(entityLogicalName: string): Promise<WorkflowInfo[]> {
|
|
2250
|
-
if (!isDataverseAvailable()) {
|
|
2251
|
-
throw new Error("PPTB Dataverse API not available");
|
|
2252
|
-
}
|
|
2253
|
-
|
|
2254
|
-
debugLog("workflowAPI", `📡 Getting on-demand workflows for: ${entityLogicalName}`);
|
|
2255
|
-
|
|
2256
|
-
try {
|
|
2257
|
-
// Query for active, on-demand workflows for this entity using OData
|
|
2258
|
-
// statecode=1 (Activated), statuscode=2 (Activated), ondemand=true, category=0 (Workflow), type=1 (Definition)
|
|
2259
|
-
// primaryentity must be quoted as a string value
|
|
2260
|
-
const odataQuery = `workflows?$select=workflowid,name,description,primaryentity,category,type,statuscode&$filter=(statecode eq 1 and statuscode eq 2 and ondemand eq true and category eq 0 and type eq 1 and primaryentity eq '${entityLogicalName}')&$orderby=name asc`;
|
|
2261
|
-
|
|
2262
|
-
const result = await window.dataverseAPI!.queryData(odataQuery);
|
|
2263
|
-
|
|
2264
|
-
// Map the records to WorkflowInfo type
|
|
2265
|
-
const workflows: WorkflowInfo[] = (result.value || []).map((record) => ({
|
|
2266
|
-
workflowid: String(record.workflowid || ""),
|
|
2267
|
-
name: String(record.name || ""),
|
|
2268
|
-
description: record.description ? String(record.description) : undefined,
|
|
2269
|
-
primaryentity: String(record.primaryentity || ""),
|
|
2270
|
-
category: Number(record.category || 0),
|
|
2271
|
-
type: Number(record.type || 0),
|
|
2272
|
-
statuscode: Number(record.statuscode || 0),
|
|
2273
|
-
}));
|
|
2274
|
-
|
|
2275
|
-
debugLog("workflowAPI", `✅ Found ${workflows.length} on-demand workflows`);
|
|
2276
|
-
|
|
2277
|
-
return workflows;
|
|
2278
|
-
} catch (error) {
|
|
2279
|
-
console.error("getOnDemandWorkflows: Failed:", error);
|
|
2280
|
-
throw error;
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
/**
|
|
2285
|
-
* Execute a workflow on a specific record
|
|
2286
|
-
* @param workflowId GUID of the workflow to execute
|
|
2287
|
-
* @param recordId GUID of the record to run the workflow on
|
|
2288
|
-
* @param entityLogicalName Logical name of the entity (for the target EntityReference)
|
|
2289
|
-
*/
|
|
2290
|
-
export async function executeWorkflow(
|
|
2291
|
-
workflowId: string,
|
|
2292
|
-
recordId: string,
|
|
2293
|
-
entityLogicalName: string
|
|
2294
|
-
): Promise<void> {
|
|
2295
|
-
if (!isDataverseAvailable()) {
|
|
2296
|
-
throw new Error("PPTB Dataverse API not available");
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
debugLog(
|
|
2300
|
-
"workflowAPI",
|
|
2301
|
-
`📡 Executing workflow ${workflowId} on ${entityLogicalName}(${recordId})`
|
|
2302
|
-
);
|
|
2303
|
-
|
|
2304
|
-
try {
|
|
2305
|
-
await window.dataverseAPI!.execute({
|
|
2306
|
-
operationName: "ExecuteWorkflow",
|
|
2307
|
-
operationType: "action",
|
|
2308
|
-
entityName: "workflow",
|
|
2309
|
-
entityId: workflowId,
|
|
2310
|
-
parameters: {
|
|
2311
|
-
EntityId: recordId,
|
|
2312
|
-
},
|
|
2313
|
-
});
|
|
2314
|
-
|
|
2315
|
-
debugLog("workflowAPI", `✅ Workflow executed successfully`);
|
|
2316
|
-
} catch (error) {
|
|
2317
|
-
console.error("executeWorkflow: Failed:", error);
|
|
2318
|
-
throw error;
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
/**
|
|
2323
|
-
* Progress info for workflow batch execution
|
|
2324
|
-
*/
|
|
2325
|
-
export interface WorkflowBatchProgress {
|
|
2326
|
-
completed: number;
|
|
2327
|
-
total: number;
|
|
2328
|
-
succeeded: number;
|
|
2329
|
-
failed: number;
|
|
2330
|
-
batchesCompleted: number;
|
|
2331
|
-
totalBatches: number;
|
|
2332
|
-
estimatedSecondsRemaining?: number;
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
/**
|
|
2336
|
-
* Execute a workflow on multiple records using $batch requests for parallelization
|
|
2337
|
-
* Uses parallel execution via PPTB dataverseAPI for performance
|
|
2338
|
-
* @see https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/execute-batch-operations-using-web-api
|
|
2339
|
-
* @param workflowId GUID of the workflow to execute
|
|
2340
|
-
* @param recordIds Array of record GUIDs to run the workflow on
|
|
2341
|
-
* @param entityLogicalName Logical name of the entity (unused but kept for API compatibility)
|
|
2342
|
-
* @param onProgress Callback for progress updates with batch info and ETA
|
|
2343
|
-
* @param batchSize Number of concurrent requests per batch (default: 10)
|
|
2344
|
-
*/
|
|
2345
|
-
export async function executeWorkflowBatch(
|
|
2346
|
-
workflowId: string,
|
|
2347
|
-
recordIds: string[],
|
|
2348
|
-
entityLogicalName: string,
|
|
2349
|
-
onProgress?: (progress: WorkflowBatchProgress) => void,
|
|
2350
|
-
batchSize: number = 10
|
|
2351
|
-
): Promise<{ succeeded: number; failed: number; errors: string[] }> {
|
|
2352
|
-
const result = { succeeded: 0, failed: 0, errors: [] as string[] };
|
|
2353
|
-
const total = recordIds.length;
|
|
2354
|
-
let completed = 0;
|
|
2355
|
-
|
|
2356
|
-
// Split recordIds into batches of batchSize (default 10) for parallel execution
|
|
2357
|
-
const batches: string[][] = [];
|
|
2358
|
-
for (let i = 0; i < recordIds.length; i += batchSize) {
|
|
2359
|
-
batches.push(recordIds.slice(i, i + batchSize));
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
debugLog(
|
|
2363
|
-
"workflowAPI",
|
|
2364
|
-
`📡 Executing workflow ${workflowId} on ${recordIds.length} records in ${batches.length} batches`
|
|
2365
|
-
);
|
|
2366
|
-
|
|
2367
|
-
// Track timing for ETA calculation
|
|
2368
|
-
const batchTimes: number[] = [];
|
|
2369
|
-
let batchesCompleted = 0;
|
|
2370
|
-
|
|
2371
|
-
// Process batches sequentially (each batch processes in parallel)
|
|
2372
|
-
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
|
|
2373
|
-
const batchRecordIds = batches[batchIndex];
|
|
2374
|
-
const batchStartTime = Date.now();
|
|
2375
|
-
|
|
2376
|
-
debugLog(
|
|
2377
|
-
"workflowAPI",
|
|
2378
|
-
`📡 Executing batch ${batchIndex + 1}/${batches.length} with ${batchRecordIds.length} records`
|
|
2379
|
-
);
|
|
2380
|
-
|
|
2381
|
-
// Execute workflows in parallel within the batch
|
|
2382
|
-
const workflowPromises = batchRecordIds.map(async (recordId) => {
|
|
2383
|
-
try {
|
|
2384
|
-
await executeWorkflow(workflowId, recordId, entityLogicalName);
|
|
2385
|
-
return { success: true, recordId };
|
|
2386
|
-
} catch (error) {
|
|
2387
|
-
return {
|
|
2388
|
-
success: false,
|
|
2389
|
-
recordId,
|
|
2390
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2391
|
-
};
|
|
2392
|
-
}
|
|
2393
|
-
});
|
|
2394
|
-
|
|
2395
|
-
const batchResults = await Promise.all(workflowPromises);
|
|
2396
|
-
const batchEndTime = Date.now();
|
|
2397
|
-
const batchTime = batchEndTime - batchStartTime;
|
|
2398
|
-
batchTimes.push(batchTime);
|
|
2399
|
-
|
|
2400
|
-
// Aggregate results
|
|
2401
|
-
for (const execResult of batchResults) {
|
|
2402
|
-
if (execResult.success) {
|
|
2403
|
-
result.succeeded++;
|
|
2404
|
-
} else {
|
|
2405
|
-
result.failed++;
|
|
2406
|
-
result.errors.push(`Record ${execResult.recordId}: ${execResult.error}`);
|
|
2407
|
-
}
|
|
2408
|
-
completed++;
|
|
2409
|
-
}
|
|
2410
|
-
batchesCompleted++;
|
|
2411
|
-
|
|
2412
|
-
// Calculate ETA based on average batch time
|
|
2413
|
-
let estimatedSecondsRemaining: number | undefined;
|
|
2414
|
-
if (batchTimes.length >= 2) {
|
|
2415
|
-
const avgBatchTime = batchTimes.reduce((a, b) => a + b, 0) / batchTimes.length;
|
|
2416
|
-
const remainingBatches = batches.length - batchesCompleted;
|
|
2417
|
-
estimatedSecondsRemaining = Math.ceil((avgBatchTime * remainingBatches) / 1000);
|
|
2418
|
-
}
|
|
2419
|
-
|
|
2420
|
-
onProgress?.({
|
|
2421
|
-
completed,
|
|
2422
|
-
total,
|
|
2423
|
-
succeeded: result.succeeded,
|
|
2424
|
-
failed: result.failed,
|
|
2425
|
-
batchesCompleted,
|
|
2426
|
-
totalBatches: batches.length,
|
|
2427
|
-
estimatedSecondsRemaining,
|
|
2428
|
-
});
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
debugLog(
|
|
2432
|
-
"workflowAPI",
|
|
2433
|
-
`✅ Workflow execution complete: ${result.succeeded} succeeded, ${result.failed} failed`
|
|
2434
|
-
);
|
|
2435
|
-
return result;
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
/**
|
|
2439
|
-
* Check if user has delete privilege for a specific entity
|
|
2440
|
-
*/
|
|
2441
|
-
export async function checkDeletePrivilege(entityLogicalName: string): Promise<boolean> {
|
|
2442
|
-
try {
|
|
2443
|
-
const user = await whoAmI();
|
|
2444
|
-
if (!user) return false;
|
|
2445
|
-
|
|
2446
|
-
// Entity-specific delete privilege (e.g., prvDeleteAccount)
|
|
2447
|
-
const deletePriv = `prvDelete${entityLogicalName
|
|
2448
|
-
.charAt(0)
|
|
2449
|
-
.toUpperCase()}${entityLogicalName.slice(1)}`;
|
|
2450
|
-
return await checkPrivilegeByName(user.UserId, deletePriv);
|
|
2451
|
-
} catch (error) {
|
|
2452
|
-
console.error("checkDeletePrivilege: Failed:", error);
|
|
2453
|
-
return false;
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
// ============================================================================
|
|
2458
|
-
// BATCH DELETE OPERATIONS
|
|
2459
|
-
// ============================================================================
|
|
2460
|
-
|
|
2461
|
-
export interface BatchDeleteProgress {
|
|
2462
|
-
completed: number;
|
|
2463
|
-
total: number;
|
|
2464
|
-
succeeded: number;
|
|
2465
|
-
failed: number;
|
|
2466
|
-
batchesCompleted: number;
|
|
2467
|
-
totalBatches: number;
|
|
2468
|
-
estimatedSecondsRemaining?: number;
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
export interface BatchDeleteResult {
|
|
2472
|
-
succeeded: number;
|
|
2473
|
-
failed: number;
|
|
2474
|
-
errors: string[];
|
|
2475
|
-
}
|
|
2476
|
-
|
|
2477
|
-
/**
|
|
2478
|
-
* Delete multiple records using parallel delete requests via PPTB API
|
|
2479
|
-
* Recommended for 1-100 records. For larger sets, use submitBulkDelete.
|
|
2480
|
-
* Uses batches of parallel requests for performance while respecting rate limits.
|
|
2481
|
-
*
|
|
2482
|
-
* @param entitySetName OData entity set name (e.g., "accounts")
|
|
2483
|
-
* @param recordIds Array of record GUIDs to delete
|
|
2484
|
-
* @param onProgress Callback for progress updates with ETA
|
|
2485
|
-
* @param batchSize Number of concurrent requests per batch (default: 10)
|
|
2486
|
-
*/
|
|
2487
|
-
export async function deleteRecordsBatch(
|
|
2488
|
-
entitySetName: string,
|
|
2489
|
-
recordIds: string[],
|
|
2490
|
-
onProgress?: (progress: BatchDeleteProgress) => void,
|
|
2491
|
-
batchSize: number = 10
|
|
2492
|
-
): Promise<BatchDeleteResult> {
|
|
2493
|
-
const result: BatchDeleteResult = { succeeded: 0, failed: 0, errors: [] };
|
|
2494
|
-
const total = recordIds.length;
|
|
2495
|
-
|
|
2496
|
-
// Split recordIds into batches for parallel processing
|
|
2497
|
-
const batches: string[][] = [];
|
|
2498
|
-
for (let i = 0; i < recordIds.length; i += batchSize) {
|
|
2499
|
-
batches.push(recordIds.slice(i, i + batchSize));
|
|
2500
|
-
}
|
|
2501
|
-
|
|
2502
|
-
debugLog("recordAPI", `📡 Deleting ${recordIds.length} records in ${batches.length} batches`);
|
|
2503
|
-
|
|
2504
|
-
// Track timing for ETA calculation
|
|
2505
|
-
const batchTimes: number[] = [];
|
|
2506
|
-
let completed = 0;
|
|
2507
|
-
let batchesCompleted = 0;
|
|
2508
|
-
|
|
2509
|
-
// Process batches sequentially (each batch processes in parallel)
|
|
2510
|
-
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
|
|
2511
|
-
const batchRecordIds = batches[batchIndex];
|
|
2512
|
-
const batchStartTime = Date.now();
|
|
2513
|
-
|
|
2514
|
-
// Execute deletes in parallel within the batch
|
|
2515
|
-
const deletePromises = batchRecordIds.map(async (recordId) => {
|
|
2516
|
-
try {
|
|
2517
|
-
await deleteRecord(entitySetName, recordId);
|
|
2518
|
-
return { success: true, recordId };
|
|
2519
|
-
} catch (error) {
|
|
2520
|
-
return {
|
|
2521
|
-
success: false,
|
|
2522
|
-
recordId,
|
|
2523
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2524
|
-
};
|
|
2525
|
-
}
|
|
2526
|
-
});
|
|
2527
|
-
|
|
2528
|
-
const batchResults = await Promise.all(deletePromises);
|
|
2529
|
-
const batchEndTime = Date.now();
|
|
2530
|
-
const batchTime = batchEndTime - batchStartTime;
|
|
2531
|
-
batchTimes.push(batchTime);
|
|
2532
|
-
|
|
2533
|
-
// Aggregate results
|
|
2534
|
-
for (const deleteResult of batchResults) {
|
|
2535
|
-
if (deleteResult.success) {
|
|
2536
|
-
result.succeeded++;
|
|
2537
|
-
} else {
|
|
2538
|
-
result.failed++;
|
|
2539
|
-
result.errors.push(`${deleteResult.recordId}: ${deleteResult.error}`);
|
|
2540
|
-
}
|
|
2541
|
-
completed++;
|
|
2542
|
-
}
|
|
2543
|
-
batchesCompleted++;
|
|
2544
|
-
|
|
2545
|
-
// Calculate ETA based on average batch time
|
|
2546
|
-
let estimatedSecondsRemaining: number | undefined;
|
|
2547
|
-
if (batchTimes.length >= 2) {
|
|
2548
|
-
const avgBatchTime = batchTimes.reduce((a, b) => a + b, 0) / batchTimes.length;
|
|
2549
|
-
const remainingBatches = batches.length - batchesCompleted;
|
|
2550
|
-
estimatedSecondsRemaining = Math.ceil((avgBatchTime * remainingBatches) / 1000);
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
onProgress?.({
|
|
2554
|
-
completed,
|
|
2555
|
-
total,
|
|
2556
|
-
succeeded: result.succeeded,
|
|
2557
|
-
failed: result.failed,
|
|
2558
|
-
batchesCompleted,
|
|
2559
|
-
totalBatches: batches.length,
|
|
2560
|
-
estimatedSecondsRemaining,
|
|
2561
|
-
});
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
debugLog(
|
|
2565
|
-
"recordAPI",
|
|
2566
|
-
`✅ Delete complete: ${result.succeeded} succeeded, ${result.failed} failed`
|
|
2567
|
-
);
|
|
2568
|
-
return result;
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
|
-
/**
|
|
2572
|
-
* Submit bulk delete for all records matching a FetchXML query
|
|
2573
|
-
* Use this when no specific records are selected - deletes ALL matching records
|
|
2574
|
-
*
|
|
2575
|
-
* @param fetchXml The FetchXML query defining which records to delete
|
|
2576
|
-
* @param jobName Name for the bulk delete job
|
|
2577
|
-
* @returns AsyncOperationId for tracking the job
|
|
2578
|
-
*/
|
|
2579
|
-
export async function submitBulkDeleteFromFetchXml(
|
|
2580
|
-
fetchXml: string,
|
|
2581
|
-
jobName: string
|
|
2582
|
-
): Promise<{ asyncOperationId: string; bulkDeleteOperationId: string; jobUrl: string }> {
|
|
2583
|
-
if (!isDataverseAvailable()) {
|
|
2584
|
-
throw new Error("PPTB Dataverse API not available");
|
|
2585
|
-
}
|
|
2586
|
-
|
|
2587
|
-
debugLog("recordAPI", `📡 Submitting bulk delete job from FetchXML: ${jobName}`);
|
|
2588
|
-
|
|
2589
|
-
try {
|
|
2590
|
-
// Convert FetchXML to QueryExpression using the Dataverse function
|
|
2591
|
-
const queryExpression = await fetchXmlToQueryExpression(fetchXml);
|
|
2592
|
-
|
|
2593
|
-
// Submit BulkDelete with the converted QueryExpression
|
|
2594
|
-
const result = (await window.dataverseAPI!.execute({
|
|
2595
|
-
operationName: "BulkDelete",
|
|
2596
|
-
operationType: "action",
|
|
2597
|
-
parameters: {
|
|
2598
|
-
QuerySet: [queryExpression],
|
|
2599
|
-
JobName: jobName,
|
|
2600
|
-
SendEmailNotification: false,
|
|
2601
|
-
ToRecipients: [],
|
|
2602
|
-
CCRecipients: [],
|
|
2603
|
-
RecurrencePattern: "",
|
|
2604
|
-
StartDateTime: new Date().toISOString(),
|
|
2605
|
-
},
|
|
2606
|
-
})) as { JobId?: string; AsyncOperationId?: string };
|
|
2607
|
-
|
|
2608
|
-
const asyncOpId = result.AsyncOperationId || result.JobId || "";
|
|
2609
|
-
|
|
2610
|
-
// Query for the bulkdeleteoperationid using the asyncoperationid
|
|
2611
|
-
const bulkDeleteOpId = await getBulkDeleteOperationId(asyncOpId);
|
|
2612
|
-
|
|
2613
|
-
// Build job tracking URL using bulkdeleteoperation entity
|
|
2614
|
-
const envUrl = await getEnvironmentUrl();
|
|
2615
|
-
const baseUrl = envUrl?.endsWith("/") ? envUrl.slice(0, -1) : envUrl;
|
|
2616
|
-
const jobUrl =
|
|
2617
|
-
baseUrl && bulkDeleteOpId
|
|
2618
|
-
? `${baseUrl}/main.aspx?pagetype=entityrecord&etn=bulkdeleteoperation&id=${bulkDeleteOpId}`
|
|
2619
|
-
: "";
|
|
2620
|
-
|
|
2621
|
-
debugLog(
|
|
2622
|
-
"recordAPI",
|
|
2623
|
-
`✅ Bulk delete job submitted: asyncOpId=${asyncOpId}, bulkDeleteOpId=${bulkDeleteOpId}`
|
|
2624
|
-
);
|
|
2625
|
-
|
|
2626
|
-
return {
|
|
2627
|
-
asyncOperationId: asyncOpId,
|
|
2628
|
-
bulkDeleteOperationId: bulkDeleteOpId,
|
|
2629
|
-
jobUrl,
|
|
2630
|
-
};
|
|
2631
|
-
} catch (error) {
|
|
2632
|
-
console.error("submitBulkDeleteFromFetchXml: Failed:", error);
|
|
2633
|
-
throw error;
|
|
2634
|
-
}
|
|
2635
|
-
}
|