@jigx/core-sdk 1.0.0 → 1.2.0-rc

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.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/dist/action/ja.generate-pdf.d.ts +17 -1
  3. package/dist/action/ja.generate-pdf.d.ts.map +1 -1
  4. package/dist/action/ja.generate-pdf.js +4 -1
  5. package/dist/action/ja.in-background.d.ts +3 -2
  6. package/dist/action/ja.in-background.d.ts.map +1 -1
  7. package/dist/action/ja.in-background.js +1 -1
  8. package/dist/assets/example-extraction-cache.json +3 -3
  9. package/dist/assets/extracted-core-sdk-examples.yaml +18 -0
  10. package/dist/assets/extracted-core-sdk-types.yaml +58 -0
  11. package/dist/assets/type-extraction-cache.json +3 -3
  12. package/docs/array-fields.md +371 -0
  13. package/docs/conditional-logic.md +178 -0
  14. package/docs/convention-naming.md +102 -0
  15. package/docs/date-field.md +92 -0
  16. package/docs/dropdown-fields.md +879 -0
  17. package/docs/field-state.md +131 -0
  18. package/docs/field-types-overview.md +132 -0
  19. package/docs/formatting.md +421 -0
  20. package/docs/icons.md +142 -0
  21. package/docs/index.md +23 -0
  22. package/docs/jsonata-expressions.md +200 -0
  23. package/docs/media-fields.md +107 -0
  24. package/docs/overview.md +467 -0
  25. package/docs/pattern-build-deploy.md +91 -0
  26. package/docs/pattern-datasources.md +459 -0
  27. package/docs/pattern-forms.md +528 -0
  28. package/docs/pattern-global-actions.md +92 -0
  29. package/docs/pattern-javascript-functions.md +452 -0
  30. package/docs/pattern-navigation.md +304 -0
  31. package/docs/pattern-pdf-generation.md +391 -0
  32. package/docs/pattern-rest-acumatica.md +660 -0
  33. package/docs/pattern-sync-progress.md +96 -0
  34. package/docs/pattern-sync.md +653 -0
  35. package/docs/pattern-tabs-form.md +293 -0
  36. package/docs/recipe-index.md +64 -0
  37. package/docs/runtime-variables.md +127 -0
  38. package/docs/sections.md +81 -0
  39. package/docs/validation-patterns.md +150 -0
  40. package/package.json +5 -4
  41. package/CHANGELOG.md +0 -95
@@ -0,0 +1,528 @@
1
+ # Pattern: Forms (generic)
2
+
3
+ Applies to **any** form — single-screen, tabbed, modal, wizard, detail-from-list.
4
+ Tab-specific extras (cross-jig validation, propagating state via inputs) live in
5
+ [pattern-tabs-form.md](pattern-tabs-form.md).
6
+
7
+ ## Required fields
8
+
9
+ Always set `isRequired` explicitly on every field — `true` or `false`. Omitting it
10
+ may render fields as required by default depending on type, which surprises users.
11
+
12
+ ```typescript
13
+ form.addControl.textField({
14
+ instanceId: 'customerName',
15
+ label: 'Customer Name',
16
+ isRequired: true,
17
+ })
18
+ form.addControl.textField({
19
+ instanceId: 'addressLine2',
20
+ label: 'Address Line 2',
21
+ isRequired: false,
22
+ })
23
+ ```
24
+
25
+ ## Form container
26
+
27
+ Wrap fields in a form so `@ctx.components.<formId>.state.isValid` and `state.isDirty`
28
+ are available for validation and discard-alert logic:
29
+
30
+ ```typescript
31
+ const form = screen.addControl.form({
32
+ instanceId: 'contactInfo',
33
+ isDiscardChangesAlertEnabled: '=@ctx.jig.state.upload = true ? false:true',
34
+ })
35
+ ```
36
+
37
+ The `isDiscardChangesAlertEnabled` expression is the hook for suppressing the
38
+ "Discard changes?" dialog during a save — see below.
39
+
40
+ ## Save button — `isDisabled`, never `when`
41
+
42
+ **Rule:** Use `isDisabled` on the save button, not `when`.
43
+
44
+ - `when` means "don't run the action if the condition is false". The button still
45
+ *looks* enabled, but tapping it does nothing. Users interpret this as a bug.
46
+ - `isDisabled` makes the button visibly greyed out when the form is invalid —
47
+ users instantly see why they can't proceed.
48
+
49
+ ```typescript
50
+ // WRONG — button looks enabled but silently unresponsive when invalid
51
+ buttons.add.list({
52
+ title: 'Save',
53
+ when: '=@ctx.components.form.state.isValid',
54
+ }).actions
55
+
56
+ // RIGHT — button visibly disabled until form is valid
57
+ buttons.add.list({
58
+ title: 'Save',
59
+ isDisabled: '=@ctx.components.form.state.isValid = true ? false:true',
60
+ }).actions
61
+ ```
62
+
63
+ `isDisabled` is a top-level property on any action builder (`ActionBaseInput`). The
64
+ SDK transforms it to `style.isDisabled` in the output YAML — you don't write the
65
+ nested `style` wrapper in TypeScript.
66
+
67
+ Applies to `buttons.add.list({...})`, `buttons.add.executeEntity({...})`,
68
+ `buttons.add.executeAction({...})`, `.swipeLeft()`, `.swipeRight()` — anywhere you
69
+ have an action button.
70
+
71
+ ## Save action list — correct ordering
72
+
73
+ For any form using the upload-state discard-alert pattern, the sequential save action
74
+ list **must** be ordered:
75
+
76
+ 1. **`setScreenState({ upload: true })`** — suppress discard alert FIRST
77
+ 2. **Saves** (`executeEntity`, `executeEntities`) — do the work. Conditional `when`
78
+ on individual steps is fine (e.g., "only save signature if changed")
79
+ 3. **`resetScreenState({ keys: ['upload'] })`** — reset state BEFORE navigating
80
+ 4. **`goBack`** — LAST, standalone, never chained
81
+
82
+ ```typescript
83
+ screen.with({ state: { upload: { initialValue: null } } })
84
+
85
+ const form = screen.addControl.form({
86
+ instanceId: 'contactInfo',
87
+ isDiscardChangesAlertEnabled: '=@ctx.jig.state.upload = true ? false:true',
88
+ })
89
+
90
+ const saveActions = screen.bottomPanel().add.list({
91
+ concurrency: 'sequential',
92
+ title: 'Save Information',
93
+ isDisabled: '=@ctx.components.contactInfo.state.isValid = true ? false:true',
94
+ }).actions
95
+
96
+ // 1. Disable alert
97
+ saveActions.setScreenState({ instanceId: 'set-upload', keys: { upload: true } })
98
+
99
+ // 2. Saves (conditional `when` on individual steps is fine)
100
+ saveActions
101
+ .executeEntity({ instanceId: 'save-info' })
102
+ .dynamicData('default/salesInfoAnswers', 'save')
103
+ .data({ /* ... */ })
104
+
105
+ saveActions
106
+ .executeEntity({
107
+ instanceId: 'save-signature',
108
+ when: '=$exists(@ctx.datasources.signatures.data.signatureImage) = false or @ctx.datasources.signatures.data.signatureImage != @ctx.components.signature.state.value',
109
+ conversions: [{ property: 'signatureImage', from: 'local-uri', to: 'base64' }],
110
+ })
111
+ .dynamicData('default/formSignatures', 'save')
112
+ .data({ /* ... */ })
113
+
114
+ // 3. Reset BEFORE goBack — jig state is unreachable after goBack
115
+ saveActions.resetScreenState({ instanceId: 'reset-upload', keys: ['upload'] })
116
+
117
+ // 4. goBack LAST, standalone
118
+ saveActions.goBack({ instanceId: 'go-back' })
119
+ ```
120
+
121
+ If the save keeps the user on the same screen instead of navigating away, keep the
122
+ same first and third steps:
123
+
124
+ 1. `setScreenState({ upload: true })`
125
+ 2. saves
126
+ 3. `resetScreenState({ keys: ['upload'] })`
127
+
128
+ In that variant there is no `goBack`, but you should still use the same `upload`
129
+ state contract so discard warnings behave consistently.
130
+
131
+ ### Why the ordering matters
132
+
133
+ **Rule 1 — `resetScreenState` before `goBack`:** Once `goBack` fires, the jig is gone
134
+ from context. Any subsequent `setScreenState` / `resetScreenState` targeting that jig's
135
+ state silently fails because the state object no longer exists. Always reset state
136
+ *before* navigating away.
137
+
138
+ **Rule 2 — `goBack` is standalone and last:** Never chain `.onSuccess.goBack()` to an
139
+ earlier step, especially one with a `when` guard (e.g., a conditional signature save).
140
+ If the guard is false the step is skipped entirely and its `onSuccess` never fires —
141
+ the user gets stuck on the form with no feedback. This exact bug shipped in
142
+ doorpro-door-quote on 2026-04-09 and confused us for a full session.
143
+
144
+ **Rule 3 — Why the discard alert still works:** You might wonder: if `resetScreenState`
145
+ fires before `goBack`, isn't the alert re-enabled at goBack time? It is — but the form
146
+ is no longer "dirty" because component values match the just-saved datasource data. The
147
+ form's dirty check compares current values against freshly saved values, not the
148
+ original initial-render values. So the alert doesn't fire even though it's "enabled".
149
+ The `upload=true` flag only needs to cover the window *during* the saves, not after.
150
+
151
+ ## Conditional signature / media save
152
+
153
+ For image fields that are expensive to re-upload, guard the save with a `when` that
154
+ only runs the write if the value actually changed:
155
+
156
+ ```typescript
157
+ saveActions
158
+ .executeEntity({
159
+ instanceId: 'save-signature',
160
+ when: '=$exists(@ctx.datasources.signatures.data.signatureImage) = false or @ctx.datasources.signatures.data.signatureImage != @ctx.components.signature.state.value',
161
+ conversions: [{ property: 'signatureImage', from: 'local-uri', to: 'base64' }],
162
+ })
163
+ .dynamicData('default/formSignatures', 'save')
164
+ .data({ /* ... */ })
165
+ ```
166
+
167
+ - `when` on the *step* is fine — it skips cleanly.
168
+ - `when` on the *button* is bad — it hides the disabled state from the user.
169
+ - `conversions` converts the field at save time. `local-uri` → `base64` produces a
170
+ base64 string suitable for embedding in `<img src="data:image/png;base64,...">` in
171
+ a PDF template. See [pattern-datasources.md](pattern-datasources.md#signature-and-media-storage--uri-vs-base64)
172
+ for the URI vs base64 trade-off.
173
+
174
+ ## Derived defaults must be visible before save
175
+
176
+ If a lookup selection derives editable field values such as price, tax category, UOM,
177
+ account, or address defaults, populate those values into visible field state when the
178
+ selection changes. Do not only calculate them inside the final `.data(...)` save
179
+ expression.
180
+
181
+ If the user cannot see the derived values before tapping save, the UI and the saved
182
+ record drift apart and debugging becomes harder. Preferred pattern:
183
+
184
+ 1. Use the lookup field's `onChange` to `setScreenState(...)` with the derived values.
185
+ 2. Bind the affected controls to those screen-state keys (with fallback to existing
186
+ datasource values when editing).
187
+ 3. Save from the same derived state so the visible form and persisted payload match.
188
+
189
+ ## Attribute-derived item codes
190
+
191
+ When an external system constructs an item/inventory code from ordered attribute
192
+ segments, do not build the code from display descriptions. Store both the
193
+ human-readable field value and the segment/value ID needed by the external system.
194
+
195
+ Use the specification-defined segment order, not the order fields happen to appear
196
+ in the saved JSON. For Acumatica door item numbers, this means joining the selected
197
+ attribute `valueID` fields with the documented separator, then showing the computed
198
+ code in a read-only field before save:
199
+
200
+ ```typescript
201
+ const doorCodeExpression =
202
+ '=($segments := [' +
203
+ [
204
+ '(@ctx.components.size.state.selected.valueID ? @ctx.components.size.state.selected.valueID : @ctx.datasources.doorDetails.data.sizeValueID)',
205
+ '(@ctx.components.modser.state.selected.valueID ? @ctx.components.modser.state.selected.valueID : @ctx.datasources.doorDetails.data.modserValueID)',
206
+ '(@ctx.components.coloram.state.selected.valueID ? @ctx.components.coloram.state.selected.valueID : @ctx.datasources.doorDetails.data.coloramValueID)',
207
+ ].join(', ') +
208
+ ']; $join($filter($segments, function($v) { $exists($v) and $string($v) != "" }), "-"))'
209
+
210
+ section.addControl.textField({
211
+ instanceId: 'doorCode',
212
+ label: 'Door Code',
213
+ isRequired: false,
214
+ isDisabled: true,
215
+ value: doorCodeExpression,
216
+ })
217
+ ```
218
+
219
+ Save the same expression into the local row (for list/PDF display) and save each
220
+ segment `valueID` separately so copies and edits do not depend on re-looking up the
221
+ description later. Use the customer's required separator exactly. If the customer
222
+ requires a compact code, join segments with `""` and do not pad spaces, even when an
223
+ older draft specification mentioned separators or fixed-width padding.
224
+
225
+ ### Manufacturer-specific attribute chains
226
+
227
+ When the attribute sequence changes by manufacturer or product family, do not force
228
+ one generic form to handle all variants. Use a lightweight chooser/list screen first,
229
+ then navigate to the manufacturer-specific builder for the selected chain. This keeps
230
+ the save action, form validity, discard-warning state, and item-code construction in
231
+ the same jig context.
232
+
233
+ Preferred pattern:
234
+
235
+ 1. Create a chooser jig backed by a static or local datasource with `manufacturer`,
236
+ `title`, `description`, and `target` jig fields.
237
+ 2. Filter out gateway values whose downstream sequence is not yet specified.
238
+ 3. On manufacturer press, `go-to` the target manufacturer jig with the stable local
239
+ row id and line number as inputs/parameters.
240
+ 4. In each manufacturer jig, declare fields in the exact specification order.
241
+ 5. Put the save button on the same manufacturer jig as the form and use that form's
242
+ `state.isValid` for `isDisabled`.
243
+ 6. Store both display text and `valueID` for every attribute segment.
244
+ 7. Build the final item code from the specification order, not from object key order.
245
+ 8. If the customer temporarily removes a segment such as Template ID, remove it from
246
+ the visible form, required validation, saved payload, and code expression together.
247
+
248
+ Avoid putting a parent save button above `component.jig`-hosted manufacturer forms.
249
+ In runtime testing, embedded jig outputs were `null` in the wrapper action context,
250
+ which caused valid forms to be treated as incomplete. Keeping the save action on the
251
+ actual manufacturer jig avoids cross-jig validity and state propagation failures.
252
+
253
+ For optional or conditional segments, keep the segment expression in the documented
254
+ position and return an empty string only when the specification says the segment is
255
+ not applicable. The join/filter step can then omit the blank segment without moving
256
+ later segments out of order.
257
+
258
+ ### Copying quote/detail records
259
+
260
+ When copying a quote or parent record, copy business fields and repeatable child
261
+ details, but reset workflow/integration state:
262
+
263
+ - Set a new parent id/opportunity id.
264
+ - Set the copied quote date to `$now()` so it reflects the copy date.
265
+ - Reset an order quote back to a non-order quote when only one order is allowed.
266
+ - Clear generated artifacts such as `pdfFilePath`, `pdfGeneratedAt`, and `pdfStale`.
267
+ - Do not copy signatures, form progress, or Acumatica submission/status rows.
268
+ - Copy child line fields that define the quote, including attributes, generated
269
+ internal codes, line pricing, tax, and totals.
270
+ - When copying a child line inside the same quote, assign a new child id, new line
271
+ number, and new display label, then mark the quote PDF stale.
272
+
273
+ ### Temporary local seeding for pending attributes
274
+
275
+ If a customer has documented an attribute chain but has not yet loaded the future
276
+ attributes into Acumatica, seed temporary local `Attributes` and `AttributeDetails`
277
+ rows with `action.execute-sql` using `INSERT OR IGNORE`. Keep this seed isolated to
278
+ the app lifecycle, mark it as temporary in code, and use the documented future
279
+ attribute IDs so the UI and save payloads do not need to be renamed later.
280
+
281
+ Do not seed over real customer data. Use stable ids such as `ATTRIBUTEID_VALUEID`,
282
+ write JSON into the `data` column, and list the affected local entities in the
283
+ `execute-sql` action so datasources refresh.
284
+
285
+ ### Add Spaces / padded segments
286
+
287
+ When an attribute specification marks `Add Spaces = true`, pad that segment to the
288
+ specified max character length before joining it into the item code. Apply padding to
289
+ the segment value itself, not to the separator. A safe JSONata pattern is:
290
+
291
+ ```typescript
292
+ const padded = '$substring($string($segment) & " ", 0, 11)'
293
+ ```
294
+
295
+ Use the per-manufacturer max length from the specification. Shared attributes can
296
+ have different max lengths in different chains, so do not centralize a single global
297
+ length per attribute ID.
298
+
299
+ ## Global action for save logic
300
+
301
+ Inline save action lists work, but they duplicate 40+ lines across every form that
302
+ follows the same pattern. Extract to a global action when:
303
+
304
+ - Multiple form screens share the same save flow (progress + data + signature)
305
+ - You want to test the save logic in isolation
306
+ - You want the save to complete reliably even if the user navigates mid-save
307
+
308
+ ### Why global actions are more robust
309
+
310
+ Global actions run in **their own action context**. Parameters are **snapshotted at
311
+ call time** and held for the duration of the action. In contrast, an inline action
312
+ list on a screen button references the screen's context directly — if the user starts
313
+ navigating while the action is mid-flight, subsequent steps may see stale or missing
314
+ context.
315
+
316
+ The form's role shrinks to: "collect inputs, call global action with snapshot". Cleaner.
317
+
318
+ ### Shape of a form-save global action
319
+
320
+ ```typescript
321
+ // src/actions/act-quote-save-customer-info.ts
322
+ export function actQuoteSaveCustomerInfo(app: ApplicationBuilder): void {
323
+ const action = app.addAction({ actionId: 'act-quote-save-customer-info' })
324
+
325
+ // Every field the save needs — snapshotted at call time
326
+ action.addParameter('data', { type: 'object', required: true })
327
+ action.addParameter('signatureImage', { type: 'string', required: false })
328
+ action.addParameter('formName', { type: 'string', required: true })
329
+ action.addParameter('itemSection', { type: 'string', required: true })
330
+
331
+ const workflow = action.action.list({ concurrency: 'sequential' })
332
+
333
+ // 1. Disable discard alert on the calling screen
334
+ workflow.actions.setScreenState({
335
+ instanceId: 'set-upload',
336
+ keys: { upload: true },
337
+ })
338
+
339
+ // 2. Save progress row (upserts)
340
+ workflow.actions
341
+ .executeEntity({ instanceId: 'save-progress' })
342
+ .dynamicData('default/formProgress', 'save')
343
+ .data({
344
+ id: '=@ctx.solution.state.opportunityid & "-" & @ctx.action.parameters.itemSection',
345
+ opportunityID: '=@ctx.solution.state.opportunityid',
346
+ appNbr: '=@ctx.solution.state.appnbr',
347
+ itemSection: '=@ctx.action.parameters.itemSection',
348
+ formName: '=@ctx.action.parameters.formName',
349
+ isCompleted: '1',
350
+ })
351
+
352
+ // 3. Save the actual form data (parameter snapshot)
353
+ workflow.actions
354
+ .executeEntity({ instanceId: 'save-info' })
355
+ .dynamicData('default/salesInfoAnswers', 'save')
356
+ .data('=@ctx.action.parameters.data')
357
+
358
+ // 4. Reset state FIRST
359
+ workflow.actions.resetScreenState({
360
+ instanceId: 'reset-upload',
361
+ keys: ['upload'],
362
+ })
363
+
364
+ // 5. goBack LAST
365
+ workflow.actions.goBack({ instanceId: 'go-back' })
366
+ }
367
+ ```
368
+
369
+ ### Calling from the form screen
370
+
371
+ ```typescript
372
+ const saveActions = screen.bottomPanel().add.list({
373
+ concurrency: 'sequential',
374
+ title: 'Save',
375
+ isDisabled: '=@ctx.components.contactInfo.state.isValid = true ? false:true',
376
+ }).actions
377
+
378
+ saveActions.executeAction({
379
+ instanceId: 'save-customer-info',
380
+ action: 'act-quote-save-customer-info',
381
+ parameters: {
382
+ formName: '=@ctx.jig.inputs.formName',
383
+ itemSection: '=@ctx.jig.inputs.itemSection',
384
+ signatureImage: '=@ctx.components.salesRepSignature.state.value',
385
+ data: `={
386
+ "id": @ctx.datasources.salesInfoAnswers.data.id ? @ctx.datasources.salesInfoAnswers.data.id : $uuid(),
387
+ "opportunityID": @ctx.solution.state.opportunityid,
388
+ "appNbr": @ctx.solution.state.appnbr,
389
+ "customer": @ctx.components.customer.state.value,
390
+ "addressLine1": @ctx.components.addressLine1.state.value
391
+ }`,
392
+ },
393
+ })
394
+ ```
395
+
396
+ The form screen has almost zero inline save logic. Two references: the button config
397
+ and the data JSONata expression. Everything else lives in the action file.
398
+
399
+ ### Caveats for global actions
400
+
401
+ - **Parameter declaration**: `action.addParameter('name', {...})` — plain name, no `$`.
402
+ - **Parameter access inside the action body**: `@ctx.action.parameters.<name>`.
403
+ - **Parameter values in the caller's `parameters: {}` block**: plain name again, no `$`.
404
+ - **Global actions can access `@ctx.datasources.X` from the caller's screen scope** —
405
+ useful for reading existing record IDs for upserts without passing them as params.
406
+ - **`setScreenState` inside the global action targets the *calling* screen's state**,
407
+ so the upload-discard-alert pattern works across the boundary.
408
+
409
+ ## Navigating TO a form — only pass declared inputs
410
+
411
+ When navigating to a form screen via `goto` or `executeAction`, only pass parameters
412
+ that the target screen has declared via `addInput(...)`. Passing undeclared parameters
413
+ can cause the navigation to fail silently — the target screen never loads or loads
414
+ with partial state.
415
+
416
+ If the form needs data from the caller that isn't a declared input, either:
417
+
418
+ - Declare it as an input on the form
419
+ - Or put it in solution state (`setApplicationState`) from the caller's `onFocus` and
420
+ read it via `@ctx.solution.state.<key>` in the form
421
+
422
+ ## Editing an existing record
423
+
424
+ Form screens typically filter a datasource by some ID (e.g., `opportunityID` from
425
+ solution state) to load existing data, then use `initialValue` on each field to
426
+ populate from that datasource. A new record has no matching row → filter returns empty
427
+ → `initialValue` resolves to null/fallback → user sees a blank form.
428
+
429
+ ```typescript
430
+ screen.addDatasource
431
+ .sqlite({ datasourceId: 'salesInfoAnswers', provider: 'dynamic' })
432
+ .entity('default/salesInfoAnswers')
433
+ .query(
434
+ `SELECT id, data FROM [default/salesInfoAnswers]
435
+ WHERE json_extract(data, '$.opportunityID') = @opportunityID`,
436
+ )
437
+ .queryParameter('opportunityID', '=@ctx.solution.state.opportunityid')
438
+ .jsonProperties('data')
439
+ .isDocument(true) // single-row lookup → returns object, not array
440
+
441
+ form.addControl.textField({
442
+ instanceId: 'customerName',
443
+ label: 'Customer Name',
444
+ isRequired: true,
445
+ initialValue: '=@ctx.datasources.salesInfoAnswers.data.customerName',
446
+ })
447
+ ```
448
+
449
+ On save, use the same datasource to detect whether this is a create or update:
450
+
451
+ ```typescript
452
+ .data({
453
+ id: '=@ctx.datasources.salesInfoAnswers.data.id ? @ctx.datasources.salesInfoAnswers.data.id : $uuid()',
454
+ // ... rest of fields
455
+ })
456
+ ```
457
+
458
+ `method: 'save'` is an upsert — it creates if `id` is new, updates if `id` exists.
459
+ No need to branch between create/update methods.
460
+
461
+ ### Filter by the unique record key only
462
+
463
+ **Critical:** filter the edit-existing-record datasource by the **unique** record key
464
+ only — typically `opportunityID` or whatever uniquely identifies the thing being
465
+ edited. Do **not** add `OR <other-key>` clauses like `OR appNbr = @appNbr`.
466
+
467
+ The legacy sales-form-triplicate pattern used `WHERE opportunityID = @opportunityID
468
+ OR appNbr = @appNbr` to support both "existing opportunity" and "new appointment
469
+ without opportunity yet" lookups. This breaks as soon as you support **multiple
470
+ records per appointment** (e.g., multiple quotes under the same appointment number):
471
+
472
+ - The `OR appNbr` clause matches every record under the appointment
473
+ - `isDocument: true` picks one — often the wrong one
474
+ - On save, `data.id` points to a different record's UUID → either silently updates
475
+ the wrong record, or returns null and `$uuid()` generates a fresh UUID → new row
476
+ - Symptoms: save button label stays "Save Information" after saving (because the
477
+ expression reads `data.id != null` which resolved to false), and each subsequent
478
+ save creates a duplicate
479
+
480
+ **Rule:** one row, one unique key, one filter condition. If you need "lookup by
481
+ appointment OR by opportunity" logic, that belongs in a quote *list* screen, not
482
+ in an individual record's edit screen.
483
+
484
+ ### Dynamic button label based on existing row
485
+
486
+ ```typescript
487
+ title: '=@ctx.datasources.salesInfoAnswers.data.id != null ? "Update Information" : "Save Information"'
488
+ ```
489
+
490
+ This works correctly when the datasource filter is precise. If the label stays on
491
+ "Save Information" after a successful save, double-check the datasource filter —
492
+ it's usually because the filter isn't finding the just-saved row.
493
+
494
+ ### JSONata comparisons are strict on type
495
+
496
+ JSONata's `=` operator does **strict equality** — `"1" = 1` evaluates to **false**
497
+ because one is a string and the other is a number. This trap hits boolean-like flags
498
+ stored as strings:
499
+
500
+ ```typescript
501
+ // Save: stored as string "1"
502
+ .data({ isCompleted: '1' })
503
+
504
+ // Read (BROKEN — always false)
505
+ '=...isCompleted = 1 ? "check" : "cross"'
506
+
507
+ // Read (CORRECT)
508
+ '=...isCompleted = "1" ? "check" : "cross"'
509
+ ```
510
+
511
+ Match the literal type in the comparison to what the save writes. If you want a
512
+ type-agnostic compare, use `$number(value) = 1` or `$string(value) = "1"` to coerce.
513
+
514
+ The `isCompleted` field in `formProgress` is stored as string `"1"` in the parent
515
+ AcumaticaBase project — we keep that convention for merge compat. When you see a
516
+ completion indicator or status icon stuck on the "not done" state, check for a
517
+ string-vs-number mismatch before assuming the save didn't run.
518
+
519
+ ## Checklist when building a new form
520
+
521
+ - [ ] Every field has `isRequired` explicitly set
522
+ - [ ] Fields wrapped in `form.addControl.form({ instanceId: ..., isDiscardChangesAlertEnabled: ... })`
523
+ - [ ] Save button uses `isDisabled`, not `when`
524
+ - [ ] Save action list order: `setScreenState(upload: true)` → saves → `resetScreenState` → `goBack`
525
+ - [ ] `goBack` is standalone and last
526
+ - [ ] Conditional saves (`when` on a step) are only used for expensive operations like signatures
527
+ - [ ] Only declared inputs are passed when navigating to the form
528
+ - [ ] Datasource filter + `initialValue` pattern for edit-existing-record support
@@ -0,0 +1,92 @@
1
+ # Pattern: Global Actions
2
+
3
+ ## Why global actions
4
+
5
+ When an action list uses `@ctx` values, those values can change between steps (e.g., a datasource updates after a previous step writes to the table). Global actions with parameters solve this — parameters are snapshot at call time and stay consistent throughout execution.
6
+
7
+ ## Defining a global action (local save)
8
+
9
+ ```typescript
10
+ const action = app.addAction({ actionId: 'act-customer-save' })
11
+ action.addParameter('data', { type: 'object', required: true })
12
+
13
+ const workflow = action.action.list({ concurrency: 'sequential' })
14
+
15
+ workflow.actions.setScreenState({
16
+ instanceId: 'disable-discard-alert',
17
+ keys: { showDiscardAlert: false },
18
+ })
19
+
20
+ workflow.actions
21
+ .executeEntity({ instanceId: 'save-entity' })
22
+ .dynamicData('default/customers', 'create')
23
+ .data('=@ctx.action.parameters.data')
24
+ .onSuccess.goBack()
25
+ ```
26
+
27
+ ## Calling from a jig
28
+
29
+ Build the data via JSONata in the jig, pass as parameter:
30
+
31
+ ```typescript
32
+ buttons.add.executeAction({
33
+ title: 'Save',
34
+ isPrimary: true,
35
+ action: 'act-customer-save',
36
+ when: IS_VALID,
37
+ parameters: {
38
+ data: buildSaveExpression(), // JSONata evaluated at call time
39
+ },
40
+ })
41
+ ```
42
+
43
+ ## TypeScript helper functions
44
+
45
+ Use helpers to reduce repetition when building cross-jig references and JSONata expressions.
46
+
47
+ **Note on JSONata variable bindings**: `$var := <expr>` *is* supported in Jigx expressions, but only when the right-hand side is a value that resolves from the `@ctx` tree — e.g., `$customers := @ctx.datasources.customers` binds the datasource's array result to a variable for later reference in the same expression. What is **not** supported is using variable bindings as a shortcut for component state paths across separate action/component configs — each config is its own JSONata expression, so a binding in one config doesn't carry into another. For DRY across configs, use TypeScript constants that get compiled into the expression strings.
48
+
49
+ ```typescript
50
+ // Full paths to cross-jig component state (TypeScript constants for DRY, not JSONata variables)
51
+ const G = '@ctx.jigs.general-instance.components'
52
+ const B = '@ctx.jigs.billing-instance.components'
53
+ const S = '@ctx.jigs.shipping-instance.components'
54
+
55
+ /** Build a JSONata {"value": <full-path>} expression for a field. */
56
+ // NOTE: The {"value": ...} wrapper is Acumatica-specific. For other systems,
57
+ // adapt the helper to match their field format (e.g., flat values, different wrappers).
58
+ function v(scope: string, componentId: string): string {
59
+ return `{"value": ${scope}.${componentId}.state.value}`
60
+ }
61
+ ```
62
+
63
+ Usage: `"CustomerName": ${v(G, 'CustomerName')}` produces `"CustomerName": {"value": @ctx.jigs.general-instance.components.CustomerName.state.value}`.
64
+
65
+ The `v()` helper builds nested `{ "value": ... }` objects. **Acumatica-specific**: Acumatica wraps all field values in `{ "value": ... }` objects. For other systems, adjust or remove the wrapper to match the expected API format.
66
+
67
+ ## JSONata save expression
68
+
69
+ Build nested JSON matching the external API schema using a single JSONata expression with variable bindings:
70
+
71
+ ```typescript
72
+ function buildSaveExpression(): `=${string}` {
73
+ return `={
74
+ "id": @ctx.jig.instanceId,
75
+ "Remote": "new",
76
+ "CustomerName": ${v(G, 'CustomerName')},
77
+ "BillingContact": {
78
+ "Email": ${v(B, 'Email')}
79
+ }
80
+ }`
81
+ }
82
+ ```
83
+
84
+ Key points:
85
+
86
+ - The `.data()` method on `executeEntity` accepts either `Record<string, Primitive>` or a single `Expression` string
87
+ - Use the expression form to build arbitrarily nested JSON
88
+ - Use TypeScript constants (`G`, `B`, `S`) for DRY across configs — these are compile-time substitutions that get baked into each expression string. JSONata `$var :=` bindings work within a single expression but don't carry between separate configs
89
+ - Return type must be `` `=${string}` `` for TypeScript
90
+ - **Acumatica-specific**: The `{ "value": ... }` wrapper matches Acumatica's API field convention. Other systems may use flat values or different structures — adapt the save expression accordingly
91
+ - Include `"Remote": "new"` to flag new records as unsynced (see [pattern-rest-acumatica.md](pattern-rest-acumatica.md#record-sync-state-remote-field) for all states)
92
+ - Include `"LastModifiedDateTime": {"value": $now()}` so new records sort to the top of time-ordered lists