@xrmforge/devkit 0.7.4 → 0.7.5

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.
@@ -65,7 +65,7 @@ export const onLoad = wrapHandler('LM.Account.onLoad', logger, (ctx) => {
65
65
  const form = typedForm<AccountLMFirmaForm>(ctx.getFormContext());
66
66
 
67
67
  // Direct field access - fully typed, IDE autocomplete works
68
- const name = form.name.getValue(); // string | null
68
+ const name = form.name.getValue(); // string | null (non-nullable, field is on form)
69
69
  form.revenue.setValue(150000); // NumberAttribute
70
70
  const parent = form.parentaccountid.getValue(); // LookupValue[] | null
71
71
 
@@ -85,8 +85,29 @@ export const onLoad = wrapHandler('LM.Account.onLoad', logger, (ctx) => {
85
85
 
86
86
  **When to use `form.fieldname` (the typedForm proxy):**
87
87
  - `getValue()`, `setValue()` (reading and writing values)
88
+ - `addOnChange()` (event registration directly on the attribute)
88
89
  - Any read-only access to field values
89
90
 
91
+ **When to use `form.$unsafe(EntityFields.X)` (off-form fields):**
92
+
93
+ D365 loads all entity attributes into the FormContext, not just those on the form.
94
+ If a field is NOT in the generated form interface (compile error on `form.fieldname`),
95
+ use `$unsafe()` with the **Entity-level Fields Enum** (never a raw string):
96
+
97
+ ```typescript
98
+ import { OpportunityFields } from '../../generated/fields/opportunity.js';
99
+
100
+ // Off-form field: not in the form interface, but loaded by D365
101
+ form.$unsafe(OpportunityFields.VslBeauftragung)?.setValue(closeDate);
102
+
103
+ // WRONG: raw string in $unsafe
104
+ form.$unsafe('estimatedclosedate')?.setValue(closeDate);
105
+ ```
106
+
107
+ `$unsafe()` returns `Attribute | null` (nullable, because the field may not exist).
108
+ Always use optional chaining (`?.`). The Entity-level Fields Enum ensures the field
109
+ name is valid even though it's not on the form.
110
+
90
111
  ### 2. Fields Enum for ALL getAttribute/getControl AND select() calls
91
112
 
92
113
  Two types of Fields enums exist:
@@ -334,6 +355,8 @@ Xrm.Navigation.openForm({ entityName: EntityNames.Account, entityId: id }); //
334
355
  - Never access WebApi response properties with `as string` casts (use generated Entity interfaces)
335
356
  - Never `.getValue()[0].id` for lookups (use `formLookup`/`formLookupId`)
336
357
  - Never raw strings in `parseLookup()` (use NavigationProperties enum)
358
+ - Never raw strings in `$unsafe()` (use Entity-level Fields Enum: `form.$unsafe(AccountFields.X)`)
359
+ - Never manual OData annotation access (`_value`, `@OData.Community.Display.V1.FormattedValue`, `@Microsoft.Dynamics.CRM.lookuplogicalname`). Use `parseLookup()` which extracts all three.
337
360
 
338
361
  **Code quality:**
339
362
  - Never `Xrm.Page` (deprecated since D365 v9.0)
@@ -438,13 +461,32 @@ import { LmBestellungStatusCode } from '../../generated/optionsets/lm_bestellung
438
461
  </condition>`
439
462
  ```
440
463
 
441
- ### Lookup Access
464
+ ### Lookup Access (Form)
442
465
  ```typescript
443
466
  // BEFORE: form.getAttribute("customerid").getValue()[0].id.replace("{","").replace("}","")
444
467
  // AFTER:
445
468
  const customerId = formLookupId(form.customerid);
446
469
  ```
447
470
 
471
+ ### Lookup from WebApi Response to Form Field
472
+ ```typescript
473
+ // BEFORE (verbose, error-prone OData annotations):
474
+ form.customerid.setValue([{
475
+ id: raw._parentcustomerid_value as string,
476
+ entityType: EntityNames.Account,
477
+ name: (raw['_parentcustomerid_value@OData.Community.Display.V1.FormattedValue'] as string) ?? '',
478
+ }]);
479
+
480
+ // AFTER (parseLookup extracts id, name, entityType automatically):
481
+ import { parseLookup } from '@xrmforge/helpers';
482
+ import { ContactNavigationProperties as ContactNav } from '../../generated/entities/contact.js';
483
+
484
+ const customer = parseLookup(raw, ContactNav.ParentCustomerId);
485
+ if (customer) {
486
+ form.customerid.setValue([customer]);
487
+ }
488
+ ```
489
+
448
490
  ### Custom API Call
449
491
  ```typescript
450
492
  // BEFORE: ExecuteFunctionCall("CancelInvoice", { InvoiceId: id })
@@ -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.5",
4
4
  "description": "Build orchestration and project tooling for Dynamics 365 WebResources",
5
5
  "keywords": [
6
6
  "dynamics-365",