@xrmforge/devkit 0.6.1 → 0.7.0
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/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/templates/AGENT.md +534 -450
- package/dist/templates/example-form.ts +48 -33
- package/dist/templates/validate-form.mjs +93 -11
- package/package.json +1 -1
package/dist/templates/AGENT.md
CHANGED
|
@@ -1,450 +1,534 @@
|
|
|
1
|
-
# XrmForge - AI Agent Instructions
|
|
2
|
-
|
|
3
|
-
This file helps AI coding assistants write optimal Dynamics 365 form scripts.
|
|
4
|
-
|
|
5
|
-
## Packages
|
|
6
|
-
|
|
7
|
-
- `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata
|
|
8
|
-
- `@xrmforge/helpers` - Browser-safe runtime: select(), parseLookup(),
|
|
9
|
-
- `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange()
|
|
10
|
-
- `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
|
|
11
|
-
- `@xrmforge/eslint-plugin` - D365-specific ESLint rules
|
|
12
|
-
|
|
13
|
-
## Generated Types (generated/ directory)
|
|
14
|
-
|
|
15
|
-
Run `xrmforge generate` to create:
|
|
16
|
-
- `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
|
|
17
|
-
- `generated/optionsets/{entity}.ts` - OptionSet const enums
|
|
18
|
-
- `generated/entities/{entity}.ts` - Entity interface
|
|
19
|
-
- `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
|
|
20
|
-
- `generated/entity-names.ts` - EntityNames const enum
|
|
21
|
-
- `generated/
|
|
22
|
-
- `generated/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
form
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
1
|
+
# XrmForge - AI Agent Instructions
|
|
2
|
+
|
|
3
|
+
This file helps AI coding assistants write optimal Dynamics 365 form scripts.
|
|
4
|
+
|
|
5
|
+
## Packages
|
|
6
|
+
|
|
7
|
+
- `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata (Node.js CLI only, NEVER import in browser code)
|
|
8
|
+
- `@xrmforge/helpers` - Browser-safe runtime: typedForm(), select(), parseLookup(), formLookup(), Xrm constants, Action executors
|
|
9
|
+
- `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange(), setupXrmMock()
|
|
10
|
+
- `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
|
|
11
|
+
- `@xrmforge/eslint-plugin` - D365-specific ESLint rules
|
|
12
|
+
|
|
13
|
+
## Generated Types (generated/ directory)
|
|
14
|
+
|
|
15
|
+
Run `xrmforge generate` to create:
|
|
16
|
+
- `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
|
|
17
|
+
- `generated/optionsets/{entity}.ts` - OptionSet const enums
|
|
18
|
+
- `generated/entities/{entity}.ts` - Entity interface (for Web API response typing)
|
|
19
|
+
- `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
|
|
20
|
+
- `generated/entity-names.ts` - EntityNames const enum
|
|
21
|
+
- `generated/actions/global.ts` - Custom API Action executors (typed params + results)
|
|
22
|
+
- `generated/functions/global.ts` - Custom API Function executors
|
|
23
|
+
- `generated/form-mapping.json` - Entity to form interface mapping (read after generate!)
|
|
24
|
+
- `generated/index.ts` - Barrel file with `export * from` re-exports
|
|
25
|
+
|
|
26
|
+
**After generate:** Read `generated/form-mapping.json` for the mapping of entity logical
|
|
27
|
+
names to form interface names. Do NOT guess interface names from entity names.
|
|
28
|
+
Fields enum member names are based on the **primary language label** (often German),
|
|
29
|
+
not the logical field name. Always read the generated files to get correct names.
|
|
30
|
+
|
|
31
|
+
**System entities:** If a form script needs an entity NOT in the generated EntityNames
|
|
32
|
+
(e.g. transactioncurrency, pricelevel, uom, systemuser), re-run generate with
|
|
33
|
+
`--entities transactioncurrency,pricelevel,...` to include them. NEVER create a local
|
|
34
|
+
`SystemEntities` object with raw strings as a workaround.
|
|
35
|
+
|
|
36
|
+
## Rules: MANDATORY (every violation is a bug)
|
|
37
|
+
|
|
38
|
+
### 1. typedForm() for ALL field access (primary pattern)
|
|
39
|
+
|
|
40
|
+
Use `typedForm<FormInterface>(formContext)` from `@xrmforge/helpers` to create a
|
|
41
|
+
typed proxy. Access fields as direct properties instead of getAttribute chains.
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { typedForm } from '@xrmforge/helpers';
|
|
45
|
+
import type { AccountLMFirmaForm } from '../../generated/forms/account.js';
|
|
46
|
+
import { AccountLMFirmaFormFieldsEnum as Fields } from '../../generated/forms/account.js';
|
|
47
|
+
|
|
48
|
+
export const onLoad = wrapHandler('LM.Account.onLoad', logger, (ctx) => {
|
|
49
|
+
const form = typedForm<AccountLMFirmaForm>(ctx.getFormContext());
|
|
50
|
+
|
|
51
|
+
// Direct field access - fully typed, IDE autocomplete works
|
|
52
|
+
const name = form.name.getValue(); // string | null
|
|
53
|
+
form.revenue.setValue(150000); // NumberAttribute
|
|
54
|
+
const parent = form.parentaccountid.getValue(); // LookupValue[] | null
|
|
55
|
+
|
|
56
|
+
// Control access
|
|
57
|
+
form.$control('name').setDisabled(true);
|
|
58
|
+
|
|
59
|
+
// Full FormContext for ui, data, tabs, addOnChange
|
|
60
|
+
form.$context.ui.setFormNotification('OK', FormNotificationLevel.Info, 'id');
|
|
61
|
+
form.$context.getAttribute(Fields.Name).addOnChange(() => { ... });
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**When to use `form.$context.getAttribute(Fields.X)` instead of `form.fieldname`:**
|
|
66
|
+
- `addOnChange()`, `removeOnChange()` (event registration on the attribute)
|
|
67
|
+
- `setRequiredLevel()`, `setSubmitMode()` (attribute-level settings)
|
|
68
|
+
- `getControl()` with typed control access (use `form.$control(Fields.X)`)
|
|
69
|
+
|
|
70
|
+
**When to use `form.fieldname` (the typedForm proxy):**
|
|
71
|
+
- `getValue()`, `setValue()` (reading and writing values)
|
|
72
|
+
- Any read-only access to field values
|
|
73
|
+
|
|
74
|
+
### 2. Fields Enum for ALL getAttribute/getControl AND select() calls
|
|
75
|
+
|
|
76
|
+
Two types of Fields enums exist:
|
|
77
|
+
- **Form-level**: `AccountLMFirmaFormFieldsEnum` (from `generated/forms/`) - for getAttribute/getControl on the form
|
|
78
|
+
- **Entity-level**: `AccountFields` (from `generated/fields/`) - for Web API $select queries
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// Form field access (form-level Fields):
|
|
82
|
+
import { AccountLMFirmaFormFieldsEnum as Fields } from '../../generated/forms/account.js';
|
|
83
|
+
form.$context.getAttribute(Fields.Name).addOnChange(() => { ... });
|
|
84
|
+
|
|
85
|
+
// Web API queries (entity-level Fields):
|
|
86
|
+
import { AccountFields } from '../../generated/fields/account.js';
|
|
87
|
+
Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
|
|
88
|
+
select(AccountFields.Name, AccountFields.WebsiteUrl));
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**NEVER use raw strings in select():**
|
|
92
|
+
```typescript
|
|
93
|
+
select('name', 'websiteurl') // BUG - raw strings
|
|
94
|
+
select(AccountFields.Name, AccountFields.WebsiteUrl) // CORRECT
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 3. OptionSet Enum for ALL value comparisons AND FetchXML
|
|
98
|
+
|
|
99
|
+
Never use magic numbers. Not in comparisons, not in FetchXML, not anywhere.
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import { InvoiceStatusCode } from '../../generated/optionsets/invoice.js';
|
|
103
|
+
|
|
104
|
+
// Comparisons:
|
|
105
|
+
if (status === InvoiceStatusCode.Gebucht) { ... } // CORRECT
|
|
106
|
+
if (status === 105710002) { ... } // BUG
|
|
107
|
+
|
|
108
|
+
// FetchXML:
|
|
109
|
+
`<condition attribute='${InvoiceFields.Statuscode}' operator='in'>
|
|
110
|
+
<value>${InvoiceStatusCode.Aktiv}</value>
|
|
111
|
+
<value>${InvoiceStatusCode.Gebucht}</value>
|
|
112
|
+
</condition>` // CORRECT
|
|
113
|
+
|
|
114
|
+
`<condition attribute='statuscode' operator='in'>
|
|
115
|
+
<value>1</value><value>105710002</value>
|
|
116
|
+
</condition>` // BUG - raw strings AND magic numbers
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 4. EntityNames Enum in ALL Xrm.WebApi calls
|
|
120
|
+
|
|
121
|
+
No exceptions, even for system entities. If missing, extend generation.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { EntityNames } from '../../generated/entity-names.js';
|
|
125
|
+
Xrm.WebApi.retrieveRecord(EntityNames.Account, id, query);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 5. Lookup helpers from @xrmforge/helpers
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { formLookup, formLookupId, parseLookup } from '@xrmforge/helpers';
|
|
132
|
+
// Form (via typedForm proxy):
|
|
133
|
+
const customer = formLookup(form.parentaccountid);
|
|
134
|
+
const customerId = formLookupId(form.parentaccountid);
|
|
135
|
+
// Web API response (use NavigationProperties enum, NOT raw strings):
|
|
136
|
+
import { AccountNavigationProperties as AccountNav } from '../../generated/entities/account.js';
|
|
137
|
+
const parent = parseLookup(apiResponse, AccountNav.ParentAccountId);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 6. select(), $filter, $expand, $orderby with Fields Enums
|
|
141
|
+
|
|
142
|
+
ALL OData query parts must use entity-level Fields Enums. No raw field name strings anywhere.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { select, selectExpand } from '@xrmforge/helpers';
|
|
146
|
+
import { AccountFields } from '../../generated/fields/account.js';
|
|
147
|
+
|
|
148
|
+
// $select:
|
|
149
|
+
select(AccountFields.Name, AccountFields.Revenue)
|
|
150
|
+
|
|
151
|
+
// $filter (field names via template literal):
|
|
152
|
+
`${select(AccountFields.Name)}&$filter=${AccountFields.Statecode} eq 0`
|
|
153
|
+
|
|
154
|
+
// $expand (navigation properties):
|
|
155
|
+
selectExpand(
|
|
156
|
+
[AccountFields.Name, AccountFields.Revenue],
|
|
157
|
+
`primarycontactid($select=${ContactFields.Fullname})`,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// $orderby:
|
|
161
|
+
`${select(AccountFields.Name)}&$orderby=${AccountFields.Name} asc`
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### 6b. Web API response typing with generated Entity interfaces
|
|
165
|
+
|
|
166
|
+
Always type Web API responses with generated Entity interfaces. Never access properties with `as string` casts.
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import type { Account } from '../../generated/entities/account.js';
|
|
170
|
+
|
|
171
|
+
const result = await Xrm.WebApi.retrieveRecord(
|
|
172
|
+
EntityNames.Account, id, select(AccountFields.Name)
|
|
173
|
+
) as Account;
|
|
174
|
+
result.name // typed as string | null, no cast needed
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 7. wrapHandler() around EVERY exported handler
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
export const onLoad = wrapHandler('Namespace.Entity.onLoad', logger, async (ctx) => {
|
|
181
|
+
const form = typedForm<MyForm>(ctx.getFormContext());
|
|
182
|
+
// ...
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### 8. Custom API Executors from generated/actions/
|
|
187
|
+
|
|
188
|
+
Never build your own ExecuteFunctionCall wrapper. Use the generated executors:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import { CreateEMailFromInvoice } from '../../generated/actions/global.js';
|
|
192
|
+
import { withProgress } from '@xrmforge/helpers';
|
|
193
|
+
|
|
194
|
+
const result = await withProgress(
|
|
195
|
+
CreateEMailFromInvoice.execute({ InvoiceId: recordId }),
|
|
196
|
+
{ title: 'E-Mail wird erstellt...' }
|
|
197
|
+
);
|
|
198
|
+
// result.EmailId is typed as string
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### 9. Named constants for ALL non-obvious values
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
205
|
+
form.lm_zahlungsziel.setValue(new Date(date.getTime() + days * MS_PER_DAY));
|
|
206
|
+
// NEVER: new Date(date.getTime() + days * 86400000)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### 10. Localized UI strings via pickLang()
|
|
210
|
+
|
|
211
|
+
All user-visible strings MUST go through `pickLang()` in constants.ts:
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
// constants.ts:
|
|
215
|
+
export const MESSAGES = {
|
|
216
|
+
de: { titlePlaceholder: '[Kurzbeschreibung der Anfrage]' },
|
|
217
|
+
en: { titlePlaceholder: '[Brief description]' },
|
|
218
|
+
} as const;
|
|
219
|
+
export function pickLang<K extends string>(languageId: number, table: { de: Record<K, string>; en: Record<K, string> }): Record<K, string>;
|
|
220
|
+
|
|
221
|
+
// form script:
|
|
222
|
+
import { MESSAGES, pickLang } from '../shared/constants.js';
|
|
223
|
+
const lang = pickLang(Xrm.Utility.getGlobalContext().userSettings.languageId, MESSAGES);
|
|
224
|
+
form.title.setValue(lang.titlePlaceholder);
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### 11. Tabs, Sections, Subgrids via generated enums
|
|
228
|
+
|
|
229
|
+
Never use raw strings for tab, section, or subgrid names:
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import { AccountLMFirmaFormTabs as Tabs } from '../../generated/forms/account.js';
|
|
233
|
+
import { AccountLMFirmaFormSUMMARYTABSections as SummarySections } from '../../generated/forms/account.js';
|
|
234
|
+
import { AccountLMFirmaFormSubgrids as Subgrids } from '../../generated/forms/account.js';
|
|
235
|
+
|
|
236
|
+
form.$context.ui.tabs.get(Tabs.SUMMARYTAB).setVisible(true);
|
|
237
|
+
form.$context.ui.tabs.get(Tabs.SUMMARYTAB).sections.get(SummarySections.General).setVisible(false);
|
|
238
|
+
(form.$context.getControl(Subgrids.Orders) as Xrm.Controls.GridControl).refresh();
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 12. Notification IDs from NOTIFICATION_IDS
|
|
242
|
+
|
|
243
|
+
All notification unique IDs must be in `constants.ts`, never inline raw strings:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// constants.ts:
|
|
247
|
+
export const NOTIFICATION_IDS = {
|
|
248
|
+
genericError: 'lmapp.notification.generic-error',
|
|
249
|
+
saveWarning: 'lmapp.notification.save-warning',
|
|
250
|
+
addressMissing: 'lmapp.notification.address-missing',
|
|
251
|
+
} as const;
|
|
252
|
+
|
|
253
|
+
// form script:
|
|
254
|
+
form.$context.ui.setFormNotification(msg, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### 13. Xrm constants from @xrmforge/helpers for ALL Xrm enum values
|
|
258
|
+
|
|
259
|
+
Never use raw strings or magic numbers for Xrm API constants:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
import { SaveMode, FormNotificationLevel, RequiredLevel, SubmitMode, DisplayState } from '@xrmforge/helpers';
|
|
263
|
+
|
|
264
|
+
// Save mode:
|
|
265
|
+
if (ctx.getEventArgs().getSaveMode() === SaveMode.AutoSave) { ... } // not === 70
|
|
266
|
+
|
|
267
|
+
// Form type (const enum from @types/xrm, works at runtime):
|
|
268
|
+
if (form.$context.ui.getFormType() === XrmEnum.FormType.Create) { ... } // not === 1
|
|
269
|
+
|
|
270
|
+
// Display state:
|
|
271
|
+
if (tab.getDisplayState() === DisplayState.Expanded) { ... } // not === 'expanded'
|
|
272
|
+
|
|
273
|
+
// Required level:
|
|
274
|
+
form.$context.getAttribute(Fields.Name).setRequiredLevel(RequiredLevel.Required); // not 'required'
|
|
275
|
+
|
|
276
|
+
// Submit mode:
|
|
277
|
+
form.$context.getAttribute(Fields.Name).setSubmitMode(SubmitMode.Always); // not 'always'
|
|
278
|
+
|
|
279
|
+
// Notification level:
|
|
280
|
+
form.$context.ui.setFormNotification(msg, FormNotificationLevel.Error, id); // not 'ERROR'
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### 14. EntityNames in openForm and ALL entity references
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
// openForm:
|
|
287
|
+
Xrm.Navigation.openForm({ entityName: EntityNames.Account, entityId: id }); // not "account"
|
|
288
|
+
|
|
289
|
+
// openWebResource, openUrl, etc.: use EntityNames wherever an entity name appears
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### 15. Module exports, Structured Logger, createFormMock
|
|
293
|
+
|
|
294
|
+
- Module exports (not window/global assignments). esbuild globalName handles namespacing.
|
|
295
|
+
- `createLogger()` instead of console.* (except in logger.ts itself)
|
|
296
|
+
- `createFormMock()` from @xrmforge/testing for ALL form tests
|
|
297
|
+
|
|
298
|
+
## Rules: NEVER (every occurrence is a bug)
|
|
299
|
+
|
|
300
|
+
**Field/Entity/Resource names:**
|
|
301
|
+
- Never raw strings in `getAttribute()`, `getControl()`, `select()`, `$filter`, `$expand`, `$orderby`, `parseLookup()`, FetchXML `attribute=`, or any function that takes a field name
|
|
302
|
+
- Never raw entity name strings in `Xrm.WebApi`, `Xrm.Navigation.openForm`, or anywhere an entity name appears (use `EntityNames`)
|
|
303
|
+
- Never raw tab/section/subgrid names (use generated Tabs/Sections/Subgrids enums)
|
|
304
|
+
- Never raw notification IDs (use `NOTIFICATION_IDS` from constants.ts)
|
|
305
|
+
- Never create `SystemEntities` objects with raw strings (extend generation with `--entities`)
|
|
306
|
+
|
|
307
|
+
**Magic values:**
|
|
308
|
+
- Never magic numbers for OptionSet values, status codes, or FetchXML `<value>` (use OptionSet Enums)
|
|
309
|
+
- Never magic numbers for time calculations (use named constants like `MS_PER_DAY`)
|
|
310
|
+
- Never `getSaveMode() === 70` (use `SaveMode.AutoSave` from @xrmforge/helpers)
|
|
311
|
+
- Never `getFormType() === 1` (use `XrmEnum.FormType.Create`)
|
|
312
|
+
- Never `'expanded'`/`'collapsed'` (use `DisplayState` from @xrmforge/helpers)
|
|
313
|
+
- Never `'ERROR'`/`'INFO'`/`'WARNING'` (use `FormNotificationLevel`)
|
|
314
|
+
- Never `'none'`/`'required'`/`'recommended'` (use `RequiredLevel`)
|
|
315
|
+
- Never `'always'`/`'dirty'` (use `SubmitMode`)
|
|
316
|
+
|
|
317
|
+
**Web API responses:**
|
|
318
|
+
- Never access WebApi response properties with `as string` casts (use generated Entity interfaces)
|
|
319
|
+
- Never `.getValue()[0].id` for lookups (use `formLookup`/`formLookupId`)
|
|
320
|
+
- Never raw strings in `parseLookup()` (use NavigationProperties enum)
|
|
321
|
+
|
|
322
|
+
**Code quality:**
|
|
323
|
+
- Never `Xrm.Page` (deprecated since D365 v9.0)
|
|
324
|
+
- Never `eval()`, never synchronous XMLHttpRequest
|
|
325
|
+
- Never `window.X = ...` (use module exports)
|
|
326
|
+
- Never `console.log/warn/error` in form scripts (use shared logger)
|
|
327
|
+
- Never export handlers without `wrapHandler()`
|
|
328
|
+
- Never unlokalized UI strings (use `pickLang()` from constants.ts)
|
|
329
|
+
- Never build your own getValue/setFieldValue/setDisabled/addOnChange helpers (use `typedForm` + native Xrm API)
|
|
330
|
+
- Never `import ... from '@xrmforge/typegen'` in browser code (use `@xrmforge/helpers`)
|
|
331
|
+
- Never `as any` without eslint-disable comment explaining why
|
|
332
|
+
- Never untyped `catch (error)` (always `catch (error: unknown)`)
|
|
333
|
+
|
|
334
|
+
## Subagent Handoff (when delegating to sub-agents)
|
|
335
|
+
|
|
336
|
+
Copy these MANDATORY rules into every sub-agent prompt:
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
1. typedForm<FormType>(ctx.getFormContext()) for ALL field access
|
|
340
|
+
2. Entity-level Fields Enums in ALL select(), $filter, $expand, $orderby, FetchXML attribute=
|
|
341
|
+
3. OptionSet Enum for ALL value comparisons AND FetchXML <value> (never magic numbers)
|
|
342
|
+
4. EntityNames for ALL Xrm.WebApi calls AND openForm (never raw entity names)
|
|
343
|
+
5. formLookup/formLookupId for ALL lookup access (never .getValue()[0].id)
|
|
344
|
+
6. parseLookup with NavigationProperties enum (never raw nav property strings)
|
|
345
|
+
7. Generated Entity interfaces for ALL WebApi response typing (never as string casts)
|
|
346
|
+
8. Tabs/Sections/Subgrids enums for ALL UI structure access (never raw strings)
|
|
347
|
+
9. SaveMode/FormType/DisplayState/RequiredLevel/SubmitMode/FormNotificationLevel constants
|
|
348
|
+
10. wrapHandler() around EVERY exported handler
|
|
349
|
+
11. createLogger() instead of console.* (except logger.ts)
|
|
350
|
+
12. Custom API Executors from generated/actions/ (never build your own)
|
|
351
|
+
13. NOTIFICATION_IDS from constants.ts for all notification unique IDs
|
|
352
|
+
14. Named constants for non-obvious values (never magic numbers like 86400000)
|
|
353
|
+
15. pickLang() for all user-visible strings (never hardcoded German/English)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Mandatory Shared Utilities
|
|
357
|
+
|
|
358
|
+
Every XrmForge project MUST have these in `src/shared/`:
|
|
359
|
+
|
|
360
|
+
### logger.ts
|
|
361
|
+
```typescript
|
|
362
|
+
export interface Logger { debug(msg: string, data?: unknown): void; info(...); warn(...); error(...); }
|
|
363
|
+
export function createLogger(namespace: string): Logger;
|
|
364
|
+
// Only file allowed to use console.*
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### error-handler.ts
|
|
368
|
+
```typescript
|
|
369
|
+
export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler;
|
|
370
|
+
export function wrapCommand(name: string, logger: Logger, handler: CommandHandler): CommandHandler;
|
|
371
|
+
// Catches sync+async errors, shows form notification via FormNotificationLevel.Error
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### constants.ts
|
|
375
|
+
```typescript
|
|
376
|
+
export const NOTIFICATION_IDS = { ... } as const;
|
|
377
|
+
export const MESSAGES = { de: { ... }, en: { ... } } as const;
|
|
378
|
+
export function pickLang<K extends string>(languageId: number, table: ...): Record<K, string>;
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Before/After Examples
|
|
382
|
+
|
|
383
|
+
### Field Access (primary pattern: typedForm)
|
|
384
|
+
```typescript
|
|
385
|
+
// BEFORE (legacy):
|
|
386
|
+
formContext.getAttribute("name").getValue()
|
|
387
|
+
// BEFORE (getAttribute + Fields):
|
|
388
|
+
form.getAttribute(Fields.Name).getValue()
|
|
389
|
+
// AFTER (typedForm - preferred):
|
|
390
|
+
const form = typedForm<AccountForm>(ctx.getFormContext());
|
|
391
|
+
form.name.getValue()
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Web API Query
|
|
395
|
+
```typescript
|
|
396
|
+
// BEFORE:
|
|
397
|
+
Xrm.WebApi.retrieveRecord("account", id, "?$select=name,revenue")
|
|
398
|
+
// AFTER:
|
|
399
|
+
import { AccountFields } from '../../generated/fields/account.js';
|
|
400
|
+
Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
|
|
401
|
+
select(AccountFields.Name, AccountFields.Revenue))
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### OptionSet Comparison
|
|
405
|
+
```typescript
|
|
406
|
+
// BEFORE: if (status.getValue() === 595300002) { ... }
|
|
407
|
+
// AFTER:
|
|
408
|
+
import { StatusCode } from '../../generated/optionsets/invoice.js';
|
|
409
|
+
if (form.statuscode.getValue() === StatusCode.Gebucht) { ... }
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### FetchXML
|
|
413
|
+
```typescript
|
|
414
|
+
// BEFORE:
|
|
415
|
+
`<condition attribute='statuscode' operator='in'><value>1</value><value>105710000</value></condition>`
|
|
416
|
+
// AFTER:
|
|
417
|
+
import { LmBestellungFields } from '../../generated/fields/lm_bestellung.js';
|
|
418
|
+
import { LmBestellungStatusCode } from '../../generated/optionsets/lm_bestellung.js';
|
|
419
|
+
`<condition attribute='${LmBestellungFields.Statuscode}' operator='in'>
|
|
420
|
+
<value>${LmBestellungStatusCode.Aktiv}</value>
|
|
421
|
+
<value>${LmBestellungStatusCode.InArbeit}</value>
|
|
422
|
+
</condition>`
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Lookup Access
|
|
426
|
+
```typescript
|
|
427
|
+
// BEFORE: form.getAttribute("customerid").getValue()[0].id.replace("{","").replace("}","")
|
|
428
|
+
// AFTER:
|
|
429
|
+
const customerId = formLookupId(form.customerid);
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Custom API Call
|
|
433
|
+
```typescript
|
|
434
|
+
// BEFORE: ExecuteFunctionCall("CancelInvoice", { InvoiceId: id })
|
|
435
|
+
// AFTER:
|
|
436
|
+
import { CancelInvoice } from '../../generated/actions/global.js';
|
|
437
|
+
const result = await withProgress(
|
|
438
|
+
CancelInvoice.execute({ InvoiceId: id }),
|
|
439
|
+
{ title: 'Rechnung wird storniert...' }
|
|
440
|
+
);
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Date Calculation
|
|
444
|
+
```typescript
|
|
445
|
+
// BEFORE: new Date(date.getTime() + nettotage * 86400000)
|
|
446
|
+
// AFTER:
|
|
447
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
448
|
+
new Date(date.getTime() + nettotage * MS_PER_DAY)
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### UI Strings
|
|
452
|
+
```typescript
|
|
453
|
+
// BEFORE: form.title.setValue('[Kurzbeschreibung der Anfrage]')
|
|
454
|
+
// AFTER:
|
|
455
|
+
const lang = pickLang(Xrm.Utility.getGlobalContext().userSettings.languageId, MESSAGES);
|
|
456
|
+
form.title.setValue(lang.titlePlaceholder);
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Testing (onLoad + onChange)
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
|
|
463
|
+
|
|
464
|
+
beforeEach(() => setupXrmMock());
|
|
465
|
+
afterEach(() => teardownXrmMock());
|
|
466
|
+
|
|
467
|
+
// onLoad test:
|
|
468
|
+
const mock = createFormMock<AccountMainForm>({ name: 'Test', statuscode: 0 });
|
|
469
|
+
onLoad(mock.asEventContext());
|
|
470
|
+
expect(mock.getControl(Fields.Revenue).getVisible()).toBe(true);
|
|
471
|
+
|
|
472
|
+
// onChange test (MANDATORY for every onChange handler):
|
|
473
|
+
mock.setValue(Fields.Revenue, 500000);
|
|
474
|
+
mock.fireOnChange(Fields.Revenue);
|
|
475
|
+
expect(mock.getControl(Fields.CreditLimit).getVisible()).toBe(true);
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**Test quality rule:** At least 30% of tests MUST use `fireOnChange` or WebApi mock
|
|
479
|
+
assertions. Pure smoke tests (`onLoad` + `not.toThrow`) do NOT count.
|
|
480
|
+
Every onChange handler MUST have a `fireOnChange` test.
|
|
481
|
+
|
|
482
|
+
**attr.controls:** Since @xrmforge/testing 0.2.3, `createFormMock()` automatically links
|
|
483
|
+
each attribute to its control. `mock.getControl(Fields.Name)` works out of the box.
|
|
484
|
+
|
|
485
|
+
## Pattern Recognition: Legacy to XrmForge
|
|
486
|
+
|
|
487
|
+
| Legacy Pattern | XrmForge Replacement |
|
|
488
|
+
|---|---|
|
|
489
|
+
| `getAttribute("name")` | `form.name` (via typedForm) or `getAttribute(Fields.Name)` |
|
|
490
|
+
| `getControl("name")` | `form.$control(Fields.Name)` |
|
|
491
|
+
| `getValue() === 595300000` | `form.statuscode.getValue() === StatusCode.Active` |
|
|
492
|
+
| `Xrm.WebApi.retrieveRecord("account", id)` | `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)` |
|
|
493
|
+
| `"?$select=name,revenue"` | `select(AccountFields.Name, AccountFields.Revenue)` |
|
|
494
|
+
| `value[0].id.replace("{","")` | `formLookupId(form.customerid)` |
|
|
495
|
+
| `Xrm.Page.getAttribute(...)` | `form.fieldname` (via typedForm) |
|
|
496
|
+
| `var formContext` (global) | `const form = typedForm<MyForm>(ctx.getFormContext())` |
|
|
497
|
+
| `function form_OnLoad(ctx)` | `export const onLoad = wrapHandler(...)` |
|
|
498
|
+
| `ExecuteFunctionCall("name", ...)` | `import { Name } from '../../generated/actions/global.js'` |
|
|
499
|
+
| `setFormNotification(msg, 'ERROR', id)` | `setFormNotification(msg, FormNotificationLevel.Error, id)` |
|
|
500
|
+
| `86400000` | `const MS_PER_DAY = 24 * 60 * 60 * 1000` |
|
|
501
|
+
| `'[Kurzbeschreibung]'` | `pickLang(languageId, MESSAGES).placeholder` |
|
|
502
|
+
|
|
503
|
+
## @types/xrm Pitfalls (known issues)
|
|
504
|
+
|
|
505
|
+
1. **Form Interface:** Do NOT use `interface extends Xrm.FormContext`. Use `Omit` pattern.
|
|
506
|
+
2. **AlertDialogResponse** does NOT exist. Use `Xrm.Async.PromiseLike<void>`.
|
|
507
|
+
3. **ConfirmDialogResponse** does NOT exist. Use `Xrm.Navigation.ConfirmResult`.
|
|
508
|
+
4. **setNotification()** requires 2 arguments: (message, uniqueId).
|
|
509
|
+
5. **openFile()** requires `fileSize` property in FileDetails.
|
|
510
|
+
6. **Grid.refresh()** requires `(grid as any).refresh()` with eslint-disable comment.
|
|
511
|
+
|
|
512
|
+
## Build
|
|
513
|
+
|
|
514
|
+
```bash
|
|
515
|
+
npx xrmforge build # IIFE bundles for D365
|
|
516
|
+
npx xrmforge build --watch # Watch mode (~10ms rebuilds)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
## File Structure
|
|
520
|
+
|
|
521
|
+
```
|
|
522
|
+
src/forms/{entity}-form.ts - Form scripts (one per entity)
|
|
523
|
+
src/shared/logger.ts - Structured logger (only file with console.*)
|
|
524
|
+
src/shared/error-handler.ts - wrapHandler + wrapCommand
|
|
525
|
+
src/shared/constants.ts - NOTIFICATION_IDS, MESSAGES, pickLang
|
|
526
|
+
generated/ - Generated types (do not edit manually)
|
|
527
|
+
tests/forms/{entity}.test.ts - Tests
|
|
528
|
+
xrmforge.config.json - Build config
|
|
529
|
+
scripts/validate-form.mjs - Quality gate (run after each batch)
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
## Self-Check (MANDATORY before Tests)
|
|
533
|
+
|
|
534
|
+
Run `node scripts/validate-form.mjs` after every batch. Must report 0 violations.
|