@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.
@@ -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
- // Control access
73
- form.$control('name').setDisabled(true);
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
- // Full FormContext for ui, data, tabs, addOnChange
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
- **When to use `form.$context.getAttribute(Fields.X)` instead of `form.fieldname`:**
82
- - `addOnChange()`, `removeOnChange()` (event registration on the attribute)
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
- - `getControl()` with typed control access (use `form.$control(Fields.X)`)
90
+ - Any attribute method: the proxy returns the full typed Attribute object
85
91
 
86
- **When to use `form.fieldname` (the typedForm proxy):**
87
- - `getValue()`, `setValue()` (reading and writing values)
88
- - Any read-only access to field values
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.$control(Fields.Name)` |
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.$control(Fields.X).setDisabled(disabled)` |
530
- | `SetVisible(attributeName, visible)` | `form.$control(Fields.X).setVisible(visible)` |
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)` | `(form.$control(Fields.X) as Xrm.Controls.LookupControl).addPreSearch(cb)` |
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
  // ============================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xrmforge/devkit",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Build orchestration and project tooling for Dynamics 365 WebResources",
5
5
  "keywords": [
6
6
  "dynamics-365",