@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,452 @@
|
|
|
1
|
+
# Pattern: Extending JSONata with JavaScript Functions
|
|
2
|
+
|
|
3
|
+
Register a JavaScript file whose exported functions become callable from any JSONata expression in the app. This is **the** way to extend JSONata in a Jigx project — anything JSONata can't express cleanly can drop into JS.
|
|
4
|
+
|
|
5
|
+
## When to use
|
|
6
|
+
|
|
7
|
+
Reach for this whenever JSONata becomes awkward or unreadable. Typical categories:
|
|
8
|
+
|
|
9
|
+
| Category | Example |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| **HTML/text template building** | PDF generation, email body composition, report rendering |
|
|
12
|
+
| **Non-trivial math** | Pricing tiers, tax/discount rules, amortization, unit conversions |
|
|
13
|
+
| **Date/time beyond `$fromMillis`** | Business-day arithmetic, timezone conversions, date-range diffs |
|
|
14
|
+
| **Data shaping** | Group-by, pivot, multi-level aggregation, CSV/TSV export |
|
|
15
|
+
| **Validation logic** | Complex multi-field checks, regex-heavy rules, checksum/Luhn |
|
|
16
|
+
| **String parsing and formatting** | Phone normalization, address parsing, slugify, abbreviation |
|
|
17
|
+
| **Lookups and decoding** | State code → full name, SKU → category tree, barcode parsing |
|
|
18
|
+
| **JSON manipulation** | Deep merge with rules, remove-nulls, diffing, schema validation |
|
|
19
|
+
|
|
20
|
+
Keep as pure JSONata:
|
|
21
|
+
|
|
22
|
+
- Simple field reads, string concatenation, null-coalescing
|
|
23
|
+
- Per-component reactive bindings (JSONata is the native reactive layer)
|
|
24
|
+
- One-off conditionals and ternaries
|
|
25
|
+
- Anything where a single expression stays readable
|
|
26
|
+
|
|
27
|
+
## API — `ApplicationBuilder.script(name, script)`
|
|
28
|
+
|
|
29
|
+
Source: `packages/core-sdk/src/application/application.sdk.ts:360`
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
script(name: string, script: unknown): this
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The JSDoc reads "reserved for future use", but it is in active use by the Expert SDK for PDF generation (`packages/expert-sdk/src/expert-form/utils/attach-pdf-action.ts:34`). Treat it as a real but semi-internal feature — no public docs, no validation of the JS body, no TypeScript checking.
|
|
36
|
+
|
|
37
|
+
On `build()`, scripts are transformed into `{ scripts: { expressions: { <name>: <body> } } }` in the output config.
|
|
38
|
+
|
|
39
|
+
## Call syntax from JSONata
|
|
40
|
+
|
|
41
|
+
The **filename without `.js`** becomes a namespace prefix. Exported functions on that module are callable via:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
$<namespace>.<exportedFn>(args)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Example — file `pdf.js` registered as `app.script('pdf.js', ...)`, with `export function create(data) { ... }`:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
=$pdf.create(@ctx.inputs.data)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Step-by-step
|
|
54
|
+
|
|
55
|
+
### 1. Write the JS file
|
|
56
|
+
|
|
57
|
+
Use ES module exports. Place under `src/scripts/` (convention — anywhere works).
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
// src/scripts/quote-math.js
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Calculate total including tax on a quote.
|
|
64
|
+
* @param {number} subtotal
|
|
65
|
+
* @param {number} taxRate - e.g. 0.0725 for 7.25%
|
|
66
|
+
* @returns {number}
|
|
67
|
+
*/
|
|
68
|
+
export function totalWithTax(subtotal, taxRate) {
|
|
69
|
+
if (!subtotal || subtotal < 0) return 0
|
|
70
|
+
const tax = Math.round(subtotal * taxRate * 100) / 100
|
|
71
|
+
return subtotal + tax
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatCurrency(amount) {
|
|
75
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount || 0)
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 2. Read and register in `app.ts`
|
|
80
|
+
|
|
81
|
+
Use Node's `fs` + `path` to read the file at build time:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import * as fs from 'node:fs'
|
|
85
|
+
import * as path from 'node:path'
|
|
86
|
+
import { fileURLToPath } from 'node:url'
|
|
87
|
+
|
|
88
|
+
function readScriptFile(scriptName: string): string {
|
|
89
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
90
|
+
const scriptPath = path.resolve(__dirname, 'scripts', scriptName)
|
|
91
|
+
return fs.readFileSync(scriptPath, 'utf-8')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// In buildApp():
|
|
95
|
+
app.script('quote-math.js', readScriptFile('quote-math.js'))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Call from any expression
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
contactSection.addControl.numberField({
|
|
102
|
+
instanceId: 'total',
|
|
103
|
+
label: 'Total',
|
|
104
|
+
value: '=$quote-math.totalWithTax(@ctx.components.subtotal.state.value, 0.0725)',
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
contactSection.addControl.textField({
|
|
108
|
+
instanceId: 'totalFormatted',
|
|
109
|
+
label: 'Total (formatted)',
|
|
110
|
+
value: '=$quote-math.formatCurrency(@ctx.components.total.state.value)',
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Note: if the filename contains a hyphen, the JSONata reference uses the raw filename segment as the namespace. Prefer short, hyphen-free filenames (`quote.js`, `pdf.js`) if you want cleaner call sites (`$quote.totalWithTax(...)`).
|
|
115
|
+
|
|
116
|
+
## Scenario gallery
|
|
117
|
+
|
|
118
|
+
Short snippets showing the kind of code that belongs in a script file vs. what you'd struggle to write in pure JSONata.
|
|
119
|
+
|
|
120
|
+
### 1. Pricing math with tiered discounts
|
|
121
|
+
|
|
122
|
+
```javascript
|
|
123
|
+
// src/scripts/pricing.js
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Apply tiered volume discount then tax.
|
|
127
|
+
* @param {number} unitPrice
|
|
128
|
+
* @param {number} quantity
|
|
129
|
+
* @param {number} taxRate - 0.0725 = 7.25%
|
|
130
|
+
* @returns {{subtotal: number, discount: number, tax: number, total: number}}
|
|
131
|
+
*/
|
|
132
|
+
export function calculate(unitPrice, quantity, taxRate) {
|
|
133
|
+
const gross = (unitPrice || 0) * (quantity || 0)
|
|
134
|
+
let discountRate = 0
|
|
135
|
+
if (quantity >= 100) discountRate = 0.15
|
|
136
|
+
else if (quantity >= 50) discountRate = 0.10
|
|
137
|
+
else if (quantity >= 20) discountRate = 0.05
|
|
138
|
+
|
|
139
|
+
const discount = Math.round(gross * discountRate * 100) / 100
|
|
140
|
+
const subtotal = gross - discount
|
|
141
|
+
const tax = Math.round(subtotal * (taxRate || 0) * 100) / 100
|
|
142
|
+
return { subtotal, discount, tax, total: subtotal + tax }
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// Usage — call and destructure in separate components
|
|
148
|
+
screen.addExpression(
|
|
149
|
+
'pricing',
|
|
150
|
+
'=$pricing.calculate(@ctx.components.unitPrice.state.value, @ctx.components.qty.state.value, 0.0725)',
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
section.addControl.numberField({ instanceId: 'subtotal', value: '=@ctx.expressions.pricing.subtotal' })
|
|
154
|
+
section.addControl.numberField({ instanceId: 'discount', value: '=@ctx.expressions.pricing.discount' })
|
|
155
|
+
section.addControl.numberField({ instanceId: 'tax', value: '=@ctx.expressions.pricing.tax' })
|
|
156
|
+
section.addControl.numberField({ instanceId: 'total', value: '=@ctx.expressions.pricing.total' })
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The JS returns an object; the single expression caches the result; individual fields read members. Cleaner than four nested JSONata ternaries.
|
|
160
|
+
|
|
161
|
+
### 2. Date formatting beyond `$fromMillis`
|
|
162
|
+
|
|
163
|
+
```javascript
|
|
164
|
+
// src/scripts/dates.js
|
|
165
|
+
|
|
166
|
+
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
|
167
|
+
|
|
168
|
+
/** Format ISO date as "Mar 5, 2026" */
|
|
169
|
+
export function friendly(iso) {
|
|
170
|
+
if (!iso) return ''
|
|
171
|
+
const d = new Date(iso)
|
|
172
|
+
if (isNaN(d.getTime())) return iso
|
|
173
|
+
return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Days between two ISO dates (positive if b > a) */
|
|
177
|
+
export function daysBetween(a, b) {
|
|
178
|
+
if (!a || !b) return null
|
|
179
|
+
const da = new Date(a), db = new Date(b)
|
|
180
|
+
return Math.round((db - da) / (1000 * 60 * 60 * 24))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Add business days (skipping Sat/Sun) */
|
|
184
|
+
export function addBusinessDays(iso, days) {
|
|
185
|
+
const d = new Date(iso)
|
|
186
|
+
let added = 0
|
|
187
|
+
while (added < days) {
|
|
188
|
+
d.setDate(d.getDate() + 1)
|
|
189
|
+
if (d.getDay() !== 0 && d.getDay() !== 6) added++
|
|
190
|
+
}
|
|
191
|
+
return d.toISOString()
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// Usage
|
|
197
|
+
field.value('=$dates.friendly(@ctx.datasources.order.data.orderDate)')
|
|
198
|
+
field.value('="Due in " & $string($dates.daysBetween($now(), @ctx.datasources.order.data.dueDate)) & " days"')
|
|
199
|
+
field.value('=$dates.addBusinessDays(@ctx.datasources.order.data.orderDate, 5)')
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### 3. Multi-field validation
|
|
203
|
+
|
|
204
|
+
```javascript
|
|
205
|
+
// src/scripts/validate.js
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validate a US address and return first problem, or null if OK.
|
|
209
|
+
* Useful for showing a single error message when multiple fields interact.
|
|
210
|
+
*/
|
|
211
|
+
export function address({ line1, city, state, postalCode }) {
|
|
212
|
+
if (!line1 || line1.length < 3) return 'Street address is too short'
|
|
213
|
+
if (!city) return 'City is required'
|
|
214
|
+
if (!/^[A-Z]{2}$/.test(state || '')) return 'State must be a 2-letter code'
|
|
215
|
+
if (!/^\d{5}(-\d{4})?$/.test(postalCode || '')) return 'ZIP must be 5 or 9 digits'
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Luhn checksum for credit card numbers */
|
|
220
|
+
export function luhn(num) {
|
|
221
|
+
const digits = String(num || '').replace(/\D/g, '').split('').map(Number)
|
|
222
|
+
if (digits.length < 13) return false
|
|
223
|
+
let sum = 0
|
|
224
|
+
for (let i = 0; i < digits.length; i++) {
|
|
225
|
+
let d = digits[digits.length - 1 - i]
|
|
226
|
+
if (i % 2 === 1) { d *= 2; if (d > 9) d -= 9 }
|
|
227
|
+
sum += d
|
|
228
|
+
}
|
|
229
|
+
return sum % 10 === 0
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// Usage — show error text beneath a form section
|
|
235
|
+
section.addControl.textField({
|
|
236
|
+
instanceId: 'addressError',
|
|
237
|
+
value: '=$validate.address({"line1": @ctx.components.line1.state.value, "city": @ctx.components.city.state.value, "state": @ctx.components.state.state.value, "postalCode": @ctx.components.zip.state.value})',
|
|
238
|
+
isHidden: '=$validate.address({...}) = null',
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### 4. Data shaping — group by + aggregate
|
|
243
|
+
|
|
244
|
+
```javascript
|
|
245
|
+
// src/scripts/agg.js
|
|
246
|
+
|
|
247
|
+
/** Group records by a key and sum a field per group */
|
|
248
|
+
export function sumBy(records, groupKey, sumKey) {
|
|
249
|
+
const out = {}
|
|
250
|
+
for (const r of records || []) {
|
|
251
|
+
const k = r[groupKey] ?? 'unknown'
|
|
252
|
+
out[k] = (out[k] || 0) + (Number(r[sumKey]) || 0)
|
|
253
|
+
}
|
|
254
|
+
return Object.entries(out).map(([key, total]) => ({ key, total }))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Pivot: records × rowKey × colKey → matrix */
|
|
258
|
+
export function pivot(records, rowKey, colKey, valueKey) {
|
|
259
|
+
const rows = {}
|
|
260
|
+
for (const r of records || []) {
|
|
261
|
+
const row = r[rowKey], col = r[colKey]
|
|
262
|
+
if (!rows[row]) rows[row] = { [rowKey]: row }
|
|
263
|
+
rows[row][col] = (rows[row][col] || 0) + (Number(r[valueKey]) || 0)
|
|
264
|
+
}
|
|
265
|
+
return Object.values(rows)
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
// Feed a list with grouped data — no SQL GROUP BY needed
|
|
271
|
+
list.data('=$agg.sumBy(@ctx.datasources.orders, "category", "amount")')
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
JSONata's `$sum`/`$reduce` can do single-group aggregation but chokes on multi-key grouping. JS is much clearer here.
|
|
275
|
+
|
|
276
|
+
### 5. Parsing and normalization
|
|
277
|
+
|
|
278
|
+
```javascript
|
|
279
|
+
// src/scripts/format.js
|
|
280
|
+
|
|
281
|
+
/** Normalize US phone to (555) 123-4567 */
|
|
282
|
+
export function phone(s) {
|
|
283
|
+
const d = String(s || '').replace(/\D/g, '')
|
|
284
|
+
if (d.length === 10) return `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`
|
|
285
|
+
if (d.length === 11 && d[0] === '1') return `+1 (${d.slice(1,4)}) ${d.slice(4,7)}-${d.slice(7)}`
|
|
286
|
+
return s
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** URL-safe slug from any string */
|
|
290
|
+
export function slug(s) {
|
|
291
|
+
return String(s || '')
|
|
292
|
+
.toLowerCase()
|
|
293
|
+
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '') // strip accents
|
|
294
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
295
|
+
.replace(/^-+|-+$/g, '')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Compact address as single display line */
|
|
299
|
+
export function address({ line1, line2, city, state, zip }) {
|
|
300
|
+
return [line1, line2, [city, state].filter(Boolean).join(', '), zip]
|
|
301
|
+
.filter(Boolean).join(' · ')
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### 6. Text export (CSV)
|
|
306
|
+
|
|
307
|
+
```javascript
|
|
308
|
+
// src/scripts/csv.js
|
|
309
|
+
|
|
310
|
+
const escape = (v) => {
|
|
311
|
+
const s = v == null ? '' : String(v)
|
|
312
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Build CSV from an array of row objects and an ordered column list */
|
|
316
|
+
export function build(rows, columns) {
|
|
317
|
+
const header = columns.map(escape).join(',')
|
|
318
|
+
const body = (rows || [])
|
|
319
|
+
.map((r) => columns.map((c) => escape(r[c])).join(','))
|
|
320
|
+
.join('\n')
|
|
321
|
+
return header + '\n' + body
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
// Use with generateFile action to produce a downloadable CSV
|
|
327
|
+
actions.generateFile({
|
|
328
|
+
fileName: 'orders.csv',
|
|
329
|
+
content: '=$csv.build(@ctx.datasources.orders, ["id","customer","total","date"])',
|
|
330
|
+
encoding: 'utf8',
|
|
331
|
+
})
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### 7. Deep merge with null-strip (save/sync hygiene)
|
|
335
|
+
|
|
336
|
+
```javascript
|
|
337
|
+
// src/scripts/obj.js
|
|
338
|
+
|
|
339
|
+
/** Merge patch over base, treating null/undefined as "don't touch" */
|
|
340
|
+
export function softMerge(base, patch) {
|
|
341
|
+
const out = { ...(base || {}) }
|
|
342
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
343
|
+
if (v !== null && v !== undefined) out[k] = v
|
|
344
|
+
}
|
|
345
|
+
return out
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Remove keys with null/undefined/empty-string values */
|
|
349
|
+
export function compact(obj) {
|
|
350
|
+
const out = {}
|
|
351
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
352
|
+
if (v !== null && v !== undefined && v !== '') out[k] = v
|
|
353
|
+
}
|
|
354
|
+
return out
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// Useful for executeEntity saves where you want to preserve unchanged fields
|
|
360
|
+
.data('=$obj.softMerge(@ctx.datasources.customer.data, {"email": @ctx.components.email.state.value})')
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
JSONata's built-in `$merge` does a full overwrite — if the patch has `null`, it nulls the field. Sometimes that's not what you want.
|
|
364
|
+
|
|
365
|
+
## Real-world references
|
|
366
|
+
|
|
367
|
+
### Expert SDK — PDF generation helper
|
|
368
|
+
|
|
369
|
+
`packages/expert-sdk/src/expert-form/utils/attach-pdf-action.ts:34`
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
app.script('pdf.js', readScriptFile('pdf.js'))
|
|
373
|
+
|
|
374
|
+
actions.generatePdf({
|
|
375
|
+
instanceId: 'pdf-file',
|
|
376
|
+
fileName: SUBMISSION_PDF_FILE_NAME,
|
|
377
|
+
html: `=$pdf.create(@ctx.inputs.data)`,
|
|
378
|
+
})
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`pdf.js` exports `create(data)` which returns an HTML string for the PDF generator to render. That's a great canonical use case: a 200-line HTML template would be unreadable as a JSONata string.
|
|
382
|
+
|
|
383
|
+
### doorpro-door-quote — Sales form HTML helper
|
|
384
|
+
|
|
385
|
+
`workspace/acumatica-apps/doorpro-door-quote/src/scripts/html.js` — ~1950 lines exporting multiple `*GenerateHTML(...)` functions for different form types. Registered in `src/app.ts` via:
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
app.script('html.js', readScriptFile('html.js'))
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Called from `src/actions/act-generate-quote-pdf.ts` as part of a sequential action list:
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
workflow.actions.generatePdf({
|
|
395
|
+
instanceId: 'generate-pdf',
|
|
396
|
+
fileName: '=... & ".pdf"',
|
|
397
|
+
html: '=$html.salesFormGenerateHTML(@ctx.datasources.salesInfoAnswers, @ctx.datasources.doorDetails, ...)',
|
|
398
|
+
})
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The JS function receives 8 datasources positionally (not wrapped in a single object) — that works fine. Callers with different datasource shapes can still call it as long as they pass the same positional types.
|
|
402
|
+
|
|
403
|
+
**Signature handling note**: this helper supports both legacy base64 signatures and new local-uri signatures by checking the `file://` / `http://` / `https://` prefix before building an `<img src>`. Lets us migrate storage format without rewriting the helper:
|
|
404
|
+
|
|
405
|
+
```javascript
|
|
406
|
+
const img = sig.signatureImage
|
|
407
|
+
if (img.startsWith('file://') || img.startsWith('http://') || img.startsWith('https://')) {
|
|
408
|
+
return img
|
|
409
|
+
}
|
|
410
|
+
return `data:image/png;base64,${img}` // legacy fallback
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Design tips
|
|
414
|
+
|
|
415
|
+
- **One file per concern.** `pricing.js`, `dates.js`, `validate.js`, `csv.js`. Namespacing by filename keeps call sites self-documenting (`$pricing.calculate(...)` reads better than `$util.calculatePrice(...)`).
|
|
416
|
+
- **Prefer pure functions.** Same inputs → same outputs, no side effects. JSONata reactivity works best when the function is referentially transparent.
|
|
417
|
+
- **Return objects for multi-value results.** Let the caller destructure via JSONata member access: `@ctx.expressions.pricing.tax`. Beats returning a tuple and indexing.
|
|
418
|
+
- **Accept either positional or single-object arguments.** For 1–3 arguments, positional is natural. For more, accept a single object so callers don't have to remember order.
|
|
419
|
+
- **Defensive null handling.** JSONata can pass `null` for missing fields. Use `v ?? default` or `v || default` guards at the top of functions.
|
|
420
|
+
- **Avoid `console.log`**. There's no reliable place to read it at runtime. If you need debugging, return diagnostic info in the result object.
|
|
421
|
+
- **No imports.** The JS file stands alone — no `require`, no `import` from other files. Everything you need must be in-lined or written inside the same file.
|
|
422
|
+
- **Keep state-free.** Don't rely on module-level variables holding state between calls. Treat each call as independent.
|
|
423
|
+
|
|
424
|
+
## Testing a script in isolation
|
|
425
|
+
|
|
426
|
+
Because it's just a `.js` file with ES module exports, you can run it through Node directly for quick unit tests:
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
node --input-type=module -e "
|
|
430
|
+
import('./src/scripts/pricing.js').then(m => {
|
|
431
|
+
console.log(m.calculate(100, 25, 0.0725))
|
|
432
|
+
})
|
|
433
|
+
"
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Or write a proper test file and run it with `node --test`. The JS file isn't tied to Jigx at runtime, so anything that runs outside Jigx will behave the same inside.
|
|
437
|
+
|
|
438
|
+
## Caveats
|
|
439
|
+
|
|
440
|
+
1. **No TypeScript.** The body is a `.js` file. You can use JSDoc for light type hints but there's no compile-time check.
|
|
441
|
+
2. **Semi-internal.** The `script()` method's JSDoc says "reserved for future use". Stable enough that the expert-sdk ships with it, but don't be surprised if the transformation shape (`scripts.expressions`) changes.
|
|
442
|
+
3. **File-based only.** No inline closure form — must be a separate `.js` file read as a string.
|
|
443
|
+
4. **Runtime-only.** Evaluated on device at expression time. No SDK validation of the JS body or its arguments.
|
|
444
|
+
5. **Namespacing by filename.** Registering two scripts with the same filename overwrites.
|
|
445
|
+
6. **No access to `@ctx`.** The function receives only the arguments passed in the JSONata call. Thread context values in explicitly: `$pdf.create({data: @ctx.inputs.data, user: @ctx.user.displayName})`.
|
|
446
|
+
7. **No browser/Node APIs.** No `fetch`, no `fs`, no `document`. The runtime is a JavaScript sandbox — pure computation only. For I/O, use Jigx actions (`executeEntity`, REST functions, `generatePdf`, etc.).
|
|
447
|
+
8. **No ES module imports between script files.** Each registered script is standalone. If two scripts share helpers, copy the helpers into both.
|
|
448
|
+
9. **Argument types are whatever JSONata evaluates.** Arrays from datasources come through as JS arrays, objects as plain objects, numbers as numbers, `null` as `null`. Dates arrive as ISO strings — `new Date(iso)` to convert.
|
|
449
|
+
|
|
450
|
+
## Generic vs Acumatica-specific
|
|
451
|
+
|
|
452
|
+
This pattern is **generic Jigx** — nothing Acumatica about it. Applies to any Core SDK app.
|