@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.
Files changed (39) hide show
  1. package/dist/action/ja.generate-pdf.d.ts +17 -1
  2. package/dist/action/ja.generate-pdf.d.ts.map +1 -1
  3. package/dist/action/ja.generate-pdf.js +4 -1
  4. package/dist/action/ja.in-background.d.ts +3 -2
  5. package/dist/action/ja.in-background.d.ts.map +1 -1
  6. package/dist/action/ja.in-background.js +1 -1
  7. package/dist/assets/example-extraction-cache.json +3 -3
  8. package/dist/assets/extracted-core-sdk-examples.yaml +18 -0
  9. package/dist/assets/extracted-core-sdk-types.yaml +58 -0
  10. package/dist/assets/type-extraction-cache.json +3 -3
  11. package/docs/array-fields.md +371 -0
  12. package/docs/conditional-logic.md +178 -0
  13. package/docs/convention-naming.md +102 -0
  14. package/docs/date-field.md +92 -0
  15. package/docs/dropdown-fields.md +879 -0
  16. package/docs/field-state.md +131 -0
  17. package/docs/field-types-overview.md +132 -0
  18. package/docs/formatting.md +421 -0
  19. package/docs/icons.md +142 -0
  20. package/docs/index.md +23 -0
  21. package/docs/jsonata-expressions.md +200 -0
  22. package/docs/media-fields.md +107 -0
  23. package/docs/overview.md +467 -0
  24. package/docs/pattern-build-deploy.md +91 -0
  25. package/docs/pattern-datasources.md +459 -0
  26. package/docs/pattern-forms.md +528 -0
  27. package/docs/pattern-global-actions.md +92 -0
  28. package/docs/pattern-javascript-functions.md +452 -0
  29. package/docs/pattern-navigation.md +304 -0
  30. package/docs/pattern-pdf-generation.md +391 -0
  31. package/docs/pattern-rest-acumatica.md +660 -0
  32. package/docs/pattern-sync-progress.md +96 -0
  33. package/docs/pattern-sync.md +653 -0
  34. package/docs/pattern-tabs-form.md +293 -0
  35. package/docs/recipe-index.md +64 -0
  36. package/docs/runtime-variables.md +127 -0
  37. package/docs/sections.md +81 -0
  38. package/docs/validation-patterns.md +150 -0
  39. package/package.json +3 -2
@@ -0,0 +1,459 @@
1
+ # Pattern: Datasources
2
+
3
+ This is the standard approach unless the user instructs otherwise.
4
+
5
+ ## Rules
6
+
7
+ 1. **Always select `id` and `data` only** — never use `json_extract` in SELECT columns
8
+ 2. **Add `jsonProperties: ['data']`** — Jigx parses the JSON column into an object automatically
9
+ 3. **Use `json_extract` only in WHERE, JOIN, ORDER BY** — when you need to filter or sort by a specific field
10
+ 4. **Access fields via the parsed `data` object** — `@ctx.datasources.name.data.FieldName.value`
11
+
12
+ ## Examples
13
+
14
+ ```typescript
15
+ // App-scoped datasource
16
+ app.addDatasource
17
+ .sqlite({ datasourceId: 'data-select-customers', provider: 'dynamic' })
18
+ .entity('default/customers')
19
+ .query('SELECT id, data FROM [default/customers]')
20
+ .jsonProperties('data')
21
+
22
+ // Screen-scoped datasource with WHERE filter (json_extract only in WHERE)
23
+ screen.addDatasource
24
+ .sqlite({ datasourceId: 'data-select-contacts', provider: 'dynamic' })
25
+ .entity('default/contacts')
26
+ .query(`SELECT id, data FROM [default/contacts] WHERE json_extract(data, '$.customerId') = @customerId`)
27
+ .queryParameter('customerId', '=@ctx.jig.inputs.customerId')
28
+ .jsonProperties('data')
29
+
30
+ // Single document with isDocument
31
+ screen.addDatasource
32
+ .sqlite({ datasourceId: 'data-select-customer', provider: 'dynamic' })
33
+ .entity('default/customers')
34
+ .query('SELECT id, data FROM [default/customers] WHERE id = @customerId')
35
+ .queryParameter('customerId', '=@ctx.jig.inputs.customerId')
36
+ .jsonProperties('data')
37
+ .isDocument(true)
38
+ ```
39
+
40
+ ## Global vs screen-scoped datasources
41
+
42
+ Datasources that reference `@ctx.jig.state`, `@ctx.jig.inputs`, or other jig-level context **must be screen-scoped** (defined inline on the screen). Global datasources (defined in `datasources.ts` at the app level) do not have access to jig state.
43
+
44
+ ```typescript
45
+ // WRONG — global datasource cannot access @ctx.jig.state.searchText
46
+ app.addDatasource.sqlite({ datasourceId: 'data-select-customers', provider: 'local' })
47
+ .query('... WHERE ... @search ...')
48
+ .queryParameter('search', '=@ctx.jig.state.searchText') // Error: no jig context
49
+
50
+ // CORRECT — screen-scoped datasource has jig context
51
+ screen.addDatasource.sqlite({ datasourceId: 'data-select-customers', provider: 'local' })
52
+ .query('... WHERE ... @search ...')
53
+ .queryParameter('search', '=@ctx.jig.state.searchText') // Works: jig context available
54
+ ```
55
+
56
+ **Rule**: If a datasource references `@ctx.jig.*`, define it on the screen. If it only references `@ctx.datasources.*` or static values, it can be global.
57
+
58
+ ## Accessing fields in expressions
59
+
60
+ With `jsonProperties: ['data']`, the `data` column is auto-parsed. Field access follows the table schema:
61
+
62
+ ```typescript
63
+ // Acumatica schema (nested with .value wrappers)
64
+ '=@ctx.datasources.data-select-customer.data.CustomerName.value'
65
+ '=@ctx.datasources.data-select-customer.data.MainContact.Email.value'
66
+
67
+ // Flat schema (no .value wrappers)
68
+ '=@ctx.current.item.data.firstName'
69
+
70
+ // In list items (current item context)
71
+ '=@ctx.current.item.data.CustomerName.value'
72
+ ```
73
+
74
+ ### Field filters with `jsonProperties` — `data.` prefix required
75
+
76
+ When you filter a datasource by field using JSONata `[field = value]`, you MUST use
77
+ the `data.` prefix, because `jsonProperties('data')` leaves field values under a
78
+ `data` sub-object:
79
+
80
+ ```typescript
81
+ // WRONG — filter never matches, silently returns empty
82
+ '=$exists(@ctx.datasources.checklistAnswers[itemID = @ctx.current.item.id])'
83
+
84
+ // RIGHT — data.itemID, not itemID
85
+ '=$exists(@ctx.datasources.checklistAnswers[data.itemID = @ctx.current.item.id])'
86
+ ```
87
+
88
+ The only time you drop the `data.` prefix is when the SELECT columns are explicit
89
+ (not `SELECT id, data`), like:
90
+
91
+ ```sql
92
+ SELECT id, json_extract(data, '$.itemSection') as itemSection FROM [...]
93
+ ```
94
+
95
+ In that case `itemSection` is a top-level column (no `data.` wrapper), so filters
96
+ like `[itemSection = "foo"]` work without the prefix.
97
+
98
+ **Rule of thumb**: match your JSONata filter to the SELECT shape. If the SELECT has
99
+ bare `data`, you need `data.field` filters. If the SELECT aliases fields as top-level
100
+ columns, you don't.
101
+
102
+ ## isDocument for single-row datasources
103
+
104
+ When a datasource always returns one row (config table, detail lookup by ID), set `isDocument(true)`. This returns an object instead of an array, so you access fields directly without `[0]`:
105
+
106
+ ```typescript
107
+ // With isDocument: true
108
+ '=@ctx.datasources.data-select-config.data.acumaticaURL'
109
+
110
+ // Without isDocument (array) — would require [0]
111
+ '=@ctx.datasources.data-select-config[0].data.acumaticaURL'
112
+ ```
113
+
114
+ ## Seeded startup rows and solution state
115
+
116
+ When a standalone app seeds local data that the landing screen immediately displays
117
+ or uses to set `@ctx.solution.state`, do not rely on a datasource reading the seeded
118
+ row during the same first render. The screen may focus before the app `onLoad` save
119
+ has refreshed the datasource, which can leave the first screen blank until the user
120
+ navigates away and back.
121
+
122
+ Use this pattern:
123
+
124
+ - Keep the seed row in one shared constant/helper so app `onLoad`, `onRefresh`, and
125
+ landing-screen `onFocus` write the exact same values.
126
+ - On app `onLoad`, save the seed row and then set required solution-state keys from
127
+ the seed values directly, not from a datasource that may not have refreshed yet.
128
+ - On the landing screen `onFocus`, upsert the same seed row first, then set solution
129
+ state from the seed values directly.
130
+ - If downstream screens need seeded identifiers, persist them into the quote/order
131
+ row when creating it instead of reintroducing hardcoded constants in submit actions.
132
+
133
+ This is especially important for mock/standalone appointment screens that will later
134
+ be replaced by a parent app's real appointment context.
135
+
136
+ ## Cross-provider JOINs: registering dynamic tables for local queries
137
+
138
+ When a datasource with `provider: local` JOINs a `default/` dynamic data table, the dynamic table must be **registered** separately — otherwise Jigx won't create it on the device and the query fails with `no such table`.
139
+
140
+ A datasource's `entities` list can only contain entities of the **same provider type**. You cannot mix local and dynamic entities in one datasource.
141
+
142
+ **Fix**: Add a separate global datasource (or sync entity call) with `provider: dynamic` that lists the dynamic table as its entity. This subscribes the table so Jigx creates it on the device. The datasource doesn't need to be actively used — just declared.
143
+
144
+ ```typescript
145
+ // Register a dynamic table so it exists on device for JOINs from local datasources
146
+ app.addDatasource
147
+ .sqlite({ datasourceId: 'data-register-panel-mapping', provider: 'dynamic' })
148
+ .entity('default/panelDesignMapping')
149
+ .query('SELECT id, data FROM [default/panelDesignMapping]')
150
+ .jsonProperties('data')
151
+
152
+ // Local datasource that JOINs the dynamic table — now works because the table is registered
153
+ screen.addDatasource
154
+ .sqlite({ datasourceId: 'panelDesigns', provider: 'local' })
155
+ .entity('AttributeDetails')
156
+ .query(`
157
+ SELECT ad.id, ad.description, pm.model, pm.windows
158
+ FROM [AttributeDetails] ad
159
+ JOIN [default/panelDesignMapping] pm ON ad.valueID = pm.panelDesignID
160
+ WHERE ad.attributeID = 'PANELDES' AND pm.model = @model
161
+ `)
162
+ ```
163
+
164
+ **Rule**: If a dynamic data table (`default/`) is referenced in a query but not listed under `entities` on any dynamic-provider datasource, the Jigx runtime won't subscribe to it and the table won't exist on the device. Always ensure every dynamic table used in queries is registered via at least one dynamic-provider datasource or sync entity call.
165
+
166
+ ## Local vs dynamic tables
167
+
168
+ | Provider | Entity format | Database file | Use case |
169
+ | --- | --- | --- | --- |
170
+ | `local` | `Customers` (no `default/` prefix) | NOT registered | User-created data, offline-first, synced to remote via REST |
171
+ | `dynamic` | `default/config` (with prefix) | Registered in `database.ts` | Cloud-synced data managed via Jigx management portal |
172
+
173
+ **Only dynamic tables go in `database.ts`.** Local tables are created automatically when referenced in datasources or operations — do not add them to the database file.
174
+
175
+ ```typescript
176
+ // database.ts — only dynamic tables
177
+ app.addDatabase.default.table('config')
178
+ // Do NOT add Customers, Contacts, or error tables here — they are local
179
+
180
+ // Local table datasource — entity name only, capitalize
181
+ app.addDatasource
182
+ .sqlite({ datasourceId: 'data-select-customers', provider: 'local' })
183
+ .entity('Customers')
184
+ .query('SELECT id, data FROM [Customers]')
185
+
186
+ // Dynamic table datasource — default/ prefix
187
+ app.addDatasource
188
+ .sqlite({ datasourceId: 'data-select-config', provider: 'dynamic' })
189
+ .entity('default/config')
190
+ .query('SELECT id, data FROM [default/config]')
191
+ ```
192
+
193
+ ## List search support
194
+
195
+ When a list screen has `isSearchable: true`, the user's search text is available via `@ctx.jig.state.searchText`. Pass it as a query parameter and use `LIKE` in the WHERE clause:
196
+
197
+ ```typescript
198
+ // Must be screen-scoped (uses @ctx.jig.state.searchText)
199
+ screen.addDatasource
200
+ .sqlite({ datasourceId: 'data-select-customers', provider: 'local' })
201
+ .entity('Customers')
202
+ .query(`
203
+ SELECT id, data FROM [Customers]
204
+ WHERE (
205
+ json_extract(data, '$.CustomerName.value') LIKE '%' || @search || '%'
206
+ OR json_extract(data, '$.CustomerID.value') LIKE '%' || @search || '%'
207
+ OR @search IS NULL
208
+ )
209
+ ORDER BY json_extract(data, '$.LastModifiedDateTime.value') DESC
210
+ `)
211
+ .queryParameter('search', '=@ctx.jig.state.searchText')
212
+ .jsonProperties('data')
213
+ ```
214
+
215
+ Key points:
216
+ - **Must be screen-scoped** — uses `@ctx.jig.state.searchText` which requires jig context (not available on global datasources)
217
+ - `@search IS NULL` ensures all records show when the search box is empty
218
+ - Search common fields like name and ID — check with the solution builder if other fields should be searchable
219
+ - `json_extract` is used in the WHERE clause (not SELECT) — consistent with our datasource pattern
220
+ - The `searchText` state is managed automatically by the list screen when `isSearchable: true`
221
+
222
+ ## Ordering by last modified date
223
+
224
+ For list screens showing entities, order by a last-modified timestamp descending so the most recently modified/created records appear first:
225
+
226
+ ```sql
227
+ SELECT id, data FROM [Customers]
228
+ ORDER BY json_extract(data, '$.LastModifiedDateTime.value') DESC
229
+ ```
230
+
231
+ When creating new records locally, set the timestamp to `$now()` so they sort to the top:
232
+
233
+ ```jsonata
234
+ "LastModifiedDateTime": {"value": $now()},
235
+ "Remote": "new",
236
+ ```
237
+
238
+ This timestamp is replaced by the remote system's actual timestamp when the record syncs.
239
+
240
+ **Acumatica-specific**: The field is called `LastModifiedDateTime` with a `{ "value": ... }` wrapper. Other systems may use a different field name (e.g., `updatedAt`, `modified_date`) and format — adapt the field path in the ORDER BY and save expression accordingly.
241
+
242
+ ## SQLite expression indexes for JSON fields
243
+
244
+ Jigx stores local and Dynamic Data rows as JSON in the `data` column. Any query that repeatedly filters, joins, or sorts with `json_extract(data, '$.field')` can become slow once the table grows. For large local lookup tables, matrix tables, sync queues, or frequently joined support tables, create SQLite expression indexes after the data sync completes.
245
+
246
+ Use an idempotent `action.execute-sql` action at the end of the initial sync action list:
247
+
248
+ ```typescript
249
+ const createIndexes = app.addAction.list({
250
+ actionId: 'act-create-local-indexes',
251
+ isSequential: true,
252
+ })
253
+
254
+ createIndexes.addAction.executeSql({
255
+ entities: ['default/items', 'default/itemDetails'],
256
+ statements: [
257
+ {
258
+ statement: `
259
+ CREATE INDEX IF NOT EXISTS idx_items_customer_status
260
+ ON [default/items] (
261
+ trim(json_extract(data, '$.customerId')),
262
+ trim(json_extract(data, '$.status'))
263
+ )
264
+ `,
265
+ },
266
+ {
267
+ statement: `
268
+ CREATE INDEX IF NOT EXISTS idx_itemdetails_parent_sort
269
+ ON [default/itemDetails] (
270
+ json_extract(data, '$.parentId'),
271
+ json_extract(data, '$.sortOrder')
272
+ )
273
+ `,
274
+ },
275
+ ],
276
+ })
277
+ ```
278
+
279
+ Index only fields that are used in expensive `WHERE`, `JOIN`, or `ORDER BY` clauses. Match the index expression exactly to the query expression, including `trim(...)`, `cast(...)`, casing, and JSON path. Composite indexes should follow the query prefix order: if dropdowns filter by `chainId`, then `v1`, then `v2`, build indexes like `(chainId)`, `(chainId, v1)`, `(chainId, v1, v2)` for the prefixes the app actually uses.
280
+
281
+ Run the index action after the tables have been synced or seeded. `CREATE INDEX IF NOT EXISTS` makes the action safe to rerun on app load, app restart, or force sync. For diagnostic screens, query `sqlite_master` to confirm the index exists on the device:
282
+
283
+ ```sql
284
+ SELECT name, tbl_name, sql
285
+ FROM sqlite_master
286
+ WHERE type = 'index'
287
+ AND name NOT LIKE 'sqlite_autoindex%'
288
+ ORDER BY lower(tbl_name), lower(name)
289
+ ```
290
+
291
+ Do not create indexes blindly for every JSON field. Indexes improve reads but add write cost and storage. Start with the fields used by high-cardinality lookups, large matrix filtering, parent-child joins, and list sorting.
292
+
293
+ ## Config table
294
+
295
+ A `config` table stores integration settings (API base URLs, feature flags, etc.). Always create it in `database.ts` and add an app-scoped datasource with `isDocument: true`.
296
+
297
+ ```typescript
298
+ // database.ts — only config (dynamic)
299
+ app.addDatabase.default.table('config')
300
+
301
+ // datasources.ts
302
+ app.addDatasource
303
+ .sqlite({ datasourceId: 'data-select-config', provider: 'dynamic' })
304
+ .entity('default/config')
305
+ .query('SELECT id, data FROM [default/config]')
306
+ .jsonProperties('data')
307
+ .isDocument(true)
308
+ ```
309
+
310
+ ## Table schemas
311
+
312
+ Always create a `schemas/` folder in the project — even in YAML projects (Jigx doesn't have this in its object model, but it helps track what the data looks like).
313
+
314
+ Each schema file documents the fields stored by the save action. Infer from:
315
+ - The JSONata save expression (for complex nested saves)
316
+ - The `.data()` record on executeEntity (for simple flat saves)
317
+ - The external API schema if syncing from a remote source
318
+
319
+ If the user doesn't provide schema guidance, infer it from the form controls and call it out.
320
+
321
+ ```yaml
322
+ # schemas/customers.yaml
323
+ id: string
324
+
325
+ data:
326
+ Remote: boolean
327
+ CustomerName:
328
+ value: string
329
+ MainContact:
330
+ Email:
331
+ value: string
332
+ Phone1:
333
+ value: string
334
+ ```
335
+
336
+ ## Signature and media storage — URI vs base64
337
+
338
+ `signatureField` and `media` components emit a **local file URI** (e.g. `file:///...`). When saving to a dynamic table via `executeEntity`, you have two options:
339
+
340
+ ### Option 1 — store the URI (simple, preferred for device-local flows)
341
+
342
+ ```typescript
343
+ saveActions
344
+ .executeEntity({ instanceId: 'save-signature' })
345
+ .dynamicData(ENTITY.FORM_SIGNATURES.FQN, 'save')
346
+ .data({
347
+ id: '=$uuid()',
348
+ signatureImage: '=@ctx.components.mySignature.state.value', // local URI
349
+ })
350
+ ```
351
+
352
+ Use this when:
353
+
354
+ - The signature/image stays on the device (PDF generation reads the URI and embeds the file).
355
+ - No cloud sync of the raw image is needed.
356
+ - You control the quote/form lifecycle (no app reinstall risk during an active quote).
357
+
358
+ ### Option 2 — convert to base64/data-uri (portable, heavier)
359
+
360
+ ```typescript
361
+ saveActions
362
+ .executeEntity({
363
+ instanceId: 'save-signature',
364
+ conversions: [{ property: 'signatureImage', from: 'local-uri', to: 'base64' }],
365
+ })
366
+ .dynamicData(ENTITY.FORM_SIGNATURES.FQN, 'save')
367
+ .data({
368
+ id: '=$uuid()',
369
+ signatureImage: '=@ctx.components.mySignature.state.value',
370
+ })
371
+ ```
372
+
373
+ Use this when:
374
+
375
+ - Signatures/images sync to a remote system (Acumatica attachment, server-side rendering, email).
376
+ - Cross-device rendering is required — a `file://` URI is meaningless on another device/server.
377
+ - You need self-contained rows (base64 travels in JSON, survives file-system changes).
378
+
379
+ ### Option 3 — dual-field storage for forms plus generated PDFs
380
+
381
+ When a signature field must reload in a Jigx form and also render in a generated
382
+ PDF that may be regenerated on another device, store both values:
383
+
384
+ - `signatureImage`: the original local URI, used only by `signatureField.initialValue`.
385
+ - `signatureImagePortable`: converted to `data-uri`, used only by the PDF HTML.
386
+
387
+ ```typescript
388
+ saveActions
389
+ .executeEntity({
390
+ instanceId: 'save-signature',
391
+ conversions: [{ property: 'signatureImagePortable', from: 'local-uri', to: 'data-uri' }],
392
+ })
393
+ .dynamicData('default/formSignatures', 'save')
394
+ .data({
395
+ id: '=$uuid()',
396
+ signatureImage: '=@ctx.components.mySignature.state.value',
397
+ signatureImagePortable: '=@ctx.components.mySignature.state.value',
398
+ })
399
+ ```
400
+
401
+ This avoids the signature field asymmetry trap while keeping generated PDFs
402
+ independent of stale device sandbox paths.
403
+
404
+ ### `conversions` schema
405
+
406
+ ```typescript
407
+ conversions: [
408
+ { property: 'fieldName', from: 'local-uri' | 'base64' | 'data-uri' | 'buffer', to: 'base64' | 'data-uri' | 'buffer' | 'local-uri' }
409
+ ]
410
+ ```
411
+
412
+ When converting back to `local-uri`, you can also pass `localFilename` to control the written file name, and `convertHeicToJpg: true` to transcode iPhone HEIC photos.
413
+
414
+ ### Our convention
415
+
416
+ **Default to local `file://` URI.** The Jigx PDF renderer supports `file://` sources
417
+ in `<img src>`, and the `signatureField` re-renders its own URI natively on reload.
418
+ No conversion, symmetric save/load, lightweight rows.
419
+
420
+ Only add `conversions` if the image/signature needs to cross a network boundary
421
+ (Acumatica attachment, server-side rendering, email) where the device-local file
422
+ path is meaningless.
423
+
424
+ ### Signature field asymmetry trap
425
+
426
+ `signatureField.initialValue` accepts `"base64 or URL"` according to the SDK types,
427
+ but a **raw base64 string** (without the `data:image/png;base64,` prefix) does **not**
428
+ render. The field needs either a file URL (`file://...`) or a data URI
429
+ (`data:image/png;base64,...`).
430
+
431
+ This creates a trap if you store raw base64 via `conversions: [{from: 'local-uri', to: 'base64'}]`:
432
+
433
+ 1. User draws signature → component emits `file://...`
434
+ 2. Save converts to raw base64 `iVBOR...` → stored in row
435
+ 3. Re-open form → `initialValue` loads `iVBOR...` → signature field can't render it → blank
436
+
437
+ You might think "just prepend `data:image/png;base64,` on load". But that creates an
438
+ asymmetry: the component now has a **data URI** in its state, while the row has
439
+ **raw base64**. The save's `when` guard compares them and they're never equal — so the
440
+ save fires every time, and the conversion `{from: 'local-uri', to: 'base64'}` tries to
441
+ convert a data URI as if it were a file URI and either fails or stores garbage.
442
+
443
+ **Correct approach for device-local forms:** don't convert the field bound back
444
+ into `signatureField.initialValue`. Store the `file://` URI directly. The signature
445
+ field re-renders its own URI natively on reload. If generated PDFs must survive
446
+ cross-device sync, add a separate portable field as described above. For PDF
447
+ generation, use a JS helper (`html.js`) that handles portable values first, then
448
+ current-device `file://` and legacy base64 formats via a simple startsWith check:
449
+
450
+ ```javascript
451
+ const img = sig.signatureImage
452
+ if (img.startsWith('file://') || img.startsWith('http://') || img.startsWith('https://')) {
453
+ return img
454
+ }
455
+ return `data:image/png;base64,${img}` // legacy fallback
456
+ ```
457
+
458
+ The same trap applies to `media` fields and `avatarField` which use the same
459
+ "base64 or URL" convention.