@cxtms/cx-schema 1.9.67 → 1.9.241

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 (81) hide show
  1. package/dist/cli.js +43 -9
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +1 -1
  4. package/schemas/actions/all.json +5 -1
  5. package/schemas/actions/reload.json +17 -0
  6. package/schemas/actions/setSessionStorage.json +38 -0
  7. package/schemas/actions/sound.json +39 -0
  8. package/schemas/actions/vibrate.json +48 -0
  9. package/schemas/components/avatar.json +1 -0
  10. package/schemas/components/badge.json +79 -0
  11. package/schemas/components/barcodeScanner.json +28 -0
  12. package/schemas/components/camera.json +177 -0
  13. package/schemas/components/card.json +78 -0
  14. package/schemas/components/dashboard-widget.json +39 -0
  15. package/schemas/components/dashboard.json +46 -0
  16. package/schemas/components/dataGrid.json +15 -0
  17. package/schemas/components/field-collection.json +86 -2
  18. package/schemas/components/field.json +39 -4
  19. package/schemas/components/form.json +4 -0
  20. package/schemas/components/index.json +17 -1
  21. package/schemas/components/infoLine.json +1 -0
  22. package/schemas/components/map.json +167 -0
  23. package/schemas/components/module.json +32 -0
  24. package/schemas/components/planner.json +1 -0
  25. package/schemas/components/progressBar.json +1 -0
  26. package/schemas/components/timeline.json +216 -74
  27. package/schemas/components/timelineGrid.json +5 -1
  28. package/schemas/fields/autocomplete-googleplaces.json +2 -2
  29. package/schemas/fields/datetime.json +2 -2
  30. package/schemas/fields/index.json +4 -0
  31. package/schemas/fields/number-select.json +88 -0
  32. package/schemas/fields/rangedatetime.json +10 -2
  33. package/schemas/fields/select-async.json +2 -2
  34. package/schemas/fields/toggle.json +91 -0
  35. package/schemas/schema.graphql +11016 -12171
  36. package/schemas/schemas.json +131 -4
  37. package/schemas/workflows/flow/entity.json +3 -1
  38. package/schemas/workflows/input.json +1 -1
  39. package/schemas/workflows/output.json +1 -1
  40. package/schemas/workflows/tasks/all.json +9 -0
  41. package/schemas/workflows/tasks/commodity.json +8 -0
  42. package/schemas/workflows/tasks/contact-address.json +25 -0
  43. package/schemas/workflows/tasks/dispatch-routes.json +64 -0
  44. package/schemas/workflows/tasks/edi.json +15 -0
  45. package/schemas/workflows/tasks/export.json +21 -0
  46. package/schemas/workflows/tasks/httpRequest.json +4 -0
  47. package/schemas/workflows/tasks/import.json +5 -0
  48. package/schemas/workflows/tasks/order-move.json +38 -0
  49. package/schemas/workflows/tasks/order.json +6 -0
  50. package/schemas/workflows/tasks/organization-config.json +101 -0
  51. package/schemas/workflows/tasks/tracking-event.json +11 -3
  52. package/schemas/workflows/tasks/workflow-execute.json +4 -0
  53. package/schemas/workflows/variable.json +2 -2
  54. package/schemas/workflows/workflow.json +39 -2
  55. package/skills/cxtms-developer/SKILL.md +5 -2
  56. package/skills/cxtms-developer/ref-cli-auth.md +2 -0
  57. package/skills/cxtms-developer/ref-entity-commodity.md +41 -5
  58. package/skills/cxtms-developer/ref-entity-contact.md +46 -0
  59. package/skills/cxtms-developer/ref-entity-dispatch-routing.md +195 -0
  60. package/skills/cxtms-developer/ref-entity-geography.md +26 -1
  61. package/skills/cxtms-developer/ref-entity-job.md +6 -2
  62. package/skills/cxtms-developer/ref-entity-order-sub.md +1 -1
  63. package/skills/cxtms-developer/ref-entity-order.md +36 -3
  64. package/skills/cxtms-developer/ref-entity-organization.md +4 -2
  65. package/skills/cxtms-developer/ref-entity-shared.md +79 -3
  66. package/skills/cxtms-developer/ref-graphql-query.md +246 -4
  67. package/skills/cxtms-module-builder/SKILL.md +33 -6
  68. package/skills/cxtms-module-builder/ref-components-data.md +85 -0
  69. package/skills/cxtms-module-builder/ref-components-display.md +282 -8
  70. package/skills/cxtms-module-builder/ref-components-forms.md +386 -21
  71. package/skills/cxtms-module-builder/ref-components-interactive.md +2 -0
  72. package/skills/cxtms-module-builder/ref-components-layout.md +288 -29
  73. package/skills/cxtms-module-builder/ref-components-specialized.md +168 -20
  74. package/skills/cxtms-workflow-builder/SKILL.md +45 -6
  75. package/skills/cxtms-workflow-builder/ref-entity.md +100 -4
  76. package/skills/cxtms-workflow-builder/ref-expressions-ncalc.md +35 -2
  77. package/skills/cxtms-workflow-builder/ref-expressions-template.md +15 -1
  78. package/skills/cxtms-workflow-builder/ref-flow.md +6 -1
  79. package/skills/cxtms-workflow-builder/ref-query.md +16 -0
  80. package/skills/cxtms-workflow-builder/ref-utilities.md +32 -2
  81. package/templates/workflow-public-api.yaml +1 -0
@@ -6,6 +6,7 @@
6
6
  - Dashboard component
7
7
  - DashboardWidget component
8
8
  - Widget component
9
+ - Map component
9
10
  - Timeline component
10
11
  - TimelineGrid component
11
12
  - OAuth2 component
@@ -103,6 +104,55 @@ props:
103
104
 
104
105
  ---
105
106
 
107
+ ## map
108
+
109
+ Inline Google Maps component for plotting explicit markers or dispatch route/template stops.
110
+
111
+ **Props:**
112
+ | Prop | Type | Default | Description |
113
+ |------|------|---------|-------------|
114
+ | `type` | `google` | `google` | Map provider; only Google Maps is supported |
115
+ | `height` | `number` | `520` | Map viewport height in pixels |
116
+ | `center` | `{lat,lng}` | auto | Optional fixed center; numbers or numeric strings |
117
+ | `zoom` | `number` | auto | Initial zoom; single marker defaults to 14 |
118
+ | `markers` | `array` | - | Explicit markers; takes precedence over `stops` |
119
+ | `stops` | `array` | - | Dispatch route/template stops converted from `stopContact.shipping` |
120
+ | `fitBounds` | `boolean` | `true` | Fit viewport to valid markers when no fixed center is supplied |
121
+ | `showUnmappedNote` | `boolean` | `true` | Show a caption for unplotted markers/stops |
122
+ | `showInfoWindow` | `boolean` | `true` | Show marker label/address/stop type in a Google info window |
123
+ | `onMarkerClick` | `action[]` | - | Fired with clicked marker as `result` |
124
+
125
+ `markers` should include `id`, `label`, `lat`, `lng`, and optionally `number`, `address`, `stopType`. Invalid coordinates and `0,0` are treated as unmapped.
126
+
127
+ ```yaml
128
+ component: map
129
+ name: dispatchRouteMap
130
+ props:
131
+ height: 560
132
+ stops: "{{ dispatchRoute.stops }}"
133
+ fitBounds: true
134
+ onMarkerClick:
135
+ - setStore:
136
+ selectedStopId: "{{ result.id }}"
137
+ ```
138
+
139
+ ```yaml
140
+ component: map
141
+ name: terminalMap
142
+ props:
143
+ height: 420
144
+ center: { lat: 33.749, lng: -84.388 }
145
+ zoom: 8
146
+ markers:
147
+ - id: atl
148
+ label: Atlanta Terminal
149
+ lat: 33.749
150
+ lng: -84.388
151
+ address: "Atlanta, GA"
152
+ ```
153
+
154
+ ---
155
+
106
156
  ## notes
107
157
 
108
158
  Rich-text notes/comments component with TipTap editor, message threading, and pagination.
@@ -177,17 +227,17 @@ CSS Grid-based dashboard with draggable/resizable widgets.
177
227
  | `options.gridGap` | `number` | `16` | Gap between cells (px) |
178
228
  | `options.allowEdit` | `boolean` | `false` | Enable edit mode (drag/resize/add/remove) |
179
229
  | `options.showGridLines` | `boolean` | `true` | Grid background in edit mode |
180
- | `options.autoSave` | `boolean` | `false` | Auto-save layout |
230
+ | `options.autoSave` | `boolean` | `false` | Auto-save layout to UserSettings (`tms:dashboards:{name}:layout`) |
181
231
  | `options.title` | `string` | — | Dashboard title |
182
232
  | `options.height` | `string` | — | Container height |
183
233
 
184
- **Children:** `dashboard-widget` components only.
234
+ **Children:** `dashboard-widget` components only. Saved layouts merge sparse YAML-widget overrides, removed-widget markers, and user-added widget entries back onto YAML defaults.
185
235
 
186
236
  ---
187
237
 
188
238
  ## dashboard-widget
189
239
 
190
- Positioned widget card inside a dashboard. Supports drag-to-move and resize in edit mode.
240
+ Positioned widget card inside a dashboard. Supports drag-to-move and resize in edit mode; the final drag/resize position is what gets persisted.
191
241
 
192
242
  **Props (under `options`):**
193
243
  | Prop | Type | Default | Description |
@@ -280,22 +330,37 @@ Data-driven widget that delegates to sub-components by type.
280
330
 
281
331
  ## timeline
282
332
 
283
- MUI Lab Timeline for displaying events chronologically. Horizontal or vertical orientation.
333
+ The mobile renderer supports the shared normal and tracking contracts. In normal mode, `activeTemplate` can override `eventTemplate` for events mapped with `status: active`; both templates receive the event as `item`. Mobile renders horizontal timelines as a scrolling row and does not visually implement `alternateSides` or `showTodayMarker`. Query-backed sources can use static `events` as a failure fallback.
334
+
335
+ MUI Lab Timeline for chronological events or milestone-based tracking progress.
336
+
337
+ **Modes:**
338
+ - `normal` (default): date-range timeline with day/week/month/year view controls.
339
+ - `tracking`: vertical milestone timeline. Events are matched to `milestones[].key` through mapped event `key`; matched milestones are `completed`, unmatched milestones are `pending`, and unmatched events can be shown as `extra`.
284
340
 
285
341
  **Props:**
286
342
  | Prop | Type | Default | Description |
287
343
  |------|------|---------|-------------|
288
- | `orientation` | `horizontal \| vertical` | `horizontal` | Layout mode |
289
- | `view` | `day \| week \| month \| year` | `week` | Time view |
290
- | `startDate` / `endDate` | `string` | | Initial date range |
291
- | `eventSources` | `EventSource[]` | | Same pattern as calendar |
292
- | `eventTemplate` | `ComponentProps` | — | Custom event template |
293
- | `options.height` | `string \| number` | `400` | Component height |
294
- | `options.showTodayMarker` | `boolean` | `true` | Today marker |
295
- | `options.enableZoom` | `boolean` | `true` | View switcher |
296
- | `options.enableNavigation` | `boolean` | `true` | Prev/next/today buttons |
297
- | `options.alternating` | `boolean` | `true` | Alternate sides (vertical) |
298
- | `options.dateFormat` | `string` | `MMM DD` | Date format |
344
+ | `mode` | `normal \| tracking` | `normal` | Timeline behavior |
345
+ | `orientation` | `horizontal \| vertical` | `horizontal` | Layout mode; tracking renders vertical |
346
+ | `view` | `day \| week \| month \| year` | `week` | Time view for normal mode |
347
+ | `startDate` / `endDate` | `string` | current week | Initial date range for normal mode |
348
+ | `eventSources` | `EventSource[]` | — | GraphQL/static event sources |
349
+ | `eventTemplate` | `ComponentProps` | | Custom event template for normal mode |
350
+ | `milestones` | `Milestone[]` | `[]` | Tracking milestones |
351
+ | `options.height` | `string \| number` | `400`/`auto` | Component height |
352
+ | `options.showTodayMarker` | `boolean` | `true` | Today marker in normal mode |
353
+ | `options.enableZoom` | `boolean` | `true` | View switcher in normal mode |
354
+ | `options.enableNavigation` | `boolean` | `true` | Prev/next/today buttons in normal mode |
355
+ | `options.alternateSides` | `boolean` | `true` | Alternate sides in vertical mode |
356
+ | `options.showTwoColumns` | `boolean` | `true` | Show date/status opposite event content in vertical mode |
357
+ | `options.dateFormat` | `string` | `MMM DD` | Moment date format |
358
+ | `options.timeFormat` | `string` | `h:mm A` | Moment time format |
359
+ | `options.extraEvents` | `show \| hide` | `hide` | Show events that do not match a milestone in tracking mode |
360
+
361
+ **EventSource:** `query.command`, `query.variables`, `query.path`, and `query.mapping` live under the `query` object. Mapping supports `id`, `key`, `date`, `title`, `description`, colors, icons, and additional fields. Tracking sources should map `key`. The component does not filter mapped events by `includeInTracking` or `isInactive`; apply those filters in the GraphQL query or source mapping when needed.
362
+
363
+ **Milestone:** `{ key, label?, description?, icon? }`. `label` can be localized (`{ en-US: "Delivered" }`).
299
364
 
300
365
  **Events:** `onEventClick` (data: `event`)
301
366
 
@@ -307,12 +372,17 @@ props:
307
372
  view: month
308
373
  options:
309
374
  enableNavigation: true
310
- alternating: true
375
+ alternateSides: true
376
+ showTwoColumns: true
311
377
  eventSources:
312
378
  - query:
313
- command: "query($id: Int!) { orderHistory(orderId: $id) { id title date type } }"
379
+ command: "query($id: Int!) { orderHistory(orderId: $id) { id title date type includeInTracking isInactive } }"
314
380
  variables: { id: "{{ number orderId }}" }
315
- path: orderHistory
381
+ path: data.orderHistory
382
+ mapping:
383
+ id: "{{ item.id }}"
384
+ date: "{{ item.date }}"
385
+ title: "{{ item.title }}"
316
386
  eventTemplate:
317
387
  component: card
318
388
  name: eventCard
@@ -329,8 +399,78 @@ props:
329
399
  props: { eventId: "{{ event.id }}" }
330
400
  ```
331
401
 
402
+ ```yaml
403
+ component: timeline
404
+ name: orderTracking
405
+ props:
406
+ mode: tracking
407
+ orientation: vertical
408
+ options:
409
+ showTwoColumns: true
410
+ alternateSides: false
411
+ extraEvents: hide
412
+ eventSources:
413
+ - query:
414
+ command: >
415
+ query($organizationId: Int!, $orderId: Int!) {
416
+ order(organizationId: $organizationId, orderId: $orderId) {
417
+ trackingEvents {
418
+ trackingEventId
419
+ eventDate
420
+ description
421
+ includeInTracking
422
+ isInactive
423
+ eventDefinition { eventName }
424
+ }
425
+ }
426
+ }
427
+ variables:
428
+ organizationId: "{{ number organizationId }}"
429
+ orderId: "{{ number orderId }}"
430
+ path: order.trackingEvents
431
+ mapping:
432
+ key: "{{ item.eventDefinition.eventName }}"
433
+ id: "{{ item.trackingEventId }}"
434
+ date: "{{ item.eventDate }}"
435
+ title: "{{ item.eventDefinition.eventName }}"
436
+ description: "{{ item.description }}"
437
+ milestones:
438
+ - key: Shipment Created
439
+ label: { en-US: Shipment Created }
440
+ - key: In Transit
441
+ label: { en-US: In Transit }
442
+ icon: truck
443
+ - key: Delivered
444
+ label: { en-US: Delivered }
445
+ icon: check-circle
446
+ ```
447
+
332
448
  ---
333
449
 
450
+ ## planner
451
+
452
+ Use `planner` when columns are ordinal positions within grouped resource rows, not time buckets. Items flow through extraction (`queries[].itemsPath`), grouping (`rows.groupBy`), stable sorting (`columns.orderBy`), and index-based placement.
453
+
454
+ ```yaml
455
+ component: planner
456
+ name: dispatchPlanner
457
+ props:
458
+ queries:
459
+ - name: work
460
+ query: { command: 'query Work { work { items } }', path: work.items }
461
+ - name: resources
462
+ query: { command: 'query Resources { resources { items } }', path: resources.items }
463
+ rows: { groupBy: resourceId, fromQuery: resources, key: id, headerWidth: 240 }
464
+ columns: { orderBy: [{ field: sequence, direction: asc }], appendEmpty: true, label: 'Move {{ index }}', width: 400 }
465
+ itemTemplate: { component: card }
466
+ ```
467
+
468
+ Optional templates cover row headers, empty cells/state, and summaries. Events are `onItemClick`, `onCellClick`, `onRowClick`, and `onItemsLoaded`. Explicit row-header and column widths are required for alignment when rows virtualize.
469
+
470
+ `dotsMenu.items` adds dataGrid-style card actions. Items support `label`, `permission`, templated `disabled`, and `onClick`; actions receive the full item context without also triggering `onItemClick`.
471
+
472
+ Set `columns.appendEmpty: true` to render one trailing empty ordinal slot beyond the largest group, ensuring every lane has an append target.
473
+
334
474
  ## timeline-grid
335
475
 
336
476
  CSS Grid-based timeline with swim lanes, drill-down, and virtual scrolling.
@@ -358,7 +498,7 @@ CSS Grid-based timeline with swim lanes, drill-down, and virtual scrolling.
358
498
  | Variable | Type | Description |
359
499
  |----------|------|-------------|
360
500
  | `dataSources` | `Record<string, TimelineEvent[]>` | Per-source events filtered to the column, keyed by `query.name` |
361
- | `column` | `ColumnDefinition` | Column metadata (`id`, `label`, `date`, `startDate`, `endDate`) |
501
+ | `column` | `ColumnDefinition` | Column metadata (`id`, `label`, `date`, `startDate`, `endDate`, `startDateLocal`, `endDateLocal`) |
362
502
  | `columnIndex` | `number` | Zero-based column index |
363
503
  | `totalCount` | `number` | Total event count for the column across all sources |
364
504
 
@@ -366,7 +506,7 @@ CSS Grid-based timeline with swim lanes, drill-down, and virtual scrolling.
366
506
  | Event | Data | Description |
367
507
  |-------|------|-------------|
368
508
  | `onEventClick` | `item, view` | Event clicked |
369
- | `onCellClick` | `column, row, date, view` | Empty cell clicked |
509
+ | `onCellClick` | `column, row, date, view` | Cell clicked; `column.startDate`/`endDate` are UTC ISO strings and `column.startDateLocal`/`endDateLocal` are local wall-clock `YYYY-MM-DDTHH:mm:ss` strings |
370
510
  | `onViewChange` | `previousView, newView, startDate, endDate` | View changed |
371
511
  | `onNavigate` | `direction, view, startDate, endDate` | Navigation |
372
512
  | `onEventsLoaded` | `events, eventCount, view, dataRange` | Data loaded |
@@ -374,6 +514,8 @@ CSS Grid-based timeline with swim lanes, drill-down, and virtual scrolling.
374
514
 
375
515
  **Column drill-down:** Click column header: year->month, month->week, week->day.
376
516
 
517
+ **Cell click date fields:** In `onCellClick`, prefer `column.startDateLocal` / `column.endDateLocal` when passing the clicked calendar range to local-day queries or dialogs. `column.startDate` / `column.endDate` remain UTC ISO instants for absolute timestamp use cases.
518
+
377
519
  ```yaml
378
520
  component: timeline-grid
379
521
  name: scheduleGrid
@@ -453,9 +595,14 @@ OAuth2 authorization flow button. Opens popup for auth, exchanges code for token
453
595
  | `scopes` | `string[]` | Requested scopes |
454
596
  | `additionalParams` | `Record<string, string>` | Extra auth URL params |
455
597
  | `additionalHeaders` | `object` | Extra token request headers |
598
+ | `oauthWorkflowId` | `string` | Optional workflow ID for server-side token exchange through `executeWorkflow` |
456
599
  | `label` | `ILocalizeString` | Button label (default: `Authorize`) |
457
600
  | `className` | `string` | Button CSS class |
458
601
 
602
+ **Callback route:** configure OAuth providers with `/oauth2/callback`. The app redirects that stable URL to `/{locale}/oauth2/callback` and preserves the query string.
603
+
604
+ **Workflow exchange:** when `oauthWorkflowId` is set, the callback passes `organizationId` inside `ExecuteWorkflowInput` together with `credentials`, `tokenUrl`, and `headers`. The `organizationId` is saved before opening the popup because the callback popup starts with a fresh Redux store.
605
+
459
606
  **Events:** `onToken` — fires with `{ token }` when OAuth completes.
460
607
 
461
608
  ```yaml
@@ -467,6 +614,7 @@ props:
467
614
  clientSecret: "{{ quickbooksClientSecret }}"
468
615
  authorizationUrl: "https://appcenter.intuit.com/connect/oauth2"
469
616
  tokenUrl: "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
617
+ oauthWorkflowId: "workflow-uuid-for-token-exchange" # optional
470
618
  scopes:
471
619
  - com.intuit.quickbooks.accounting
472
620
  onToken:
@@ -68,7 +68,7 @@ npx cxtms create workflow <name> --template <template>
68
68
 
69
69
  **`webhook`** — endpoint: `POST /api/v2/orgs/{organizationId}/webhooks/{workflowId}`. The endpoint is anonymous (`[AllowAnonymous]`) and rate-limited (10/sec, 100/min per IP). Two inputs are auto-injected by the controller: `payload` (parsed JSON body or raw string) and `request` (object with `headers`, `body`, `remoteIpAddress`). Control the HTTP response via `response` and `statusCode` outputs. Update `webhookSecret` configName to your app config path. Customize the `ValidateWebhook` step for your auth method (header secret, HMAC signature, etc.). Use `executionMode: Sync` when the caller needs a response; use `Async` for fire-and-forget (returns immediately). Keep `runAs: "system"` since the endpoint is anonymous. Add `additionalProperties.cors.allowedOrigins` to restrict CORS if needed.
70
70
 
71
- **`public-api`** — requires a top-level `api` section defining the REST endpoint. Set `api.path` with route params (e.g., `/orders/{orderId}`), `api.method` (GET/POST/PUT/PATCH/DELETE), `api.authentication` (`none`, `bearer`, `apiKey`), `api.document` (swagger doc name, default `"public"`), and `api.category` (swagger tag). Configure `api.rateLimit` with `perSecond`/`perMinute`. Each input uses `props.in` (`path`, `query`, `header`, `body`) to specify where the parameter comes from, and `props.format` for OpenAPI type hints (e.g., `uuid`, `date-time`). Outputs use `props.type`, `props.description`, and `props.schema` to describe the response for OpenAPI docs. Must use `executionMode: Sync`. Control HTTP response via `response` and `statusCode` outputs.
71
+ **`public-api`** — requires a top-level `api` section defining the REST endpoint. Set `api.path` with route params (e.g., `/orders/{orderId}`), `api.method` (GET/POST/PUT/PATCH/DELETE), `api.authentication` (`none`, `bearer`, `apiKey`), `api.document` (swagger doc name, default `"public"`), and `api.category` (swagger tag). Configure `api.rateLimit` with `perSecond`/`perMinute`. Each input uses `props.in` (`path`, `query`, `header`, `body`) to specify where the parameter comes from, and `props.format` for OpenAPI type hints (e.g., `uuid`, `date-time`). Body inputs may define `props.schema` with recursive OpenAPI schema fields (`type`, `properties`, `required`, `items`, `format`, `description`, `enum`); body inputs without a schema render as an object request body. Prefer `api.responses` for response documentation. Legacy `response` outputs can still use `props.type`, `props.description`, and recursive `props.schema` to describe the single `200` response. Must use `executionMode: Sync`. Control HTTP response via `response` and `statusCode` outputs.
72
72
 
73
73
  **All templates** include workflow-level `events` (`onWorkflowStarted`, `onWorkflowCompleted`, `onWorkflowFailed`) and activity-level `events` (`onActivityStarted`, `onActivityCompleted`, `onActivityFailed`) with Log steps. Replace/extend these with notification tasks (Email/Send, HttpRequest, Workflow/Execute) as needed. (`onWorkflowExecuted` is a deprecated alias for `onWorkflowCompleted` — use `onWorkflowCompleted` in new workflows.)
74
74
 
@@ -191,10 +191,25 @@ events: # Workflow-level event handlers
191
191
  For template expressions and value directives: see [ref-expressions-template.md](skills/cxtms-workflow-builder/ref-expressions-template.md)
192
192
  For NCalc conditions and functions: see [ref-expressions-ncalc.md](skills/cxtms-workflow-builder/ref-expressions-ncalc.md)
193
193
 
194
- **`{{ path }}`** — in step inputs. Single `{{ }}` returns raw object. Multiple returns string interpolation.
195
- **`[variable]`** — in conditions and `expression:` directives. NCalc syntax.
196
- **Value directives**: `expression`, `coalesce`, `foreach`, `switch`, `extends`, `$raw`
197
- **38 custom functions** + NCalc built-ins. Key ones: `isNullOrEmpty()`, `any()`, `all()`, `count()`, `sum()`, `first()`, `last()`, `contains()`, `join()`, `split()`, `format()`, `now()`, `addDays()`, `formatDate()`, `if()`, `groupBy()`, `concat()`, `distinct()`
194
+ **`{{ path }}`** — in step inputs. Single `{{ }}` returns raw object. Multiple returns string interpolation. Supports sub-expressions `(variableName)` inside filter brackets for dynamic matching.
195
+ **`[variable]`** — in conditions and `expression:` directives. NCalc syntax. **Warning**: empty strings are silently converted to `null` — always use `isNullOrEmpty()` instead of comparing to `''`. See [ref-expressions-ncalc.md](skills/cxtms-workflow-builder/ref-expressions-ncalc.md) Pitfalls section.
196
+ **Value directives**: `expression`, `coalesce`, `foreach`, `switch`, `extends`, `$raw`, `$eval`, `resolve`, `decrypt`/`encrypt`
197
+ **Type converters** (prefix in `{{ }}`): `int`, `decimal`, `bool`, `boolOrFalse`, `boolOrTrue`, `datetime`, `string`, `emptyIfNull`, `nullIfEmpty`, `luceneString`, `transliterate`, `transliterateUa`, `fromJson`, `toJson`, `trim`, `toLocalTime`
198
+ **51 custom functions** + NCalc built-ins. Key ones: `isNullOrEmpty()`, `any()`, `all()`, `count()`, `sum()`, `first()`, `last()`, `contains()`, `join()`, `split()`, `format()`, `now()`, `addDays()`, `addHours()`, `dateDiff()`, `formatDate()`, `if()`, `groupBy()`, `concat()`, `distinct()`, `select()`, `zip()`, `regex()`, `fromJson()`
199
+
200
+ ### Resolution Pipeline
201
+
202
+ Variables resolve through a 6-stage pipeline:
203
+ 1. **Declaration** — `variables:` section sets static/computed/expression values
204
+ 2. **Step input resolution** — `inputs:` values resolved per step
205
+ 3. **Template resolution** — `{{ }}` expressions parsed by `TemplatedVariableParser` (type converters, sub-expressions, property paths)
206
+ 4. **NCalc evaluation** — `conditions` and `expression:` directives evaluated by `ExpressionEvaluatorNCalc`
207
+ 5. **foreach scoping** — `item` and `index` variables injected per iteration
208
+ 6. **Output mapping** — step results stored as `ActivityName.StepName.outputKey`
209
+
210
+ ### Runtime Boundary
211
+
212
+ Workflow `{{ }}` (server-side `TemplatedVariableParser`) and app module `{{ }}` (client-side custom template engine) are **different runtimes** with different syntax and capabilities. Workflow expressions support type converters, value directives, and NCalc conditions. App module expressions support their own operations: `eval`, `format`, `formatTz`, `hasPermission`, `isNullOrEmpty`, `any`, `isEqual`/`isEquals`, `moreThan`, `lessThan`, `dateDiff`, `daysBetween`, `daysUntil`, `daysAgo`, `isDateBefore`, `isDateAfter`, `round`, `trim`, `parse`, `startsWith`, `endsWith`, `includes`/`contains`, `isTrue`, `localStorage`, `sessionStorage`, `fromConfig`, `encodeURIComponent`.
198
213
 
199
214
  ### Null-Safe Operator `?` — USE BY DEFAULT
200
215
 
@@ -214,6 +229,30 @@ collection: "Activity?.Step?.output?.items?"
214
229
  url: "{{ config?.baseUrl? }}"
215
230
  ```
216
231
 
232
+ ### `fromConfig` Whole-Config Variables Include `organizationConfigId`
233
+
234
+ When a workflow-level variable loads an organization config without a `key`, the runtime injects `organizationConfigId` into the returned object alongside the config custom values. Use this ID when a later `OrganizationConfig/Update@1` step needs to update the same record.
235
+
236
+ ```yaml
237
+ variables:
238
+ - name: config
239
+ fromConfig:
240
+ configName: "apps.myApp"
241
+
242
+ activities:
243
+ - name: UpdateConfig
244
+ steps:
245
+ - task: "OrganizationConfig/Update@1"
246
+ inputs:
247
+ organizationId: "{{ int organizationId }}"
248
+ organizationConfigId: "{{ int config.organizationConfigId }}"
249
+ organizationConfig:
250
+ baseUrl: "{{ config?.baseUrl? }}"
251
+ lastSyncAt: "{{ datetime now }}"
252
+ ```
253
+
254
+ If `fromConfig.key` is provided, the variable resolves to only that keyed value and does not include `organizationConfigId`.
255
+
217
256
  **Engine-level null safety for object inputs**: Task inputs resolved as complex objects (e.g., `headers`, `columnMappings`, configuration objects) are automatically null-safe at the engine level. If an optional object input is omitted from YAML, the engine returns `null` instead of throwing — no `?` suffix needed on the YAML key itself. The `?` operator is still required in template expressions and NCalc paths that reference those objects.
218
257
 
219
258
  **When `?` is NOT needed** (guaranteed system variables):
@@ -386,7 +425,7 @@ npx cxtms workflow execute workflow.yaml --vars '{"mode": "preview"}' --file imp
386
425
 
387
426
  `--file varName=path` uploads the local file to the server via presigned URL and sets the resulting URL as the named variable. Can be specified multiple times.
388
427
 
389
- Returns execution result including `executionId`, `isAsync`, `outputs` (for Sync workflows).
428
+ Returns execution result including `executionId`, `isAsync`, `outputs` (for Sync workflows). When retry safety matters, pass a stable `executionId` per business operation so duplicate `executeWorkflow` attempts replay the stored result instead of running side effects again.
390
429
 
391
430
  ### Execution Logs
392
431
 
@@ -14,6 +14,7 @@
14
14
  - Order sub-entity tasks (OrderCommodity, OrderCharge, OrderDocument, OrderTrackingEvent, OrderEntity)
15
15
  - Inventory tasks (InventoryItem Create, Update, Delete)
16
16
  - Other entity tasks (Movement, Country, Cities, Rate, TrackingEvent/Import)
17
+ - Dispatch route generation tasks
17
18
  - Notification tasks (Create)
18
19
  - Note tasks (Create, Update, Delete, Import, Export, RenameThread)
19
20
  - AccountingTransaction/ApplyCredit task
@@ -46,6 +47,39 @@ Imports postal codes (CSV, JSON, Excel). Supports upsert via `matchByFields`.
46
47
  **Outputs:** `result.success`, `result.added`, `result.updated`, `result.errors`, `result.totalProcessed`, `result.hasErrors`
47
48
  Input priority: `stream` > `fileUrl` > `postalCodes`. Task catches exceptions and returns them in `result.errors`.
48
49
 
50
+ ## Dispatch Routes
51
+
52
+ | Task | Description |
53
+ |------|-------------|
54
+ | `DispatchRoutes/Generate@1` | Generate daily draft dispatch routes from enabled templates |
55
+ | `OrderMove/Create@1` | Create an order move with nested legs |
56
+ | `OrderMove/Update@1` | Sparse-update a move and reconcile nested legs |
57
+ | `OrderMove/Delete@1` | Soft-delete a move and its legs |
58
+
59
+ ### DispatchRoutes/Generate@1
60
+
61
+ Creates draft dispatch routes for enabled templates whose `daysOfWeek` contains the requested route date. Idempotent per template/date: existing generated routes are skipped.
62
+
63
+ ```yaml
64
+ - task: "DispatchRoutes/Generate@1"
65
+ name: GenerateDispatchRoutes
66
+ inputs:
67
+ organizationId: "{{ inputs.organizationId }}"
68
+ dispatchRouteStatusId: "{{ inputs.dispatchRouteStatusId }}"
69
+ routeDate: "{{ inputs.routeDate }}"
70
+ outputs:
71
+ - name: createdCount
72
+ mapping: "createdCount"
73
+ - name: skippedCount
74
+ mapping: "skippedCount"
75
+ - name: createdRouteIds
76
+ mapping: "createdRouteIds"
77
+ ```
78
+
79
+ **Inputs:** `organizationId` (string, required), `dispatchRouteStatusId` (string, required), `routeDate` (string?, `yyyy-MM-dd`, defaults to today UTC), `dispatchRouteTemplateId` (string?, generate one template only).
80
+
81
+ **Outputs:** `createdCount`, `skippedCount`, `createdRouteIds`. Generated routes copy template name, route type, division, and stops; driver/equipment assignment happens after generation.
82
+
49
83
  ## Generic Entity Change
50
84
 
51
85
  | Task | Description |
@@ -88,11 +122,18 @@ Input priority: `stream` > `fileUrl` > `postalCodes`. Task catches exceptions an
88
122
  entity:
89
123
  customer: "{{ inputs.customerId }}"
90
124
  status: "Draft"
125
+ values:
126
+ commodities:
127
+ - description: "{{ inputs.description }}"
128
+ pieces: "{{ inputs.pieces }}"
129
+ billToContactId: "{{ inputs.customerId }}"
91
130
  outputs:
92
131
  - name: order
93
132
  mapping: "order"
94
133
  ```
95
134
 
135
+ For `Order/Create@1`, nested `values.commodities[*].billToContactId` is supported and is applied to the created commodity. Use it for multi-customer consolidations so downstream charge filtering can match commodity freight to the correct bill-to contact.
136
+
96
137
  ```yaml
97
138
  - task: "Order/Update@2"
98
139
  name: UpdateOrder
@@ -115,6 +156,7 @@ Imports order data from an external feed. Supports create and upsert (match-by-f
115
156
  options:
116
157
  orderMatchByFields: ["customValues.externalId"]
117
158
  commodityMatchByFields: ["customValues.lineId"]
159
+ commodityMatchConflict: Error
118
160
  skipValues: "NullOrEmpty" # default — preserves existing data when feed stops sending a field
119
161
  outputs:
120
162
  - name: result
@@ -131,6 +173,7 @@ Imports order data from an external feed. Supports create and upsert (match-by-f
131
173
  | `inventoryItemMatchByFields` | string[] | null | Fields to match existing inventory items on commodities. |
132
174
  | `tagMatchByFields` | string[] | null | Fields to match existing tags. |
133
175
  | `commodityMatchByFields` | string[] | null | Fields to match existing commodities on update. |
176
+ | `commodityMatchConflict` | `Link` \| `Error` | `Link` | Handling when a matched top-level commodity belongs to another order of the same type. `Error` skips it and adds an import error. |
134
177
  | `linkTrackingEventsToCommodities` | boolean | false | Link imported tracking events to first-level commodities. |
135
178
  | `skipValues` | `None` \| `Null` \| `NullOrEmpty` | `NullOrEmpty` | Strip null/empty values before updating existing records. `NullOrEmpty` (default) prevents feeds that stop returning a field from wiping existing data (e.g. ETA after delivery). Use `None` to allow explicit null overwrites. |
136
179
 
@@ -141,6 +184,8 @@ Imports order data from an external feed. Supports create and upsert (match-by-f
141
184
 
142
185
  **Order/Import commodity fields**: When importing commodities, you can supply `packageTypeName` (string) instead of `packageTypeId`. The import handler resolves the name to an ID using an N+1-safe per-import cache (one DB query per unique package type name).
143
186
 
187
+ Commodity matches are shared across the entire import session. Nested container commodities match positionally within their parent, preserving repeated same-key lines without duplicating them on re-import.
188
+
144
189
  ## Contact
145
190
 
146
191
  | Task | Description |
@@ -149,6 +194,30 @@ Imports order data from an external feed. Supports create and upsert (match-by-f
149
194
  | `Contact/Update` | Update contact |
150
195
  | `Contact/Delete` | Delete contact |
151
196
 
197
+ **Contact import behavior:** backend contact imports validate `DivisionId` against the target organization. Invalid division IDs are ignored; new contacts default to the importing user's division when no valid division is provided. Nested `division.divisionName` can be supplied and is resolved to an organization division before import.
198
+
199
+ ## OrganizationConfig
200
+
201
+ | Task | Description |
202
+ |------|-------------|
203
+ | `OrganizationConfig/Update@1` | Update an existing organization configuration record with dynamic values |
204
+
205
+ ```yaml
206
+ - task: "OrganizationConfig/Update@1"
207
+ name: UpdateOrgConfig
208
+ inputs:
209
+ organizationId: "{{ int inputs.organizationId }}"
210
+ organizationConfigId: "{{ int vars.config.organizationConfigId }}"
211
+ organizationConfig:
212
+ values:
213
+ enabled: true
214
+ outputs:
215
+ - name: result
216
+ mapping: "result?"
217
+ ```
218
+
219
+ **Inputs:** `organizationId` (int, required), `organizationConfigId` (int, required), `organizationConfig` (object, required dynamic values). Returns an empty object on success. Query by config name first if you do not already have the ID.
220
+
152
221
  ## Contact Address
153
222
 
154
223
  | Task | Description |
@@ -156,7 +225,32 @@ Imports order data from an external feed. Supports create and upsert (match-by-f
156
225
  | `ContactAddress/Create` | Create address |
157
226
  | `ContactAddress/Update` | Update address |
158
227
  | `ContactAddress/Delete` | Delete address |
159
- | `ContactAddress/Import` | Bulk import addresses |
228
+ | `ContactAddress/Import` | Bulk import addresses; ID-first upsert when `ContactAddressId` or `contactAddressId` is present |
229
+
230
+ **Selective import fetch:** `ContactAddress/Import@1` matches rows by `ContactAddressId`/`contactAddressId` first, then by `matchByFields`. Rows are bound as dictionaries, so primary-key columns and custom inbound column names survive even when there is no DTO property. When `matchByFields` is set, the importer builds batched Lucene filters from the incoming row values and fetches only candidate `AddressType.Other` addresses for the target contact. If `matchByFields` is omitted, it falls back to loading all `Other` addresses for that contact. Use stable fields such as `AddressLine`, `CityName`, `CountryCode`, `PostalCode`, and `StateCode` for large imports.
231
+
232
+
233
+ ### ContactAddress/Import@1
234
+
235
+ Bulk imports contact addresses for one `organizationId` + `contactId` from file/stream/import data. The import reads rows as dictionaries, so column mappings and aliases are supported.
236
+
237
+ ```yaml
238
+ - task: "ContactAddress/Import@1"
239
+ name: ImportContactAddresses
240
+ inputs:
241
+ organizationId: "{{ int inputs.organizationId }}"
242
+ contactId: "{{ int inputs.contactId }}"
243
+ fileUrl: "{{ inputs.fileUrl }}"
244
+ fileType: "Csv"
245
+ matchByFields: [AddressLine, AddressLine2, CityName, CountryCode, PostalCode, StateCode]
246
+ columnMappings:
247
+ City: CityName
248
+ outputs:
249
+ - name: result
250
+ mapping: "result?"
251
+ ```
252
+
253
+ **Behavior:** rows with `ContactAddressId` or `contactAddressId` match existing addresses by ID first; otherwise `matchByFields` is used. New rows default to `AddressType.Other`. `City` aliases to `CityName`; `StateCode` is used directly or `StateName` is resolved. `Longitude` + `Latitude` set location. Rows missing country or state context are skipped.
160
254
 
161
255
  ## Contact Payment Method
162
256
 
@@ -172,7 +266,7 @@ Imports order data from an external feed. Supports create and upsert (match-by-f
172
266
  | Task | Description |
173
267
  |------|-------------|
174
268
  | `Commodity/Create` | Create commodity |
175
- | `Commodity/Update` (v1, v2) | Update commodity |
269
+ | `Commodity/Update` (v1, v2) | Update commodity; supports scan custom-value cascade flags `cascadeToChildren` and `promoteParentWhenAllChildren` |
176
270
  | `Commodity/Split` | Split commodity into multiple |
177
271
  | `Commodity/Repack` | Repack commodities |
178
272
  | `Commodity/Unpack` | Unpack commodity |
@@ -298,8 +392,8 @@ Imports order data from an external feed. Supports create and upsert (match-by-f
298
392
  | `Country/Create`, `Country/Update`, `Country/Delete` | Country CRUD |
299
393
  | `Cities/Import` | Import cities |
300
394
  | `Rate/Update` | Update rate |
301
- | `TrackingEvent/Create` | Create a single tracking event and link to an order, a commodity, or multiple commodities |
302
- | `TrackingEvent/Import` | Batch import tracking events into an order |
395
+ | `TrackingEvent/Create` | Create a single tracking event and link to an order, commodity, dispatch route, or dispatch route stop |
396
+ | `TrackingEvent/Import` | Batch import tracking events into exactly one order, dispatch route, or dispatch route stop |
303
397
 
304
398
  ```yaml
305
399
  # Create a single tracking event linked to a commodity (or a list of commodities)
@@ -319,6 +413,8 @@ Imports order data from an external feed. Supports create and upsert (match-by-f
319
413
 
320
414
  Use [`OrderTrackingEvent/Create@1`](#order-sub-entities) when you want per-task overrides of the commodity auto-link behavior (`autoLinkToCommodities`, `commodityIds`). `TrackingEvent/Create@1` only honors the org-wide `tms.trackingEvents.autoLinkToCommodities` default for the auto-link step.
321
415
 
416
+ For dispatch milestones, pass `dispatchRouteId` or `dispatchRouteStopId` to `TrackingEvent/Create@1`. For `TrackingEvent/Import@1`, provide exactly one of `orderId`, `dispatchRouteId`, or `dispatchRouteStopId`; route and stop imports do not auto-link commodities.
417
+
322
418
  ```yaml
323
419
  # Batch import tracking events
324
420
  - task: "TrackingEvent/Import@1"
@@ -5,10 +5,12 @@
5
5
  - Operators (comparison, logical, arithmetic, ternary, membership)
6
6
  - Iterator variables (`[each.*]` and `[item.*]`)
7
7
  - Collection functions (any, all, count, sum, first, last, distinct, select, zip, groupBy, join, sort, etc.)
8
- - String functions (isNullOrEmpty, length, lower, upper, replace, format, base64, coalesce, etc.)
9
- - Date functions (now, parseDate, addDays, formatDate, dateFromUnix, etc.)
8
+ - String functions (isNullOrEmpty, length, lower, upper, replace, format, base64, fromJson, regex, coalesce, etc.)
9
+ - Date functions (now, parseDate, addDays, addHours, dateDiff, formatDate, dateFromUnix, dateToUtc, toLocalTime)
10
10
  - Math functions (Abs, Ceiling, Floor, Round, Min, Max, etc.)
11
11
  - Domain functions (convertWeight, convertDimension)
12
+ - Variable resolution details
13
+ - Pitfalls (empty string null trap)
12
14
 
13
15
  For template expressions `{{ path }}` used in step inputs, see [ref-expressions-template.md](ref-expressions-template.md).
14
16
 
@@ -85,6 +87,8 @@ Functions use two iterator variable names:
85
87
  | `trim([value])` | Trim whitespace. Returns `""` for null |
86
88
  | `format('{0}-{1}', [prefix], [id])` | String.Format style. Variadic args. Returns null if format is null |
87
89
  | `base64([value])` / `fromBase64([encoded])` | Base64 encode/decode. Handles string, byte[], JToken |
90
+ | `fromJson([str])` | Parse JSON string to Dictionary or JArray. Returns `null` for null input |
91
+ | `regex([str], 'pattern')` | Regex match. Returns match result |
88
92
  | `bool([value])` | Convert to boolean: null->`false`, empty string->`false`, "true"/"false"->parsed, non-zero number->`true`, any object->`true` |
89
93
  | `transliterate([value])` | Unicode to ASCII (Unidecode). Returns `""` for null |
90
94
  | `transliterateUa([value])` | Ukrainian-specific transliteration. Returns `""` for null |
@@ -101,6 +105,7 @@ Functions use two iterator variable names:
101
105
  | `now('yyyy-MM-dd', 'en-US')` | Formatted current time as string |
102
106
  | `addDays([date], 30)` | Add days (decimal, can be negative). Accepts DateTime, DateTimeOffset, string |
103
107
  | `addHours([date], 2)` | Add hours (decimal, can be negative). Same type handling |
108
+ | `dateDiff([start], [end], 'unit')` | Difference between two dates in specified unit |
104
109
  | `formatDate([date], 'dd/MM/yyyy')` | Format date using InvariantCulture (culture optional). Returns `null` for null or unparseable input. Accepts `DateTime`, `DateTimeOffset`, or string (including `DateTimeOffset`-shaped strings from `toLocalTime`) |
105
110
  | `formatDate([date], 'dd/MM/yyyy', 'en-US')` | Format date with explicit culture |
106
111
  | `dateFromUnix([unixTime])` | Unix timestamp (seconds) -> `DateTimeOffset`. Accepts int, long, decimal, string |
@@ -135,3 +140,31 @@ Custom: `ceiling([value])` -- same as `Ceiling` but handles type conversion to d
135
140
  |----------|-------------|
136
141
  | `convertWeight([weight], 'Kg', 'Lb')` | Weight unit conversion. Returns `decimal` rounded to 5 places |
137
142
  | `convertDimension([length], 'Cm', 'In')` | Dimension unit conversion. Returns `decimal` rounded to 3 places |
143
+
144
+ ---
145
+
146
+ ## Variable Resolution Details
147
+
148
+ - `[variableName]` → workflow variable dictionary lookup via `GetPropertyValue()`
149
+ - `[order.status]` → dot-path traversal through nested dictionaries/reflection
150
+ - Without `?` → throws `InvalidOperationException` on missing path
151
+ - With `?` → returns null instead of throwing
152
+ - Empty string → null conversion (see Pitfalls below)
153
+ - `FormatException` → retries with numeric string conversion (`"42"` → `42m`)
154
+
155
+ ## Pitfalls
156
+
157
+ ### Empty String Null Trap
158
+
159
+ The NCalc engine silently converts `""` (empty string) to `null` **before** evaluating expressions. This happens in `EvaluateParameter` — it uses `string.IsNullOrEmpty()` (not `IsNullOrWhiteSpace`), so whitespace-only strings like `" "` pass through as-is.
160
+
161
+ **Consequence**: `[myVar] != ''` is **always true** when the variable is empty, because the empty string becomes `null` and `null != ''` evaluates to `true`.
162
+
163
+ **Rule**: Never compare against empty string. Always use `isNullOrEmpty()` instead:
164
+ ```yaml
165
+ # WRONG — always true when myVar is empty
166
+ expression: "[myVar] != ''"
167
+
168
+ # CORRECT
169
+ expression: "isNullOrEmpty([myVar]) = false"
170
+ ```
@@ -24,6 +24,8 @@ inputs:
24
24
 
25
25
  **Date string normalization in step inputs**: When a string value is merged into a step's input dictionary (via `AddRangeN`), the engine auto-detects date/datetime strings and converts them to ISO format. Common formats recognized: ISO (`2024-03-15`), US (`03/15/2024`), EU (`25/12/2024`), datetime with offset (`2024-03-15T15:30:45+05:00` → UTC). Empty strings are converted to `null`. OLE Automation date numbers (e.g., `"45752"`) are also recognized as dates — but only when the field name includes a date/time keyword (e.g., `departureDate`, `pickupTime`). A numeric string like `"45752"` for a field named `amount` is kept as-is.
26
26
 
27
+ **DateTime assignment in dynamic entity updates**: Values assigned to `DateTime` or nullable `DateTime` entity fields are normalized to UTC before persistence. Offset-bearing strings and local/offset values are shifted to the equivalent UTC instant. Zone-less strings and `DateTime` values with an unspecified kind are assumed UTC without changing their wall-clock value. Empty strings become `null` only for nullable fields; use ISO 8601 with `Z` or an explicit offset whenever the source represents a known instant.
28
+
27
29
  ### Type Converters (prefix in {{ }})
28
30
 
29
31
  ```yaml
@@ -180,9 +182,10 @@ Used in `collection:` (foreach), `mapping:` (outputs), and variable resolution.
180
182
  | `list[*]` | Flatten/wildcard (all items) | `containers[*].commodities` |
181
183
  | `list[*].dictKey` | Wildcard traversal into Dictionary/JObject keys | `items[*].customValues.chapter_en` |
182
184
  | `list[**]` | Recursive flatten (all depths) | `containerCommodities[**]` |
183
- | `list[-1]` | Depth filter (leaves only) | `tree[**][-1]` |
185
+ | `list[-1]` | Tree-depth filter from end (meaningful after `[**]` recursive flatten) | `tree[**][-1]` |
184
186
  | `list[condition]` | Filter by condition. LHS supports dotted nested paths | `items[status=Active]`, `activity[status.type=D]` |
185
187
  | `dict['key']` | Dictionary key access | `customValues['myField']` |
188
+ | `dict[variableName]` | Dynamic key — resolves variableName as property path, uses result as dict key | `customValues[mode]` |
186
189
  | `list[*].{f1 f2}` | Field selector (projection) | `items[*].{name description}` |
187
190
  | `list[*].{alias:source}` | Field selector with alias | `items[*].{id:commodityId}` |
188
191
  | `list[*].{alias:_.parent}` | Field selector referencing parent | `items[*].{parentId:_.orderId}` |
@@ -190,3 +193,14 @@ Used in `collection:` (foreach), `mapping:` (outputs), and variable resolution.
190
193
  **Wildcard traversal into Dictionary/JObject**: After `[*]`, subsequent path segments drill into Dictionary keys and JObject properties on each item. Dictionary-like values are preserved intact (not flattened) so multi-hop paths work: `items[*].customValues.chapter_en` extracts the `chapter_en` key from each item's `customValues` dictionary. This also works with nested dictionaries (`items[*].meta.locale.name`) and JObject items from JSON payloads.
191
194
 
192
195
  **JArray primitive unwrapping**: When `GetPropertyValue` encounters a JArray where every element is a JValue (primitive), it automatically unwraps the array into a `List<object>` of plain .NET values. This ensures downstream iteration (e.g., `select()`, `zip()`, `foreach`) works with primitives rather than JValue wrappers.
196
+
197
+ ### Sub-expressions
198
+
199
+ Parenthesized `(variableName)` inside filter brackets resolves the inner variable first, then uses its value as the filter match target:
200
+
201
+ ```yaml
202
+ # selected_status resolves to e.g. "Active", then items is filtered where status matches
203
+ filteredName: "{{ order.items.[status=(selected_status)].[0].name }}"
204
+ ```
205
+
206
+ The inner variable `(selected_status)` is resolved through the same property path engine before the filter expression is evaluated. This enables dynamic filtering where the match value comes from a workflow variable rather than a literal.