@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.
- package/README.md +2 -0
- 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 +5 -4
- package/CHANGELOG.md +0 -95
|
@@ -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.
|