@jigx/core-sdk 1.1.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.
- package/dist/action/ja.generate-pdf.d.ts +17 -1
- package/dist/action/ja.generate-pdf.d.ts.map +1 -1
- package/dist/action/ja.generate-pdf.js +4 -1
- package/dist/action/ja.in-background.d.ts +3 -2
- package/dist/action/ja.in-background.d.ts.map +1 -1
- package/dist/action/ja.in-background.js +1 -1
- package/dist/assets/example-extraction-cache.json +3 -3
- package/dist/assets/extracted-core-sdk-examples.yaml +18 -0
- package/dist/assets/extracted-core-sdk-types.yaml +58 -0
- package/dist/assets/type-extraction-cache.json +3 -3
- package/docs/array-fields.md +371 -0
- package/docs/conditional-logic.md +178 -0
- package/docs/convention-naming.md +102 -0
- package/docs/date-field.md +92 -0
- package/docs/dropdown-fields.md +879 -0
- package/docs/field-state.md +131 -0
- package/docs/field-types-overview.md +132 -0
- package/docs/formatting.md +421 -0
- package/docs/icons.md +142 -0
- package/docs/index.md +23 -0
- package/docs/jsonata-expressions.md +200 -0
- package/docs/media-fields.md +107 -0
- package/docs/overview.md +467 -0
- package/docs/pattern-build-deploy.md +91 -0
- package/docs/pattern-datasources.md +459 -0
- package/docs/pattern-forms.md +528 -0
- package/docs/pattern-global-actions.md +92 -0
- package/docs/pattern-javascript-functions.md +452 -0
- package/docs/pattern-navigation.md +304 -0
- package/docs/pattern-pdf-generation.md +391 -0
- package/docs/pattern-rest-acumatica.md +660 -0
- package/docs/pattern-sync-progress.md +96 -0
- package/docs/pattern-sync.md +653 -0
- package/docs/pattern-tabs-form.md +293 -0
- package/docs/recipe-index.md +64 -0
- package/docs/runtime-variables.md +127 -0
- package/docs/sections.md +81 -0
- package/docs/validation-patterns.md +150 -0
- package/package.json +3 -2
|
@@ -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
|