@xrmforge/devkit 0.7.4 → 0.7.6
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/dist/templates/AGENT.md +70 -15
- package/dist/templates/validate-form.mjs +61 -0
- package/package.json +1 -1
package/dist/templates/AGENT.md
CHANGED
|
@@ -69,23 +69,56 @@ export const onLoad = wrapHandler('LM.Account.onLoad', logger, (ctx) => {
|
|
|
69
69
|
form.revenue.setValue(150000); // NumberAttribute
|
|
70
70
|
const parent = form.parentaccountid.getValue(); // LookupValue[] | null
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
form
|
|
72
|
+
// addOnChange directly on the proxy (NOT via $context.getAttribute)
|
|
73
|
+
form.name.addOnChange(() => { logger.debug('Name changed'); });
|
|
74
|
+
form.revenue.addOnChange(() => { recalculate(form); });
|
|
74
75
|
|
|
75
|
-
//
|
|
76
|
+
// Control access via controls proxy (typed from ControlMap, no cast needed)
|
|
77
|
+
form.controls.name.setDisabled(true);
|
|
78
|
+
form.controls.customerid.setEntityTypes([EntityNames.Account]); // LookupControl
|
|
79
|
+
form.controls.revenue.setVisible(false); // NumberControl
|
|
80
|
+
|
|
81
|
+
// Full FormContext for ui, data, tabs
|
|
76
82
|
form.$context.ui.setFormNotification('OK', FormNotificationLevel.Info, 'id');
|
|
77
|
-
form.$context.getAttribute(Fields.Name).addOnChange(() => { ... });
|
|
78
83
|
});
|
|
79
84
|
```
|
|
80
85
|
|
|
81
|
-
**
|
|
82
|
-
- `
|
|
86
|
+
**Use `form.fieldname` (the typedForm proxy) for EVERYTHING on the attribute:**
|
|
87
|
+
- `getValue()`, `setValue()` (reading and writing values)
|
|
88
|
+
- `addOnChange()`, `removeOnChange()` (event registration)
|
|
83
89
|
- `setRequiredLevel()`, `setSubmitMode()` (attribute-level settings)
|
|
84
|
-
-
|
|
90
|
+
- Any attribute method: the proxy returns the full typed Attribute object
|
|
85
91
|
|
|
86
|
-
**
|
|
87
|
-
- `
|
|
88
|
-
-
|
|
92
|
+
**Use `form.controls.fieldname` for control-level operations:**
|
|
93
|
+
- `setDisabled()`, `setVisible()`, `setLabel()`
|
|
94
|
+
- `addPreSearch()` (on LookupControl)
|
|
95
|
+
- `setNotification()`, `clearNotification()`
|
|
96
|
+
|
|
97
|
+
**Use `form.$context` ONLY for FormContext-level operations:**
|
|
98
|
+
- `form.$context.ui` (setFormNotification, tabs, close)
|
|
99
|
+
- `form.$context.data` (save, entity, process)
|
|
100
|
+
- `form.$context.ui.getFormType()`
|
|
101
|
+
- NOT for getAttribute (use the proxy instead)
|
|
102
|
+
|
|
103
|
+
**When to use `form.$unsafe(EntityFields.X)` (off-form fields):**
|
|
104
|
+
|
|
105
|
+
D365 loads all entity attributes into the FormContext, not just those on the form.
|
|
106
|
+
If a field is NOT in the generated form interface (compile error on `form.fieldname`),
|
|
107
|
+
use `$unsafe()` with the **Entity-level Fields Enum** (never a raw string):
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { OpportunityFields } from '../../generated/fields/opportunity.js';
|
|
111
|
+
|
|
112
|
+
// Off-form field: not in the form interface, but loaded by D365
|
|
113
|
+
form.$unsafe(OpportunityFields.VslBeauftragung)?.setValue(closeDate);
|
|
114
|
+
|
|
115
|
+
// WRONG: raw string in $unsafe
|
|
116
|
+
form.$unsafe('estimatedclosedate')?.setValue(closeDate);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`$unsafe()` returns `Attribute | null` (nullable, because the field may not exist).
|
|
120
|
+
Always use optional chaining (`?.`). The Entity-level Fields Enum ensures the field
|
|
121
|
+
name is valid even though it's not on the form.
|
|
89
122
|
|
|
90
123
|
### 2. Fields Enum for ALL getAttribute/getControl AND select() calls
|
|
91
124
|
|
|
@@ -334,6 +367,8 @@ Xrm.Navigation.openForm({ entityName: EntityNames.Account, entityId: id }); //
|
|
|
334
367
|
- Never access WebApi response properties with `as string` casts (use generated Entity interfaces)
|
|
335
368
|
- Never `.getValue()[0].id` for lookups (use `formLookup`/`formLookupId`)
|
|
336
369
|
- Never raw strings in `parseLookup()` (use NavigationProperties enum)
|
|
370
|
+
- Never raw strings in `$unsafe()` (use Entity-level Fields Enum: `form.$unsafe(AccountFields.X)`)
|
|
371
|
+
- Never manual OData annotation access (`_value`, `@OData.Community.Display.V1.FormattedValue`, `@Microsoft.Dynamics.CRM.lookuplogicalname`). Use `parseLookup()` which extracts all three.
|
|
337
372
|
|
|
338
373
|
**Code quality:**
|
|
339
374
|
- Never `Xrm.Page` (deprecated since D365 v9.0)
|
|
@@ -344,6 +379,7 @@ Xrm.Navigation.openForm({ entityName: EntityNames.Account, entityId: id }); //
|
|
|
344
379
|
- Never unlokalized UI strings (use `pickLang()` from constants.ts)
|
|
345
380
|
- Never build your own getValue/setFieldValue/setDisabled/addOnChange helpers (use `typedForm` + native Xrm API)
|
|
346
381
|
- Never `import ... from '@xrmforge/typegen'` in browser code (use `@xrmforge/helpers`)
|
|
382
|
+
- Never `as Xrm.Controls.LookupControl` or similar control casts (`form.controls.fieldname` returns the typed control from ControlMap)
|
|
347
383
|
- Never `as any` without eslint-disable comment explaining why
|
|
348
384
|
- Never untyped `catch (error)` (always `catch (error: unknown)`)
|
|
349
385
|
|
|
@@ -438,13 +474,32 @@ import { LmBestellungStatusCode } from '../../generated/optionsets/lm_bestellung
|
|
|
438
474
|
</condition>`
|
|
439
475
|
```
|
|
440
476
|
|
|
441
|
-
### Lookup Access
|
|
477
|
+
### Lookup Access (Form)
|
|
442
478
|
```typescript
|
|
443
479
|
// BEFORE: form.getAttribute("customerid").getValue()[0].id.replace("{","").replace("}","")
|
|
444
480
|
// AFTER:
|
|
445
481
|
const customerId = formLookupId(form.customerid);
|
|
446
482
|
```
|
|
447
483
|
|
|
484
|
+
### Lookup from WebApi Response to Form Field
|
|
485
|
+
```typescript
|
|
486
|
+
// BEFORE (verbose, error-prone OData annotations):
|
|
487
|
+
form.customerid.setValue([{
|
|
488
|
+
id: raw._parentcustomerid_value as string,
|
|
489
|
+
entityType: EntityNames.Account,
|
|
490
|
+
name: (raw['_parentcustomerid_value@OData.Community.Display.V1.FormattedValue'] as string) ?? '',
|
|
491
|
+
}]);
|
|
492
|
+
|
|
493
|
+
// AFTER (parseLookup extracts id, name, entityType automatically):
|
|
494
|
+
import { parseLookup } from '@xrmforge/helpers';
|
|
495
|
+
import { ContactNavigationProperties as ContactNav } from '../../generated/entities/contact.js';
|
|
496
|
+
|
|
497
|
+
const customer = parseLookup(raw, ContactNav.ParentCustomerId);
|
|
498
|
+
if (customer) {
|
|
499
|
+
form.customerid.setValue([customer]);
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
448
503
|
### Custom API Call
|
|
449
504
|
```typescript
|
|
450
505
|
// BEFORE: ExecuteFunctionCall("CancelInvoice", { InvoiceId: id })
|
|
@@ -504,7 +559,7 @@ each attribute to its control. `mock.getControl(Fields.Name)` works out of the b
|
|
|
504
559
|
| Legacy Pattern | XrmForge Replacement |
|
|
505
560
|
|---|---|
|
|
506
561
|
| `getAttribute("name")` | `form.name` (via typedForm) |
|
|
507
|
-
| `getControl("name")` | `form
|
|
562
|
+
| `getControl("name")` | `form.controls.name` |
|
|
508
563
|
| `Xrm.Page.getAttribute(...)` | `form.fieldname` (via typedForm) |
|
|
509
564
|
| `var formContext` (global) | `const form = typedForm<MyForm>(ctx.getFormContext())` |
|
|
510
565
|
| `function form_OnLoad(ctx)` | `export const onLoad = wrapHandler(...)` |
|
|
@@ -526,11 +581,11 @@ Never recreate them. Use the typed API directly.
|
|
|
526
581
|
|---|---|
|
|
527
582
|
| `GetValue(fieldName)` | `form.fieldname.getValue()` (typed via typedForm) |
|
|
528
583
|
| `SetValue(fieldName, value)` | `form.fieldname.setValue(value)` (typed via typedForm) |
|
|
529
|
-
| `SetDisabled(attributeName, disabled)` | `form
|
|
530
|
-
| `SetVisible(attributeName, visible)` | `form
|
|
584
|
+
| `SetDisabled(attributeName, disabled)` | `form.controls.fieldname.setDisabled(disabled)` |
|
|
585
|
+
| `SetVisible(attributeName, visible)` | `form.controls.fieldname.setVisible(visible)` |
|
|
531
586
|
| `SetRequiredLevel(attributeName, level)` | `form.$context.getAttribute(Fields.X).setRequiredLevel(RequiredLevel.Required)` |
|
|
532
587
|
| `AddOnChange(attributeName, callback)` | `form.$context.getAttribute(Fields.X).addOnChange(cb)` |
|
|
533
|
-
| `AddPreSearch(controlName, callback)` | `
|
|
588
|
+
| `AddPreSearch(controlName, callback)` | `form.controls.fieldname.addPreSearch(cb)` (typed as LookupControl from ControlMap) |
|
|
534
589
|
| `GetLookupValueId(fieldName)` | `formLookupId(form.fieldname)` |
|
|
535
590
|
| `SetLookupValue(field, id, type, name)` | `form.fieldname.setValue([{ id, entityType, name }])` |
|
|
536
591
|
| `GetId()` | `form.$context.data.entity.getId()` |
|
|
@@ -239,6 +239,13 @@ checkPattern(
|
|
|
239
239
|
],
|
|
240
240
|
);
|
|
241
241
|
|
|
242
|
+
// 3l4. Raw strings in $unsafe() (must use Entity-level Fields Enum)
|
|
243
|
+
checkPattern(
|
|
244
|
+
'Raw field strings in $unsafe() (use Entity-level Fields Enum)',
|
|
245
|
+
formFiles,
|
|
246
|
+
/\$unsafe\s*\(\s*['"][a-z]/,
|
|
247
|
+
);
|
|
248
|
+
|
|
242
249
|
// ── Handler Pattern ──────────────────────────────────────────────────────────
|
|
243
250
|
|
|
244
251
|
// 3l. Exported handlers without wrapHandler or wrapCommand
|
|
@@ -281,6 +288,60 @@ checkPattern(
|
|
|
281
288
|
['generated/'],
|
|
282
289
|
);
|
|
283
290
|
|
|
291
|
+
// ── WebApi Response Typing ────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
// 3p. Untyped WebApi responses (must use generated Entity interfaces)
|
|
294
|
+
checkPattern(
|
|
295
|
+
'Untyped WebApi response cast (use generated Entity interface instead of Record<string, unknown>)',
|
|
296
|
+
allSrcFiles,
|
|
297
|
+
/as\s+Record\s*<\s*string\s*,\s*unknown\s*>/,
|
|
298
|
+
['generated/'],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// 3q. Manual OData annotation access (use parseLookup instead)
|
|
302
|
+
checkPattern(
|
|
303
|
+
'Manual OData annotation access (use parseLookup() from @xrmforge/helpers)',
|
|
304
|
+
allSrcFiles,
|
|
305
|
+
/@OData\.Community\.Display|@Microsoft\.Dynamics\.CRM\.lookuplogicalname|_value(?:@|\s*as\s)/,
|
|
306
|
+
['generated/', 'node_modules'],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// ── Legacy Helper Wrappers ───────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
// 3r. Forbidden legacy helper functions (must use typedForm + @xrmforge/helpers)
|
|
312
|
+
checkPattern(
|
|
313
|
+
'Forbidden helper: getLookupId (use formLookupId from @xrmforge/helpers)',
|
|
314
|
+
allSrcFiles,
|
|
315
|
+
/\bgetLookupId\s*\(/,
|
|
316
|
+
['generated/'],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
checkPattern(
|
|
320
|
+
'Forbidden helper: setLookupValue (use form.field.setValue([{...}]))',
|
|
321
|
+
allSrcFiles,
|
|
322
|
+
/\bsetLookupValue\s*\(/,
|
|
323
|
+
['generated/'],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// ── UI Localization ──────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
// 3s. Hardcoded UI strings in dialogs/progress (must use pickLang from constants.ts)
|
|
329
|
+
checkPattern(
|
|
330
|
+
'Hardcoded UI string in dialog/progress (use pickLang(MESSAGES) from constants.ts)',
|
|
331
|
+
allSrcFiles,
|
|
332
|
+
/(?:openAlertDialog|openConfirmDialog|openErrorDialog|showProgressIndicator)\s*\(\s*(?:\{\s*text\s*:\s*)?['"]/,
|
|
333
|
+
['generated/', 'constants.ts'],
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// ── Duplicate Framework Functions ────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
// 3t. Own normalizeGuid/compareGuid (use normalizeGuid from @xrmforge/helpers)
|
|
339
|
+
checkPattern(
|
|
340
|
+
'Own normalizeGuid/compareGuid definition (use normalizeGuid from @xrmforge/helpers)',
|
|
341
|
+
allSrcFiles,
|
|
342
|
+
/(?:export\s+)?function\s+(?:normalizeGuid|compareGuid)\s*\(/,
|
|
343
|
+
);
|
|
344
|
+
|
|
284
345
|
// ============================================================
|
|
285
346
|
// Result
|
|
286
347
|
// ============================================================
|