@xrmforge/typegen 0.4.0 → 0.5.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/MIGRATION.md +154 -0
- package/dist/index.d.ts +0 -10
- package/dist/index.js +71 -55
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# XrmForge Migration Guide
|
|
2
|
+
|
|
3
|
+
How to convert legacy Dynamics 365 JavaScript to type-safe TypeScript with XrmForge.
|
|
4
|
+
|
|
5
|
+
## Step 1: Initialize Project
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @xrmforge/cli init my-project --prefix contoso
|
|
9
|
+
cd my-project
|
|
10
|
+
npm install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Step 2: Generate Types from Dataverse
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx xrmforge generate \
|
|
17
|
+
--url https://YOUR-ORG.crm4.dynamics.com \
|
|
18
|
+
--auth interactive \
|
|
19
|
+
--tenant-id YOUR-TENANT-ID \
|
|
20
|
+
--client-id YOUR-CLIENT-ID \
|
|
21
|
+
--entities account,contact,opportunity \
|
|
22
|
+
--output ./typings
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This generates:
|
|
26
|
+
- `typings/entities/*.d.ts` - Entity interfaces with typed attributes
|
|
27
|
+
- `typings/forms/*.d.ts` - Form interfaces with Fields enum, Tabs enum, Subgrid enum
|
|
28
|
+
- `typings/optionsets/*.d.ts` - OptionSet const enums with labels
|
|
29
|
+
- `typings/entity-names.d.ts` - EntityNames const enum
|
|
30
|
+
|
|
31
|
+
## Step 3: Convert Form Scripts
|
|
32
|
+
|
|
33
|
+
### Before (legacy JavaScript):
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
// account.js - global functions, raw strings, no type safety
|
|
37
|
+
var LM = LM || {};
|
|
38
|
+
LM.Account = {
|
|
39
|
+
onLoad: function(executionContext) {
|
|
40
|
+
var formContext = executionContext.getFormContext();
|
|
41
|
+
var name = formContext.getAttribute("name"); // generic Attribute
|
|
42
|
+
var status = formContext.getAttribute("statuscode");
|
|
43
|
+
if (status.getValue() === 1) { // magic number!
|
|
44
|
+
formContext.getControl("revenue").setVisible(true);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### After (XrmForge TypeScript):
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// account-form.ts - typed, safe, autocomplete everywhere
|
|
54
|
+
import { AccountMainFormFieldsEnum as Fields } from '../../typings/forms/account';
|
|
55
|
+
|
|
56
|
+
export function onLoad(executionContext: Xrm.Events.EventContext): void {
|
|
57
|
+
const form = executionContext.getFormContext() as XrmForge.Forms.Account.AccountMainForm;
|
|
58
|
+
|
|
59
|
+
// Fields enum: compile error on typos, autocomplete in IDE
|
|
60
|
+
const name = form.getAttribute(Fields.AccountName); // StringAttribute, not generic
|
|
61
|
+
const status = form.getAttribute(Fields.StatusCode); // OptionSetAttribute
|
|
62
|
+
|
|
63
|
+
// OptionSet enum: no magic numbers
|
|
64
|
+
if (status.getValue() === XrmForge.OptionSets.Account.StatusCode.Active) {
|
|
65
|
+
form.getControl(Fields.Revenue).setVisible(true);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Key Differences:
|
|
71
|
+
|
|
72
|
+
| Legacy | XrmForge |
|
|
73
|
+
|--------|----------|
|
|
74
|
+
| `getAttribute("name")` | `getAttribute(Fields.AccountName)` |
|
|
75
|
+
| `getValue() === 1` | `getValue() === OptionSets.StatusCode.Active` |
|
|
76
|
+
| `formContext` (untyped) | `form as AccountMainForm` (typed) |
|
|
77
|
+
| `getControl("revenue")` | `getControl(Fields.Revenue)` |
|
|
78
|
+
| No compile-time checks | Typos are compile errors |
|
|
79
|
+
|
|
80
|
+
## Step 4: Replace Common Patterns
|
|
81
|
+
|
|
82
|
+
### Lookup Values
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// Before:
|
|
86
|
+
var value = formContext.getAttribute("primarycontactid").getValue();
|
|
87
|
+
var id = value[0].id.replace("{","").replace("}","");
|
|
88
|
+
|
|
89
|
+
// After: use parseLookup from @xrmforge/typegen
|
|
90
|
+
import { parseLookup } from '@xrmforge/typegen';
|
|
91
|
+
const contact = parseLookup(form.getAttribute(Fields.PrimaryContactId));
|
|
92
|
+
if (contact) {
|
|
93
|
+
console.log(contact.id); // already clean GUID
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Web API Queries
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// Before:
|
|
101
|
+
Xrm.WebApi.retrieveMultipleRecords("account",
|
|
102
|
+
"?$select=name,revenue&$filter=statecode eq 0");
|
|
103
|
+
|
|
104
|
+
// After: use Fields enum for $select
|
|
105
|
+
import { select } from '@xrmforge/typegen';
|
|
106
|
+
import { AccountFields } from '../../typings/entities/account';
|
|
107
|
+
Xrm.WebApi.retrieveMultipleRecords("account",
|
|
108
|
+
`?$select=${select(AccountFields.Name, AccountFields.Revenue)}&$filter=statecode eq 0`);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Form Testing
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
// Before: no tests, or complex manual mocks
|
|
115
|
+
|
|
116
|
+
// After: @xrmforge/testing
|
|
117
|
+
import { createFormMock, fireOnChange } from '@xrmforge/testing';
|
|
118
|
+
import type { AccountMainForm, AccountMainFormMockValues } from '../../typings/forms/account';
|
|
119
|
+
|
|
120
|
+
const mock = createFormMock<AccountMainForm, AccountMainFormMockValues>({
|
|
121
|
+
name: 'Contoso Ltd',
|
|
122
|
+
revenue: 1000000,
|
|
123
|
+
statuscode: 1,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
onLoad(mock.executionContext);
|
|
127
|
+
expect(mock.formContext.getControl('revenue').getVisible()).toBe(true);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Step 5: Build
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npx xrmforge build # IIFE bundles for D365
|
|
134
|
+
npx xrmforge build --watch # Watch mode
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Step 6: Replace Magic Numbers
|
|
138
|
+
|
|
139
|
+
Search your code for patterns like:
|
|
140
|
+
- `getValue() === 123` or `getValue() !== 456`
|
|
141
|
+
- `setValue("statuscode", 1)`
|
|
142
|
+
- Raw OptionSet values in if/switch statements
|
|
143
|
+
|
|
144
|
+
Replace with generated const enums from `typings/optionsets/`.
|
|
145
|
+
|
|
146
|
+
## Checklist
|
|
147
|
+
|
|
148
|
+
- [ ] All `getAttribute("string")` calls use Fields enum
|
|
149
|
+
- [ ] All OptionSet comparisons use const enums (no magic numbers)
|
|
150
|
+
- [ ] All `Xrm.Page` calls replaced with `formContext`
|
|
151
|
+
- [ ] Form scripts export functions (not global namespace objects)
|
|
152
|
+
- [ ] Each form script has tests using `@xrmforge/testing`
|
|
153
|
+
- [ ] `xrmforge build` produces IIFE bundles
|
|
154
|
+
- [ ] `tsc --noEmit` passes with zero errors
|
package/dist/index.d.ts
CHANGED
|
@@ -330,16 +330,6 @@ declare class DataverseHttpClient {
|
|
|
330
330
|
* @param signal - Optional AbortSignal to cancel the request
|
|
331
331
|
*/
|
|
332
332
|
getAll<T>(path: string, signal?: AbortSignal): Promise<T[]>;
|
|
333
|
-
/**
|
|
334
|
-
* Execute a POST request that is semantically a read operation.
|
|
335
|
-
* Used for Dataverse actions like RetrieveMetadataChanges that require POST
|
|
336
|
-
* but do not modify data. Allowed even in read-only mode.
|
|
337
|
-
*
|
|
338
|
-
* @param path - API path (relative to apiUrl)
|
|
339
|
-
* @param body - JSON body to send
|
|
340
|
-
* @param signal - Optional AbortSignal to cancel the request
|
|
341
|
-
*/
|
|
342
|
-
postReadOnly<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T>;
|
|
343
333
|
/**
|
|
344
334
|
* Returns true if this client is in read-only mode (the safe default).
|
|
345
335
|
*/
|
package/dist/index.js
CHANGED
|
@@ -404,28 +404,6 @@ var DataverseHttpClient = class {
|
|
|
404
404
|
}
|
|
405
405
|
return allResults;
|
|
406
406
|
}
|
|
407
|
-
/**
|
|
408
|
-
* Execute a POST request that is semantically a read operation.
|
|
409
|
-
* Used for Dataverse actions like RetrieveMetadataChanges that require POST
|
|
410
|
-
* but do not modify data. Allowed even in read-only mode.
|
|
411
|
-
*
|
|
412
|
-
* @param path - API path (relative to apiUrl)
|
|
413
|
-
* @param body - JSON body to send
|
|
414
|
-
* @param signal - Optional AbortSignal to cancel the request
|
|
415
|
-
*/
|
|
416
|
-
async postReadOnly(path2, body, signal) {
|
|
417
|
-
const ALLOWED_PATHS = ["/RetrieveMetadataChanges"];
|
|
418
|
-
const normalizedPath = path2.startsWith("/") ? path2 : `/${path2}`;
|
|
419
|
-
if (!ALLOWED_PATHS.some((p) => normalizedPath.startsWith(p))) {
|
|
420
|
-
throw new ApiRequestError(
|
|
421
|
-
"API_2001" /* API_REQUEST_FAILED */,
|
|
422
|
-
`postReadOnly is only allowed for safe read operations: ${ALLOWED_PATHS.join(", ")}. Got: "${normalizedPath}"`,
|
|
423
|
-
{ path: normalizedPath }
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
const url = this.resolveUrl(path2);
|
|
427
|
-
return this.executeWithConcurrency(url, signal, "POST", body);
|
|
428
|
-
}
|
|
429
407
|
// ─── Read-Only Enforcement ─────────────────────────────────────────────
|
|
430
408
|
/**
|
|
431
409
|
* Returns true if this client is in read-only mode (the safe default).
|
|
@@ -514,12 +492,14 @@ var DataverseHttpClient = class {
|
|
|
514
492
|
try {
|
|
515
493
|
tokenResponse = await this.credential.getToken(scope);
|
|
516
494
|
} catch (error) {
|
|
495
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
517
496
|
throw new AuthenticationError(
|
|
518
497
|
"AUTH_1003" /* AUTH_TOKEN_FAILED */,
|
|
519
|
-
`Failed to acquire access token for ${this.baseUrl}. Verify your authentication configuration
|
|
498
|
+
`Failed to acquire access token for ${this.baseUrl}. Verify your authentication configuration.
|
|
499
|
+
Cause: ${cause}`,
|
|
520
500
|
{
|
|
521
501
|
environmentUrl: this.baseUrl,
|
|
522
|
-
originalError:
|
|
502
|
+
originalError: cause
|
|
523
503
|
}
|
|
524
504
|
);
|
|
525
505
|
}
|
|
@@ -1421,26 +1401,22 @@ var ChangeDetector = class {
|
|
|
1421
1401
|
*/
|
|
1422
1402
|
async detectChanges(clientVersionStamp) {
|
|
1423
1403
|
log6.info("Detecting metadata changes since last run");
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
Conditions: []
|
|
1429
|
-
},
|
|
1430
|
-
Properties: {
|
|
1431
|
-
AllProperties: false,
|
|
1432
|
-
PropertyNames: ["LogicalName"]
|
|
1433
|
-
}
|
|
1404
|
+
const query = {
|
|
1405
|
+
Criteria: {
|
|
1406
|
+
FilterOperator: "And",
|
|
1407
|
+
Conditions: []
|
|
1434
1408
|
},
|
|
1435
|
-
|
|
1436
|
-
|
|
1409
|
+
Properties: {
|
|
1410
|
+
AllProperties: false,
|
|
1411
|
+
PropertyNames: ["LogicalName"]
|
|
1412
|
+
}
|
|
1437
1413
|
};
|
|
1414
|
+
const queryJson = encodeURIComponent(JSON.stringify(query));
|
|
1415
|
+
const deletedFilter = `Microsoft.Dynamics.CRM.DeletedMetadataFilters'Default'`;
|
|
1416
|
+
const path2 = `/RetrieveMetadataChanges(Query=@q,ClientVersionStamp=@s,DeletedMetadataFilters=@d)?@q=${queryJson}&@s='${clientVersionStamp}'&@d=${deletedFilter}`;
|
|
1438
1417
|
let response;
|
|
1439
1418
|
try {
|
|
1440
|
-
response = await this.http.
|
|
1441
|
-
"/RetrieveMetadataChanges",
|
|
1442
|
-
requestBody
|
|
1443
|
-
);
|
|
1419
|
+
response = await this.http.get(path2);
|
|
1444
1420
|
} catch (error) {
|
|
1445
1421
|
if (this.isExpiredVersionStampError(error)) {
|
|
1446
1422
|
throw new MetadataError(
|
|
@@ -1475,22 +1451,19 @@ var ChangeDetector = class {
|
|
|
1475
1451
|
*/
|
|
1476
1452
|
async getInitialVersionStamp() {
|
|
1477
1453
|
log6.info("Fetching initial server version stamp");
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
PropertyNames: ["LogicalName"]
|
|
1487
|
-
}
|
|
1454
|
+
const query = {
|
|
1455
|
+
Criteria: {
|
|
1456
|
+
FilterOperator: "And",
|
|
1457
|
+
Conditions: []
|
|
1458
|
+
},
|
|
1459
|
+
Properties: {
|
|
1460
|
+
AllProperties: false,
|
|
1461
|
+
PropertyNames: ["LogicalName"]
|
|
1488
1462
|
}
|
|
1489
1463
|
};
|
|
1490
|
-
const
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
);
|
|
1464
|
+
const queryParam = encodeURIComponent(JSON.stringify(query));
|
|
1465
|
+
const path2 = `/RetrieveMetadataChanges(Query=@q)?@q=${queryParam}`;
|
|
1466
|
+
const response = await this.http.get(path2);
|
|
1494
1467
|
log6.info("Initial version stamp acquired");
|
|
1495
1468
|
return response.ServerVersionStamp;
|
|
1496
1469
|
}
|
|
@@ -2135,6 +2108,50 @@ function generateFormInterface(form, entityLogicalName, attributeMap, options =
|
|
|
2135
2108
|
lines.push("");
|
|
2136
2109
|
}
|
|
2137
2110
|
}
|
|
2111
|
+
const specialControls = form.allSpecialControls || [];
|
|
2112
|
+
const subgrids = specialControls.filter((sc) => sc.controlType === "subgrid" || sc.controlType === "editablegrid");
|
|
2113
|
+
const quickViews = specialControls.filter((sc) => sc.controlType === "quickview");
|
|
2114
|
+
if (subgrids.length > 0) {
|
|
2115
|
+
const subgridsEnumName = `${baseName}FormSubgrids`;
|
|
2116
|
+
lines.push(` /** Subgrid constants for "${form.name}" (compile-time only, zero runtime) */`);
|
|
2117
|
+
lines.push(` const enum ${subgridsEnumName} {`);
|
|
2118
|
+
const usedMembers = /* @__PURE__ */ new Set();
|
|
2119
|
+
for (const sg of subgrids) {
|
|
2120
|
+
let member = toSafeFormName(sg.id) || toPascalCase(sg.id);
|
|
2121
|
+
const original = member;
|
|
2122
|
+
let counter = 2;
|
|
2123
|
+
while (usedMembers.has(member)) {
|
|
2124
|
+
member = `${original}${counter}`;
|
|
2125
|
+
counter++;
|
|
2126
|
+
}
|
|
2127
|
+
usedMembers.add(member);
|
|
2128
|
+
const label = sg.targetEntityType ? `Subgrid: ${sg.targetEntityType}` : `Subgrid`;
|
|
2129
|
+
lines.push(` /** ${label} */`);
|
|
2130
|
+
lines.push(` ${member} = '${sg.id}',`);
|
|
2131
|
+
}
|
|
2132
|
+
lines.push(" }");
|
|
2133
|
+
lines.push("");
|
|
2134
|
+
}
|
|
2135
|
+
if (quickViews.length > 0) {
|
|
2136
|
+
const qvEnumName = `${baseName}FormQuickViews`;
|
|
2137
|
+
lines.push(` /** Quick View constants for "${form.name}" (compile-time only, zero runtime) */`);
|
|
2138
|
+
lines.push(` const enum ${qvEnumName} {`);
|
|
2139
|
+
const usedMembers = /* @__PURE__ */ new Set();
|
|
2140
|
+
for (const qv of quickViews) {
|
|
2141
|
+
let member = toSafeFormName(qv.id) || toPascalCase(qv.id);
|
|
2142
|
+
const original = member;
|
|
2143
|
+
let counter = 2;
|
|
2144
|
+
while (usedMembers.has(member)) {
|
|
2145
|
+
member = `${original}${counter}`;
|
|
2146
|
+
counter++;
|
|
2147
|
+
}
|
|
2148
|
+
usedMembers.add(member);
|
|
2149
|
+
lines.push(` /** Quick View */`);
|
|
2150
|
+
lines.push(` ${member} = '${qv.id}',`);
|
|
2151
|
+
}
|
|
2152
|
+
lines.push(" }");
|
|
2153
|
+
lines.push("");
|
|
2154
|
+
}
|
|
2138
2155
|
lines.push(` /** ${form.name} */`);
|
|
2139
2156
|
lines.push(` interface ${interfaceName} extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {`);
|
|
2140
2157
|
lines.push(` /** Typisierter Feldzugriff: nur Felder die auf diesem Formular existieren */`);
|
|
@@ -2144,7 +2161,6 @@ function generateFormInterface(form, entityLogicalName, attributeMap, options =
|
|
|
2144
2161
|
lines.push("");
|
|
2145
2162
|
lines.push(` /** Typisierter Control-Zugriff: nur Controls die auf diesem Formular existieren */`);
|
|
2146
2163
|
lines.push(` getControl<K extends ${fieldsTypeName}>(name: K): ${ctrlMapName}[K];`);
|
|
2147
|
-
const specialControls = form.allSpecialControls || [];
|
|
2148
2164
|
for (const sc of specialControls) {
|
|
2149
2165
|
const xrmType = specialControlToXrmType(sc.controlType);
|
|
2150
2166
|
if (xrmType) {
|