@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,391 @@
1
+ # Pattern: PDF Generation and Preview
2
+
3
+ Generate a PDF from form data, persist the file URI on the source record, preview in a native document viewer, share via the OS share sheet.
4
+
5
+ ## The trio
6
+
7
+ 1. **`app.script('html.js', ...)`** — JS helper that builds the HTML string (see [pattern-javascript-functions.md](pattern-javascript-functions.md))
8
+ 2. **`action.generate-pdf`** — renders the HTML to a PDF file, outputs a local URI
9
+ 3. **`jig.document` screen** — native PDF viewer that binds to the file URI
10
+
11
+ This pattern is **generic Jigx** — nothing Acumatica-specific.
12
+
13
+ ## Global action — generate, save, push to state
14
+
15
+ ```typescript
16
+ export function actGenerateQuotePdf(app: ApplicationBuilder): void {
17
+ const action = app.addAction({ actionId: 'act-generate-quote-pdf' })
18
+ action.addParameter('opportunityID', { type: 'string', required: true })
19
+ action.addParameter('quoteName', { type: 'string', required: false })
20
+
21
+ const workflow = action.action.list({ concurrency: 'sequential' })
22
+
23
+ // 1. Generate the PDF
24
+ workflow.actions.generatePdf({
25
+ instanceId: 'generate-pdf',
26
+ fileName: '=@ctx.action.parameters.quoteName & "-" & @ctx.action.parameters.opportunityID & ".pdf"',
27
+ html: '=$html.salesFormGenerateHTML(@ctx.datasources.salesInfoAnswers, ...)',
28
+ })
29
+
30
+ // 2. Persist URI on the quote record (merge over existing fields).
31
+ //
32
+ // Two critical details:
33
+ // a. `when` guard — only save if the row actually exists. Otherwise a missing
34
+ // datasource or empty filter would cause $merge to produce an object with
35
+ // only {pdfFilePath, pdfGeneratedAt} and no id, triggering a *duplicate row*
36
+ // on every PDF generation.
37
+ // b. Explicitly override `id` with the row's top-level `.id` column (not
38
+ // `.data.id`). Jigx may strip `id` from the JSON blob on save and keep it
39
+ // only as the row's primary key column. Reading `.data.id` can return
40
+ // undefined, leaving the merged object with no id → new UUID → duplicate row.
41
+ workflow.actions
42
+ .executeEntity({
43
+ instanceId: 'save-pdf-path',
44
+ when: '=$exists(@ctx.datasources.salesInfoAnswers.id)',
45
+ })
46
+ .dynamicData('default/salesInfoAnswers', 'save')
47
+ .data(
48
+ '=$merge([@ctx.datasources.salesInfoAnswers.data, {"id": @ctx.datasources.salesInfoAnswers.id, "pdfFilePath": @ctx.actions.generate-pdf.outputs.uri, "pdfGeneratedAt": $now()}])',
49
+ )
50
+
51
+ // 3. Push URI to screen state for immediate viewer refresh
52
+ workflow.actions.setScreenState({
53
+ instanceId: 'set-pdf-path',
54
+ keys: { pdfFilePath: '=@ctx.actions.generate-pdf.outputs.uri' },
55
+ })
56
+ }
57
+ ```
58
+
59
+ ### Why the three steps matter
60
+
61
+ - **Step 1** produces the URI but nothing else is aware of it yet.
62
+ - **Step 2** writes it to the database. This triggers a datasource cache invalidation, so lists showing quotes will eventually refresh. But the current screen's datasources may not have re-queried yet by the time step 3 runs.
63
+ - **Step 3** puts the URI directly into screen state, which the viewer binds to first (before falling back to the datasource value). The viewer updates on the next render tick, no race.
64
+
65
+ ### `$merge` over field-by-field enumeration
66
+
67
+ For upsert-style saves where most fields should be preserved and only a few updated, `$merge([existing, patch])` is much cleaner than listing every field:
68
+
69
+ ```typescript
70
+ // Verbose (don't do this)
71
+ .data({
72
+ id: '=@ctx.datasources.X.data.id',
73
+ field1: '=@ctx.datasources.X.data.field1',
74
+ field2: '=@ctx.datasources.X.data.field2',
75
+ // ... 20 more fields
76
+ pdfFilePath: '=@ctx.actions.generate-pdf.outputs.uri',
77
+ })
78
+
79
+ // Clean
80
+ .data('=$merge([@ctx.datasources.X.data, {"id": @ctx.datasources.X.id, "pdfFilePath": @ctx.actions.generate-pdf.outputs.uri}])')
81
+ ```
82
+
83
+ **Always include `.id` explicitly in the merge patch.** Jigx's `save` method may strip
84
+ `id` from the JSON blob on write, keeping it only as the row primary key column.
85
+ Reading back via `.data.id` can return undefined, which breaks the upsert key and
86
+ creates a duplicate row. Override explicitly with the row's top-level `.id`.
87
+
88
+ ## Passing datasource arrays to JavaScript functions
89
+
90
+ Single-row array datasources (non-`isDocument`) have a trap. JSONata's "singleton
91
+ sequence" behavior unwraps a 1-element sequence to a scalar when passed to a function:
92
+
93
+ ```text
94
+ @ctx.datasources.doors = [{id, data: {...}}] // 1-row array
95
+ @ctx.datasources.doors.data = {...} ← scalar, not [{...}]!
96
+ ```
97
+
98
+ The JS function expecting an array will see a single object and `Array.isArray()`
99
+ returns false. Fix by forcing array shape with `[...]` wrapping:
100
+
101
+ ```text
102
+ [@ctx.datasources.doors.data] = [{...}] // always an array
103
+ ```
104
+
105
+ For datasources where you need the row's top-level `id` preserved inside each item
106
+ (e.g., to match foreign keys like `answer.itemID === item.id`), use `$map` with
107
+ `$merge` to lift the id into the data object:
108
+
109
+ ```text
110
+ $map(@ctx.datasources.salesChecklist, function($r) { $merge([{"id": $r.id}, $r.data]) })
111
+ ```
112
+
113
+ Each resulting item is `{id, title, description, ...}` — id promoted from the row
114
+ column into the data shape the JS helper expects.
115
+
116
+ `isDocument: true` datasources don't have this trap — they always return a single
117
+ object, so `@ctx.datasources.X.data` is reliably the data object.
118
+
119
+ ## Redesigning an existing PDF safely
120
+
121
+ When replacing a production PDF layout, do not overwrite the existing HTML helper
122
+ until the new layout has been reviewed in the app. Add a second script file and
123
+ register it with its own namespace:
124
+
125
+ ```typescript
126
+ app.script('quotehtml.js', readScriptFile('quotehtml.js')) // existing fallback
127
+ app.script('quotepresentation.js', readScriptFile('quotepresentation.js')) // new layout
128
+ ```
129
+
130
+ Then change only the `action.generate-pdf.html` expression to call the new
131
+ namespace, keeping the argument order and datasource wrapping identical:
132
+
133
+ ```text
134
+ =$quotepresentation.salesPresentationGenerateHTML(
135
+ @ctx.datasources.salesInfoAnswers.data,
136
+ [@ctx.datasources.doorDetails.data],
137
+ @ctx.datasources.salesSelectionAnswers.data,
138
+ $map(@ctx.datasources.salesChecklist, function($r) { $merge([{"id": $r.id}, $r.data]) }),
139
+ [@ctx.datasources.salesChecklistAnswers.data],
140
+ [@ctx.datasources.openerDetails.data],
141
+ @ctx.datasources.salesOpenersAnswers.data,
142
+ [@ctx.datasources.salesFormSignatures.data]
143
+ )
144
+ ```
145
+
146
+ This keeps rollback cheap and avoids reintroducing array/singleton shape bugs
147
+ while changing only the presentation layer.
148
+
149
+ ### Use line records as the pricing source of truth
150
+
151
+ For quote PDFs with doors, openers, products, services, or other repeatable line
152
+ items, keep `price`, `tax`, and `totalWithTax` on each line record. The quote
153
+ screen and PDF should aggregate those child records for running totals instead of
154
+ duplicating an editable quote-level total on the disclosure or signature screen.
155
+
156
+ This prevents stale totals when a line is copied, deleted, repriced, or edited
157
+ after quote acceptance is captured. The final PDF page can still show a quote
158
+ summary, but it should be derived from the saved line rows:
159
+
160
+ ```text
161
+ door total = SUM(COALESCE(totalWithTax, price + tax, 0))
162
+ opener total = SUM(COALESCE(totalWithTax, price + tax, 0))
163
+ quote total = door total + opener total
164
+ ```
165
+
166
+ If a line-level code is internal only, do not render it in customer-facing PDF
167
+ HTML. Save it locally and pass it to integration/sync actions as needed.
168
+
169
+ ### Paginate repeatable quote lines deliberately
170
+
171
+ For presentation-style PDFs, do not assume all repeatable line items fit on one
172
+ slide/page. Chunk child records into a conservative fixed count and render more
173
+ slides as needed:
174
+
175
+ - Door configuration cards: 2 per slide.
176
+ - Opener configuration cards: 2 per slide.
177
+ - Included services / scope: separate slide from repeatable products.
178
+
179
+ This keeps the layout stable when a quote has 3, 5, or more line items and avoids
180
+ one overflowing mixed slide that combines repeatable products with scope text.
181
+
182
+ ## Signature images in generated PDFs
183
+
184
+ Do not rely on persisted local signature paths for PDF HTML unless the PDF is being
185
+ generated on the same device that captured the signature. Paths like
186
+ `file:///data/...` or `/var/mobile/...` are sandbox-local and can point to nothing
187
+ after the quote syncs to a different device. Missing images can also make the native
188
+ PDF renderer wait until timeout.
189
+
190
+ For signatures that must appear in generated PDFs:
191
+
192
+ - Keep `signatureImage` as the local URI for `signatureField.initialValue`.
193
+ - Save a separate `signatureImagePortable` field with `conversions:
194
+ [{ property: 'signatureImagePortable', from: 'local-uri', to: 'data-uri' }]`.
195
+ - Re-run the save/conversion when `signatureImagePortable` is missing, even if
196
+ `signatureImage` did not change. Older rows or rows captured on another device
197
+ can have only the non-portable local URI.
198
+ - In the PDF HTML helper, use `signatureImagePortable` first, then only fall back to
199
+ current-device local paths if needed.
200
+ - If no portable image is available, render a text fallback such as "Signature on
201
+ file" instead of emitting a stale local `<img>` path or leaving an unexplained
202
+ blank area.
203
+
204
+ ## Product and logo images in generated PDFs
205
+
206
+ Do not embed large static product images as base64/data-URI constants in a solution.
207
+ It inflates the solution payload, slows script parsing, makes YAML diffs unreadable,
208
+ and can force heavy memory usage during PDF generation. Prefer normal hosted image
209
+ URLs for static manufacturer/product examples when the app is online and the image
210
+ host is reliable.
211
+
212
+ Use portable `data:` images only for small or user-generated values that cannot be
213
+ fetched by URL, such as `signatureImagePortable`. If a remote product image fails
214
+ to load, the PDF should still be recoverable by regenerating after connectivity is
215
+ restored. Do not use stale device-local `file://` paths for images captured on a
216
+ different device.
217
+
218
+ ## Saving generated HTML for renderer debugging
219
+
220
+ When native PDF output does not match the browser-rendered HTML, save the exact
221
+ HTML string to a dedicated dynamic debug table immediately before
222
+ `action.generate-pdf`. This separates template bugs from native renderer bugs.
223
+
224
+ Use a first-class table name like `default/htmldebug`, not a generic scratch table,
225
+ so it is easy to inspect in the device database or management tools:
226
+
227
+ ```typescript
228
+ app.addDatabase.default.table('htmldebug')
229
+
230
+ const htmlExpr = '$quote.salesPresentationGenerateHTML(...)'
231
+
232
+ workflow.actions
233
+ .executeEntity({ instanceId: 'debug-html' })
234
+ .dynamicData('default/htmldebug', 'save')
235
+ .data(
236
+ `={"id": @ctx.action.parameters.opportunityID, "opportunityID": @ctx.action.parameters.opportunityID, "html": ${htmlExpr}, "generatedAt": $now(), "templateVersion": "presentation-v1"}`,
237
+ )
238
+
239
+ workflow.actions.generatePdf({
240
+ instanceId: 'generate-pdf',
241
+ fileName: '=@ctx.action.parameters.opportunityID & "-" & $millis() & ".pdf"',
242
+ html: `=${htmlExpr}`,
243
+ })
244
+ ```
245
+
246
+ Keep the debug row keyed by the parent record id (`opportunityID`, service order
247
+ id, etc.) so regeneration replaces the previous debug HTML for that record.
248
+
249
+ ## Document screen — native PDF viewer
250
+
251
+ ```typescript
252
+ const screen = app.addScreen.document({
253
+ screenId: 'jig-quote-pdf-preview',
254
+ title: 'Quote PDF',
255
+ })
256
+
257
+ screen.addInput({ name: 'opportunityID', type: 'string' })
258
+ screen.with({ state: { pdfFilePath: { initialValue: null } } })
259
+
260
+ const hasPdfStateExpr =
261
+ '@ctx.jig.state.pdfFilePath != null and @ctx.jig.state.pdfFilePath != ""'
262
+
263
+ // All datasources the PDF generator needs — scoped to this quote
264
+ screen.addDatasource.sqlite({ datasourceId: 'salesInfoAnswers', provider: 'dynamic' })
265
+ .entity('default/salesInfoAnswers')
266
+ .query(`SELECT id, data FROM [default/salesInfoAnswers] WHERE json_extract(data, '$.opportunityID') = @opportunityID`)
267
+ .queryParameter('opportunityID', '=@ctx.jig.inputs.opportunityID')
268
+ .jsonProperties('data')
269
+ .isDocument(true)
270
+ // ... more datasources
271
+
272
+ // Generate on first visit in this screen/device session if no local state URI exists yet.
273
+ // Persisted file:// URIs are device-local and must not be trusted across phones,
274
+ // app reinstalls, or sandbox changes.
275
+ screen.onFocus.executeAction({
276
+ action: 'act-generate-quote-pdf',
277
+ when: `=(${hasPdfStateExpr}) = false`,
278
+ parameters: {
279
+ opportunityID: '=@ctx.jig.inputs.opportunityID',
280
+ },
281
+ })
282
+
283
+ // Bind the PDF source only to the current screen/device URI. Do not fall back to
284
+ // persisted pdfFilePath here; it may point to another device's sandbox.
285
+ screen.source.pdf({
286
+ uri: '=@ctx.jig.state.pdfFilePath',
287
+ })
288
+
289
+ // Header actions — regenerate and share
290
+ const header = screen.header()
291
+
292
+ header.actions.executeAction({
293
+ title: 'Regenerate',
294
+ icon: 'synchronize-arrows-1',
295
+ action: 'act-generate-quote-pdf',
296
+ parameters: { opportunityID: '=@ctx.jig.inputs.opportunityID' },
297
+ })
298
+
299
+ header.actions.share({
300
+ title: 'Share',
301
+ icon: 'share',
302
+ fileUri: '=@ctx.jig.state.pdfFilePath',
303
+ when: '=$exists(@ctx.jig.state.pdfFilePath)',
304
+ })
305
+ ```
306
+
307
+ ### Placeholder PDFs for incomplete flows
308
+
309
+ `jig.document` has one source type for the screen. If the screen is a PDF viewer,
310
+ do not navigate away just because the real PDF cannot be generated yet. Generate a
311
+ small temporary placeholder PDF on focus, set its URI into screen state, and keep
312
+ the real persisted `pdfFilePath` untouched.
313
+
314
+ Use this when a tab should remain open but the quote/report is not ready:
315
+
316
+ ```typescript
317
+ focusActions.infoModal({
318
+ instanceId: 'incomplete-alert',
319
+ when: '=@ctx.expressions.allSectionsComplete = false',
320
+ modal: { title: 'Quote Not Complete', buttonText: 'OK' },
321
+ })
322
+
323
+ focusActions.generatePdf({
324
+ instanceId: 'generate-placeholder-pdf',
325
+ when: '=@ctx.expressions.allSectionsComplete = false',
326
+ fileName: '="Quote-Not-Generated-" & @ctx.jig.inputs.opportunityID & ".pdf"',
327
+ html: '<html><body><h1>Not generated yet</h1></body></html>',
328
+ })
329
+
330
+ focusActions.setScreenState({
331
+ instanceId: 'set-placeholder-pdf',
332
+ when: '=@ctx.expressions.allSectionsComplete = false',
333
+ keys: { pdfFilePath: '=@ctx.actions.generate-placeholder-pdf.outputs.uri' },
334
+ })
335
+ ```
336
+
337
+ Hide `Share` and `Clear` while showing a placeholder. Those actions should apply
338
+ only to a real generated PDF, not the temporary screen-state PDF.
339
+
340
+ ### Key behaviors to remember
341
+
342
+ - **`DocumentScreenBuilder extends ScreenBaseBuilderNew`** — so it has `addInput`, `addDatasource`, `with({state})`, `onFocus`, `header`, etc. Same building blocks as a default screen.
343
+ - **`source.pdf({uri})` accepts an expression** — reactive binding. When the expression changes (state, datasource), the viewer updates.
344
+ - **`onFocus` fires on navigation *to* the screen, not on `goBack`**. Safe to use for "generate if missing" without re-triggering every time the user leaves and comes back via back navigation.
345
+ - **`share` action requires a local `file://` URI** — base64 and data URIs don't work.
346
+ - **Generated PDF `file://` URIs are device-local.** A URI persisted on a dynamic-data row can point to another device's app sandbox, or to a previous install's sandbox. The preview screen should generate a fresh local PDF into screen state when `@ctx.jig.state.pdfFilePath` is missing, bind `source.pdf` to state only, and share state only. Persist `pdfFilePath` for audit/status/sync metadata, not as a guaranteed reusable viewer source.
347
+ - **Do not use `$exists` alone on initialized state.** If `pdfFilePath` is declared with `initialValue: null`, `$exists(@ctx.jig.state.pdfFilePath)` can be true even though there is no usable URI. Use an explicit non-null/non-empty check like `@ctx.jig.state.pdfFilePath != null and @ctx.jig.state.pdfFilePath != ""` for on-focus generation and share enablement.
348
+ - **Images inside generated HTML must not use stale device-local paths.** Device-local image/signature paths from another device can leave the PDF renderer waiting for images until `RENDER_TIMEOUT`. For signatures, store a portable `data:` representation or omit the image and show the signature/name/date fields instead.
349
+ - **External HTTP images can still fail if connectivity or the image host is bad.** Keep product imagery hosted at stable URLs and avoid very large images, but do not base64-embed large static brand/product photos into the solution.
350
+ - **Guard post-generation saves.** If `action.generate-pdf` fails, later sequential actions may still run. Save `pdfFilePath` and update screen state only when `$exists(@ctx.actions.generate-pdf.outputs.uri)` is true.
351
+ - **Give regenerate actions explicit feedback.** A `generate-pdf` failure can leave
352
+ `outputs.uri` empty while later guarded steps are skipped. For user-triggered
353
+ regeneration, add success and failure `infoModal` actions gated by
354
+ `$exists(@ctx.actions.<instanceId>.outputs.uri)` so the user knows whether a new
355
+ file was created.
356
+ - **Do not rely on print-only CSS for production appearance.** The current iOS
357
+ `action.generate-pdf` path renders HTML through the native Jigx PDF generator
358
+ using a WebView PDF export, not a full browser print flow. `@page`,
359
+ `page-break-after`, and `@media print` are useful hints, but normal screen CSS
360
+ must already look acceptable because iOS can export the scrollable WebView as a
361
+ single tall page. Avoid dark-only presentation slides, oversized decorative
362
+ absolute shapes, and backgrounds that only get corrected inside `@media print`.
363
+ - **Prefer flat, explicit PDF styling.** Native and desktop PDF viewers can render
364
+ transparency, gradients, and CSS shadows differently. For customer-facing PDFs,
365
+ prefer `background: #ffffff`, solid borders, and simple accent colors over
366
+ `rgba()`, `linear-gradient()`, and `box-shadow`. This keeps Android preview,
367
+ shared PDFs, macOS Preview, and browser print output closer to each other.
368
+
369
+ ## Global action caller context
370
+
371
+ A key thing to understand: when `executeAction` calls a global action, expressions inside the global action's steps resolve in the **caller's screen context**. That means `@ctx.datasources.X` refers to the caller's screen-level datasources, not anything defined on the action itself (global actions don't have datasources).
372
+
373
+ So the PDF generation action above works because the PDF preview screen defines all the required datasources in its own scope. If you called the same action from a screen without those datasources loaded, the expressions would resolve to null.
374
+
375
+ ## `file://` URIs flow end-to-end
376
+
377
+ For this pattern we deliberately store signature and media images as **local URIs** (not base64), then let the generator embed them directly as `<img src="file://...">`. The Jigx `generate-pdf` action renders these correctly. This avoids the cost of base64 encoding on every save and keeps the rows small. See "Signature and media storage" in [pattern-datasources.md](pattern-datasources.md) for the trade-off.
378
+
379
+ ## Filename — namespace by script file name
380
+
381
+ Calling `app.script('html.js', ...)` registers the functions under the `$html` JSONata namespace (filename minus `.js`). Call as `$html.salesFormGenerateHTML(...)`. If you name the file `pdf.js`, call as `$pdf.create(...)`.
382
+
383
+ ## Real-world reference
384
+
385
+ `workspace/acumatica-apps/doorpro-door-quote/` — the full trio:
386
+
387
+ - `src/scripts/html.js` — HTML helper
388
+ - `src/actions/act-generate-quote-pdf.ts` — global action
389
+ - `src/screens/jig-quote-pdf-preview.ts` — document screen
390
+ - `src/app.ts` — `readScriptFile` + `app.script('html.js', ...)`
391
+ - `src/screens/jig-quote-list.ts:93` — swipe-left that navigates to the PDF screen