@cxtms/cx-schema 1.9.258 → 1.9.260

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 (34) hide show
  1. package/dist/cli.js +2 -0
  2. package/dist/cli.js.map +1 -1
  3. package/dist/types.d.ts +1 -1
  4. package/dist/types.d.ts.map +1 -1
  5. package/dist/workflowValidator.d.ts +7 -0
  6. package/dist/workflowValidator.d.ts.map +1 -1
  7. package/dist/workflowValidator.js +54 -0
  8. package/dist/workflowValidator.js.map +1 -1
  9. package/package.json +1 -1
  10. package/schemas/actions/sound.json +4 -3
  11. package/schemas/actions/vibrate.json +6 -4
  12. package/schemas/components/README.md +1 -0
  13. package/schemas/components/avatar.json +117 -1
  14. package/schemas/components/infoLine.json +82 -1
  15. package/schemas/components/planner.json +369 -1
  16. package/schemas/components/progressBar.json +115 -1
  17. package/schemas/components/timeline.json +24 -0
  18. package/schemas/fields/attachment.json +35 -2
  19. package/schemas/workflows/agent/agent.json +98 -0
  20. package/schemas/workflows/tasks/attachment.json +58 -2
  21. package/schemas/workflows/workflow.json +14 -19
  22. package/skills/cxtms-developer/ref-entity-notification.md +2 -2
  23. package/skills/cxtms-developer/ref-entity-shared.md +6 -2
  24. package/skills/cxtms-module-builder/SKILL.md +83 -5
  25. package/skills/cxtms-module-builder/ref-components-data.md +1 -1
  26. package/skills/cxtms-module-builder/ref-components-display.md +98 -15
  27. package/skills/cxtms-module-builder/ref-components-forms.md +27 -1
  28. package/skills/cxtms-module-builder/ref-components-interactive.md +3 -3
  29. package/skills/cxtms-module-builder/ref-components-layout.md +1 -1
  30. package/skills/cxtms-module-builder/ref-components-specialized.md +3 -0
  31. package/skills/cxtms-workflow-builder/SKILL.md +3 -1
  32. package/skills/cxtms-workflow-builder/ref-agent.md +283 -0
  33. package/skills/cxtms-workflow-builder/ref-communication.md +37 -10
  34. package/templates/workflow-agent.yaml +48 -0
@@ -0,0 +1,98 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "agent/agent.json",
4
+ "title": "Agent section",
5
+ "description": "Configuration for workflowType: Agent. An LLM agent that calls other workflows as tools.",
6
+ "type": "object",
7
+ "required": ["instructions"],
8
+ "properties": {
9
+ "description": { "type": "string", "description": "What this agent does. Shown to callers and to other agents that may invoke it." },
10
+ "ui": {
11
+ "type": "object",
12
+ "description": "How the AI Assistant chat shows this agent. Display-only; no runtime effect.",
13
+ "properties": {
14
+ "name": { "type": "string", "description": "Display name. Defaults to the workflow name." },
15
+ "shortDescription": { "type": "string", "description": "One line under the name, e.g. 'Status, ETAs, exceptions, POD'." },
16
+ "icon": { "type": "string", "description": "Tabler icon name without the 'tabler-' prefix, e.g. 'map-pin'. Defaults to 'robot'." },
17
+ "color": { "type": "string", "enum": ["primary", "secondary", "info", "success", "warning", "error"], "description": "Theme palette color for the agent's icon. Defaults to 'primary'." },
18
+ "prompts": { "type": "array", "items": { "type": "string" }, "maxItems": 5, "description": "Suggested prompts shown on an empty chat. At most 5." }
19
+ },
20
+ "additionalProperties": false
21
+ },
22
+ "instructions": { "type": "string", "minLength": 1, "description": "System instructions. Handlebars template over workflow variables and inputs." },
23
+ "model": {
24
+ "type": "object",
25
+ "properties": {
26
+ "fromConfig": { "type": "string", "description": "Organization config name holding provider, model, apiKey, endpoint. Default ai.default." },
27
+ "name": { "type": "string", "description": "Overrides the config's model name." },
28
+ "temperature": { "type": "number", "minimum": 0, "maximum": 2 },
29
+ "contextWindow": { "type": "integer", "minimum": 1, "description": "The model's context window in tokens. Needed when this agent overrides the model name via model.name: the organization config's window describes the config's model, not this one. Defaults to 256000 when neither the agent nor the resolved model configuration declares one." }
30
+ },
31
+ "additionalProperties": false
32
+ },
33
+ "session": {
34
+ "type": "object",
35
+ "properties": {
36
+ "type": { "type": "string", "enum": ["task", "chat"], "default": "task", "description": "task ends when set_result is called; chat is open-ended." },
37
+ "maxTurns": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 },
38
+ "timeout": { "type": "integer", "minimum": 1, "default": 300, "description": "Seconds." }
39
+ },
40
+ "additionalProperties": false
41
+ },
42
+ "result": {
43
+ "type": "object",
44
+ "description": "JSON Schema (type: object) for the set_result argument.",
45
+ "properties": { "type": { "const": "object" } },
46
+ "required": ["type"]
47
+ },
48
+ "skills": { "type": "array", "items": { "type": "string" }, "description": "Installed skill names to enable (runtime in a later release)." },
49
+ "tools": {
50
+ "type": "array",
51
+ "description": "Tools the agent may call: other workflows, or built-in tools. Each entry has exactly one of 'workflow' or 'builtin'.",
52
+ "items": {
53
+ "oneOf": [
54
+ {
55
+ "type": "object",
56
+ "required": ["workflow"],
57
+ "properties": {
58
+ "workflow": { "type": "string", "minLength": 1, "description": "Workflow name or workflowId to expose as a tool." },
59
+ "instructions": { "type": "string", "description": "When and how the agent should use this tool." },
60
+ "mode": { "type": "string", "enum": ["auto", "approval", "always"], "default": "auto", "description": "'auto' runs the tool as soon as the model calls it. 'approval' pauses a chat session until a person approves or declines the call, unless the chat is in Auto approval mode, where it runs and is recorded as auto-approved. 'always' pauses a chat session for a person in every approval mode. A task session (no person present) refuses 'approval' and 'always' calls outright and tells the agent it needs human approval." }
61
+ },
62
+ "additionalProperties": false
63
+ },
64
+ {
65
+ "type": "object",
66
+ "required": ["builtin"],
67
+ "properties": {
68
+ "builtin": { "type": "string", "enum": ["data.query", "data.schema", "data.type"], "description": "A built-in, read-only data tool. data.query runs a GraphQL query in the agent's organization (tool data_query); data.schema lists query fields and types (data_schema); data.type describes one type (data_type)." },
69
+ "instructions": { "type": "string", "description": "When and how the agent should use this tool." },
70
+ "mode": { "type": "string", "enum": ["auto", "approval", "always"], "default": "auto", "description": "Same meaning as for workflow tools." }
71
+ },
72
+ "additionalProperties": false
73
+ }
74
+ ]
75
+ }
76
+ },
77
+ "mcp": {
78
+ "type": "array",
79
+ "items": { "type": "object", "required": ["fromConfig"], "properties": { "fromConfig": { "type": "string" } }, "additionalProperties": false },
80
+ "description": "Outbound MCP connections (runtime in a later release)."
81
+ },
82
+ "agents": {
83
+ "type": "array",
84
+ "items": {
85
+ "type": "object",
86
+ "properties": {
87
+ "agent": { "type": "string", "description": "Name of another Agent workflow in this organization." },
88
+ "url": { "type": "string", "format": "uri", "description": "Remote A2A agent card URL." },
89
+ "modes": { "type": "array", "items": { "type": "string", "enum": ["task", "chat"] } }
90
+ },
91
+ "oneOf": [{ "required": ["agent"] }, { "required": ["url"] }],
92
+ "additionalProperties": false
93
+ },
94
+ "description": "Allow list of agents this agent may invoke (runtime in a later release)."
95
+ }
96
+ },
97
+ "additionalProperties": false
98
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "title": "Attachment Tasks",
4
- "description": "Attachment entity CRUD operations",
4
+ "description": "Attachment entity CRUD operations and entity links",
5
5
  "type": "object",
6
6
  "properties": {
7
7
  "task": {
@@ -9,6 +9,8 @@
9
9
  "enum": [
10
10
  "Attachment/Create@1",
11
11
  "Attachment/Update@1",
12
+ "Attachment/Link@1",
13
+ "Attachment/Unlink@1",
12
14
  "Attachment/Delete@1",
13
15
  "Attachment/Get@1",
14
16
  "Attachment/Thumbnail",
@@ -48,13 +50,67 @@
48
50
  "type": "string",
49
51
  "description": "Attachment ID"
50
52
  },
53
+ "attachment": {
54
+ "type": "object",
55
+ "description": "Attachment values (Attachment/Create@1, Attachment/Update@1)",
56
+ "properties": {
57
+ "fileName": {
58
+ "type": "string"
59
+ },
60
+ "attachmentType": {
61
+ "type": "string",
62
+ "description": "Picture, OtherDocument, Avatar, CustomerDocument"
63
+ },
64
+ "description": {
65
+ "type": "string"
66
+ },
67
+ "parentType": {
68
+ "type": "string",
69
+ "description": "Primary parent type: Order, Contact, AccountingTransaction, EquipmentType, Job, Commodity, Route, TrackingEvent"
70
+ },
71
+ "parentId": {
72
+ "type": "string",
73
+ "description": "Primary parent ID"
74
+ },
75
+ "links": {
76
+ "type": "array",
77
+ "description": "Additional entity links on create (Attachment/Create@1)",
78
+ "items": {
79
+ "type": "object",
80
+ "properties": {
81
+ "entityType": {
82
+ "type": "string",
83
+ "description": "Linked entity type: Order, Contact, Job, TrackingEvent"
84
+ },
85
+ "entityId": {
86
+ "type": "string",
87
+ "description": "Linked entity ID (int id; uuid for Job)"
88
+ }
89
+ },
90
+ "required": ["entityType", "entityId"]
91
+ }
92
+ }
93
+ },
94
+ "additionalProperties": true
95
+ },
96
+ "fileData": {
97
+ "description": "File content: base64 string, byte array or stream (Attachment/Create@1)"
98
+ },
99
+ "fileUrl": {
100
+ "type": "string",
101
+ "description": "URL to download the file from (Attachment/Create@1)"
102
+ },
51
103
  "entityName": {
52
104
  "type": "string",
53
105
  "description": "Parent entity name"
54
106
  },
107
+ "entityType": {
108
+ "type": "string",
109
+ "description": "Entity type to link/unlink: Order, Contact, Job, TrackingEvent (Attachment/Link@1, Attachment/Unlink@1)"
110
+ },
55
111
  "entityId": {
56
112
  "type": "string",
57
- "description": "Parent entity ID"
113
+ "description": "Entity ID to link/unlink: int id, uuid for Job (Attachment/Link@1, Attachment/Unlink@1)"
58
114
  },
59
115
  "fileName": {
60
116
  "type": "string",
@@ -67,8 +67,8 @@
67
67
  },
68
68
  "workflowType": {
69
69
  "type": "string",
70
- "enum": ["Document", "Quote", "Flow", "Webhook", "PublicApi", "McpTool", "McpResource", "McpPrompt"],
71
- "description": "Workflow type, including organization-scoped MCP tools, resources, and prompts. Omit for standard process workflows."
70
+ "enum": ["Process", "Document", "Quote", "Flow", "Webhook", "PublicApi", "McpTool", "McpResource", "McpPrompt", "Agent", "EmailTemplate", "RulesTariff"],
71
+ "description": "Workflow type: Process (standard workflow), Document (PDF/Excel generation), Quote, Flow (declarative state machine), Webhook (HTTP endpoint), PublicApi (REST API endpoint), McpTool, McpResource, McpPrompt (organization-scoped MCP tools, resources and prompts), Agent (LLM agent), EmailTemplate or RulesTariff. Omit for standard process workflows."
72
72
  },
73
73
  "runAs": {
74
74
  "type": "string",
@@ -279,6 +279,7 @@
279
279
  },
280
280
  "additionalProperties": false
281
281
  },
282
+ "agent": { "$ref": "agent/agent.json" },
282
283
  "entity": {
283
284
  "$ref": "flow/entity.json",
284
285
  "description": "Entity configuration for Flow workflows"
@@ -308,24 +309,18 @@
308
309
  "required": ["workflow"],
309
310
  "allOf": [
310
311
  {
311
- "if": {
312
- "properties": {
313
- "workflow": {
314
- "properties": {
315
- "workflowType": { "const": "Flow" }
316
- },
317
- "required": ["workflowType"]
318
- }
319
- }
320
- },
321
- "then": {
322
- "required": ["workflow", "entity"],
323
- "properties": {
324
- "activities": false
325
- }
326
- },
312
+ "if": { "properties": { "workflow": { "properties": { "workflowType": { "const": "Flow" } }, "required": ["workflowType"] } } },
313
+ "then": { "required": ["workflow", "entity"], "properties": { "activities": false } },
327
314
  "else": {
328
- "required": ["workflow", "activities"]
315
+ "if": { "properties": { "workflow": { "properties": { "workflowType": { "const": "Agent" } }, "required": ["workflowType"] } } },
316
+ "then": {
317
+ "required": ["workflow", "agent"],
318
+ "properties": {
319
+ "activities": false,
320
+ "workflow": { "properties": { "executionMode": { "const": "Sync" } }, "required": ["executionMode"] }
321
+ }
322
+ },
323
+ "else": { "required": ["workflow", "activities"] }
329
324
  }
330
325
  },
331
326
  {
@@ -13,8 +13,8 @@ Real-time notification system. Notifications are org-scoped with per-user read t
13
13
  | `type` | `NotificationType` | System=0, OrderUpdate=1, TaskAssignment=2, Alert=3, Info=4 |
14
14
  | `priority` | `NotificationPriority` | Low=0, Normal=1, High=2, Urgent=3 |
15
15
  | `targetUserId` | `string?` | If set, targets one user; if null, broadcasts to all active org users |
16
- | `entityType` | `string?` | Linked entity type (e.g. "Order", "Job") |
17
- | `entityId` | `int?` | Linked entity PK |
16
+ | `entityType` | `string?` | Linked entity type (e.g. "Order", "Job", "AgentSession") |
17
+ | `entityId` | `string?` | Linked entity id (varchar(64)). Most entity types still store an integer PK as text; `entityType: "AgentSession"` carries the session's GUID (`AgentSessionId.ToString()`) instead. |
18
18
  | `expiresAt` | `DateTime?` | Optional expiration |
19
19
  | `created` / `lastModified` | `DateTime` | Audit fields (from `AuditableEntity`) |
20
20
  | `createdBy` / `lastModifiedBy` | `string` | Audit fields |
@@ -77,7 +77,7 @@ Tagging system for orders, commodities, inventory items.
77
77
 
78
78
  ## Attachment
79
79
 
80
- File attachments linked to orders, contacts, jobs, etc.
80
+ File attachments. `parentType`/`parentId` is the primary parent; an attachment can also be linked to more Orders, Contacts, Jobs and TrackingEvents (link tables).
81
81
 
82
82
  | Field | Type | Notes |
83
83
  |-------|------|-------|
@@ -91,7 +91,7 @@ File attachments linked to orders, contacts, jobs, etc.
91
91
  | `description` | `string?` | |
92
92
  | `attachmentType` | `AttachmentType` enum | Picture=1, OtherDocument, Avatar, CustomerDocument |
93
93
  | `parentId` | `string?` | Polymorphic FK (entity ID as string) |
94
- | `parentType` | `AttachmentParentType` enum | None=0, Order=1, Contact=2, AccountingTransaction=3, EquipmentType=4, Job=5, Commodity=6 |
94
+ | `parentType` | `AttachmentParentType` enum | None=0, Order=1, Contact=2, AccountingTransaction=3, EquipmentType=4, Job=5, Commodity=6, Route=7, TrackingEvent=8 |
95
95
  | `status` | `AttachmentStatus` enum | Active=0, PendingUpload=1, UploadFailed=2 |
96
96
  | `category` | `AttachmentCategory` enum | General=0, FieldValue=1 |
97
97
  | `organizationId` | `int` | |
@@ -103,6 +103,10 @@ File attachments linked to orders, contacts, jobs, etc.
103
103
  - `presignedFileUri`, `presignedPreviewUri`, `presignedThumbnailUri` — signed URLs
104
104
  - `getPresignedUri(expiresInDays, uriType)` — custom resolver
105
105
  - `getParentOrder` — resolve parent Order
106
+ - `links { entityType entityId }` — all links, including the primary one
107
+ - Filter by link: `attachments(filter: "orderLinks.orderId:123")` — also `contactLinks.contactId`, `jobLinks.jobId`, `trackingEventLinks.trackingEventId`
108
+ - `job { getJobAttachments }`, `trackingEvent { getTrackingEventAttachments }` — attachments linked to the entity
109
+ - Mutations: `linkAttachment` / `unlinkAttachment` (`attachmentId`, `entityType`, `entityId`); `createAttachment` `values.links: [{ entityType, entityId }]`. Workflows: `Attachment/Link@1`, `Attachment/Unlink@1`
106
110
 
107
111
  ---
108
112
 
@@ -315,8 +315,9 @@ onClick:
315
315
  - consoleLog: { message: "debug info" }
316
316
  - openBarcodeScanner: { onScan: [...] }
317
317
  - resetDirtyState: {}
318
- - sound: success # success|error|warning|scan
319
- - vibrate: light # success|error|warning|light|medium|heavy
318
+ - sound: success # audible cue: success|error|warning|scan (or { type, volume: 0-1 })
319
+ - vibrate: success # haptic: success|error|warning|light|medium|heavy
320
+ - vibrate: { pattern: [100, 50, 100] } # custom ms pattern (web Vibration API convention) or { duration: 200 }
320
321
  ```
321
322
 
322
323
  ## Common Patterns
@@ -334,10 +335,87 @@ value: "{{ fieldName }}" # Simple variable
334
335
  value: "{{ format date L }}" # Format helper
335
336
  value: "{{ number quantity }}" # Type cast
336
337
  value: "{{ sessionStorage isDevMode }}" # Current-tab sessionStorage
337
- value: "{{ eval items.length > 0 }}" # JavaScript expression
338
- isHidden: "{{ eval !canEdit }}" # Conditional visibility
338
+ isHidden: "{{ !canEdit }}" # Negation — no eval needed
339
+ value: "{{ eval items.map(x => x.id) }}" # JavaScript eval — last resort
339
340
  ```
340
341
 
342
+ **Prefer built-in template functions over `eval`.** The engine ships named functions for
343
+ the common cases — use them first. Reach for `eval` only when nothing below fits
344
+ (array `.map()`/`.filter()`, arithmetic, template literals, object building, ternaries).
345
+
346
+ | Function | Example | Notes |
347
+ |----------|---------|-------|
348
+ | `number` / `number!` | `{{ number quantity }}` | Cast to number; `number!` returns `0` for null |
349
+ | `string` / `boolean` | `{{ boolean isActive }}` | Cast; `boolean` treats string `'true'` as true |
350
+ | `nullIfEmpty` | `{{ nullIfEmpty notes }}` | `''`, `0`, undefined → null |
351
+ | `luceneString` | `{{ luceneString search }}` | Escape for Lucene filter strings |
352
+ | `isEqual` | `{{ isEqual status 'Pending' }}` | Deep equality; numeric strings coerced |
353
+ | `isTrue` | `{{ isTrue customValues.flag }}` | `true`, `'true'`, or truthy |
354
+ | `isNullOrEmpty` | `{{ isNullOrEmpty orderNumber }}` | null, undefined, `''`, or empty array |
355
+ | `any` | `{{ any selectedItems }}` | Array/string has at least one element |
356
+ | `moreThan` / `lessThan` | `{{ moreThan totalAmount 100 }}` | Numbers by value; arrays/strings by length |
357
+ | `startsWith` / `endsWith` | `{{ startsWith trackingNumber 'TRK' }}` | String prefix/suffix check |
358
+ | `includes` / `contains` | `{{ includes description 'rush' }}` | Substring check (aliases) |
359
+ | `trim` | `{{ trim name }}` | Trim whitespace |
360
+ | `round` | `{{ round totalAmount 2 }}` | `toFixed(n)` returned as number |
361
+ | `encodeURIComponent` | `{{ encodeURIComponent returnUrl }}` | URL-encode |
362
+ | `format` | `{{ format createdDate L }}` | moment format (`L`, `MM/DD/YYYY`, `fromNow`, …) |
363
+ | `formatTz` | `{{ formatTz pickupDate 'MM/DD h:mm A' 'America/New_York' }}` | Format UTC value in a timezone |
364
+ | `dateDiff` | `{{ dateDiff dueDate startDate }}` | Whole days, first minus second |
365
+ | `daysBetween` | `{{ daysBetween pickupDate deliveryDate }}` | Absolute day difference |
366
+ | `daysUntil` / `daysAgo` | `{{ daysUntil dueDate }}` | Days between now and the date |
367
+ | `isDateBefore` / `isDateAfter` | `{{ isDateBefore pickupDate deliveryDate }}` | Date comparison |
368
+ | `hasPermission` | `{{ hasPermission Orders/Update }}` | Current user permission check |
369
+ | `fromConfig` | `{{ fromConfig apps.myFeature key }}` | Organization config lookup |
370
+ | `localStorage` / `sessionStorage` | `{{ localStorage recentSearch }}` | Browser storage read |
371
+ | `parse` | `{{ parse configs.labelTemplate }}` | Re-parse a template stored in a variable |
372
+
373
+ Combinators — also no `eval` needed:
374
+ - `!` / `!!` prefix negates / booleanizes any path or function: `{{ !isEqual status 'Pending' }}`, `{{ !!order.notes }}`
375
+ - `||` / `&&` combine paths and comparisons: `{{ orderId || quoteId }}`, `{{ !pickupForm.contactAddressId || !pickupForm.pickupDate }}`
376
+ - `?.` optional chaining inside paths: `{{ order?.customer?.name }}`
377
+ - Quoted arguments are string literals; parenthesized arguments evaluate as nested expressions
378
+
379
+ ```yaml
380
+ # ❌ eval where a built-in exists
381
+ disabled: "{{ eval status !== 'Pending' }}"
382
+ isHidden: "{{ eval selectedItems.length === 0 }}"
383
+ # ✅ built-in equivalents
384
+ disabled: "{{ !isEqual status 'Pending' }}"
385
+ isHidden: "{{ !any selectedItems }}"
386
+ ```
387
+
388
+ **When `eval` is genuinely needed, keep it human-readable.** Inline form is only for
389
+ trivial expressions. Anything longer (ternaries, method chains, or ~60+ characters)
390
+ MUST be written as a multiline YAML block scalar:
391
+
392
+ ```yaml
393
+ # Multiline eval — block scalar, one clause per line
394
+ color: >-
395
+ {{ eval
396
+ item.status === 'delayed' ? '#e53e3e'
397
+ : item.priority === 'high' ? '#ffa726'
398
+ : '#4ecdc4'
399
+ }}
400
+ label: >-
401
+ {{ eval
402
+ attachments.items
403
+ .map(x => x.fileName)
404
+ .join(', ')
405
+ }}
406
+ ```
407
+
408
+ Multiline eval rules:
409
+ - **Always use `>-` (folded, strip)** — never `>`, `|`, or `|+`. Those keep a trailing
410
+ newline, so the value no longer ends with `}}` and the engine silently falls back to
411
+ string interpolation instead of evaluating the expression.
412
+ - The block must contain exactly one `{{ eval ... }}` and nothing else — surrounding text
413
+ or a second `{{ }}` also switches the engine to string interpolation.
414
+ - Block scalars need no YAML quoting or escaping — use natural JavaScript quotes
415
+ (`'` or `"`) inside the expression.
416
+ - Put `{{ eval` on the first line, one clause per indented line, and `}}` on its own
417
+ closing line.
418
+
341
419
  ### Permissions
342
420
  ```yaml
343
421
  permission: "ModuleName/Read" # Single string (PascalCase/Action)
@@ -474,7 +552,7 @@ npx cxtms app release -m "Add warehouse locations module" --org 42
474
552
  - Component names: Module/Component pattern (e.g., `WarehouseLocations/List`)
475
553
  - Route paths: kebab-case (e.g., `/warehouse-locations`)
476
554
  - Permission names: PascalCase with slashes (e.g., `WarehouseLocations/Read`, `System/Contacts/Update`)
477
- 4. **Template expressions** use `{{ expression }}` syntax (double curly braces)
555
+ 4. **Template expressions** use `{{ expression }}` syntax (double curly braces); prefer built-in functions (`isEqual`, `any`, `isNullOrEmpty`, `format`, …) over `eval`, and write non-trivial `eval` expressions as multiline block scalars (`>-`) — see Template expressions
478
556
  5. **Include filePath** property pointing to the YAML file location
479
557
  6. **Set proper entityKind** when defining entities (Order, Contact, OrderEntity, AccountingTransaction, Calendar, CalendarEvent, Other)
480
558
  7. **DataGrid options** requires ALL properties: query, rootEntityName, entityKeys, navigationType, enableDynamicGrid, enableViews, enableSearch, enablePagination, enableColumns, enableFilter, defaultView, onRowClick
@@ -314,7 +314,7 @@ props:
314
314
  - navigate: "orders/{{ item.id }}"
315
315
  - label: "Delete"
316
316
  icon: trash
317
- disabled: "{{ eval item.status === 'Completed' }}"
317
+ disabled: "{{ isEqual item.status 'Completed' }}"
318
318
  onClick:
319
319
  - confirm: { title: { en-US: "Delete?" } }
320
320
  onClick:
@@ -477,21 +477,6 @@ props:
477
477
 
478
478
  Compact theme-aware badge. Prefer semantic variants for new modules; the dot is opt-in.
479
479
 
480
- ## avatar, infoLine, and progressBar
481
-
482
- Use these template-aware display components for compact entity cards and planner headers.
483
-
484
- ```yaml
485
- - component: avatar
486
- props: { name: '{{ row.record.name }}', colorSeed: '{{ row.record.id }}', size: 40 }
487
- - component: infoLine
488
- props: { icon: tabler-phone, value: '{{ row.record.phone }}', href: 'tel:{{ row.record.phone }}' }
489
- - component: progressBar
490
- props: { items: '{{ row.items }}', completedPath: status, completedValue: Completed }
491
- ```
492
-
493
- `avatar` derives initials from names/email and uses `src` as an optional image. `infoLine` renders nothing for an empty value and only links allowlisted URL schemes. `progressBar` accepts direct `value`/`max` or derives counts from `items`; choose `count`/`percent` and `linear`/`circular`/`segmented` display modes. On mobile, `activeColor` and `trackColor` customize segmented progress; `tooltip` is exposed to assistive technology rather than hover UI.
494
-
495
480
  **Props:**
496
481
  | Prop | Type | Description |
497
482
  |------|------|-------------|
@@ -534,6 +519,104 @@ props:
534
519
 
535
520
  ---
536
521
 
522
+ ## avatar
523
+
524
+ Template-aware display component for compact entity cards and planner headers. Circular avatar with automatic two-letter initials. Sources tried in priority order: `src` image → `firstName` + `lastName` → `name` → `email` → placeholder user icon. Color is picked deterministically from the theme palette by hashing `colorSeed`.
525
+
526
+ **Props (all template-parsed):**
527
+ | Prop | Type | Description |
528
+ |------|------|-------------|
529
+ | `src` | `string` | Image URL; falls back to initials when empty or broken |
530
+ | `name` | `string` | Full name: `Andre Kovac` → AK, `Kovac, Andre` → AK, `Madonna` → MA |
531
+ | `firstName` / `lastName` | `string` | Explicit name parts (organization user form); take priority over `name` |
532
+ | `email` | `string` | Last text fallback: local part, digits dropped (`andre.kovac@…` → AK) |
533
+ | `colorSeed` | `string\|number` | Stable color seed — use an id (`contactId`, user id), not the name |
534
+ | `color` | `string` | Pin a theme color: `primary` \| `secondary` \| `error` \| `warning` \| `info` \| `success` |
535
+ | `bgcolor` / `textColor` | `string` | Explicit CSS color overrides |
536
+ | `size` | `number` | Diameter in px, default 40 |
537
+ | `variant` | `string` | `circular` (default) \| `rounded` \| `square` |
538
+ | `skin` | `string` | `light` (default, tinted bg) \| `filled` \| `light-static` |
539
+ | `tooltip` | `string\|localized` | Tooltip on hover |
540
+ | `onClick` | `Action[]` | Actions on click; when present the avatar is clickable |
541
+
542
+ ```yaml
543
+ component: avatar
544
+ name: driverLaneAvatar
545
+ props:
546
+ name: '{{ row.record.name }}'
547
+ colorSeed: '{{ row.record.contactId }}'
548
+ size: 40
549
+ ```
550
+
551
+ ---
552
+
553
+ ## infoLine
554
+
555
+ Compact "icon + value" line for entity cards (phone, email, truck, container…). Abstract: YAML assembles the value string, the component only renders it. **An empty value renders nothing**, so optional lines can be declared unconditionally.
556
+
557
+ **Props (all template-parsed):**
558
+ | Prop | Type | Description |
559
+ |------|------|-------------|
560
+ | `icon` | `string` | Icon class: `tabler-phone`, `tabler-mail`, `tabler-truck`, `tabler-container`, FA names |
561
+ | `iconColor` | `string` | Theme path or CSS color, default `text.secondary` |
562
+ | `value` | `string` | The text; assemble separators in YAML: `'{{ n }} • {{ size }}'`. Empty → not rendered |
563
+ | `href` | `string` | Renders the value as a link: `tel:{{ … }}`, `mailto:{{ … }}`, or a URL; only allowlisted URL schemes are linked |
564
+ | `onClick` | `Action[]` | Actions on click (alternative to href) |
565
+ | `truncate` | `boolean` | Ellipsis on overflow; tooltip defaults to the full value |
566
+ | `tooltip` | `string\|localized` | Explicit tooltip, overrides the truncate default |
567
+ | `variant` | `string` | `default` \| `chip` — bordered rounded container |
568
+
569
+ ```yaml
570
+ - component: infoLine
571
+ name: driverPhone
572
+ props:
573
+ icon: tabler-phone
574
+ value: '{{ row.record.phoneNumber }}'
575
+ href: 'tel:{{ row.record.phoneNumber }}'
576
+ - component: infoLine
577
+ name: containerChip
578
+ props:
579
+ icon: tabler-container
580
+ value: '{{ container.number }} • {{ container.size }}'
581
+ variant: chip
582
+ truncate: true
583
+ ```
584
+
585
+ ---
586
+
587
+ ## progressBar
588
+
589
+ Abstract progress bar. Feed it `value`/`max` directly, or an `items` array with a declarative completed predicate — what the numbers mean (moves, stops, hours…) is the module's business. **max 0 or missing → nothing renders**, so it can be declared unconditionally.
590
+
591
+ **Props (all template-parsed):**
592
+ | Prop | Type | Description |
593
+ |------|------|-------------|
594
+ | `value` | `number\|string` | Completed amount; overrides the derived count |
595
+ | `max` | `number\|string` | Total; defaults to `items.length` when `items` is set |
596
+ | `items` | `string` | Single-chunk template → array, e.g. `'{{ row.items }}'` |
597
+ | `completedPath` | `string` | Dot path inside each item, e.g. `orderMove.orderMoveStatus.statusStage` |
598
+ | `completedValue` | `string` | Match at the path counts as completed; omitted → any truthy value |
599
+ | `label` | `string\|localized` | Caption on the left, e.g. `Moves` |
600
+ | `showValue` | `boolean` | Value caption (`2/5`), default true |
601
+ | `valueFormat` | `string` | `count` (default) \| `percent` |
602
+ | `variant` | `string` | `linear` (default) \| `circular` \| `segmented` |
603
+ | `height` / `size` | `number` | Linear thickness (6) / circular diameter (40) |
604
+ | `color` | `string` | Theme name or CSS color; default primary → success at 100% |
605
+ | `activeColor` / `trackColor` | `string` | Mobile: completed-segment color / track or incomplete-segment color for the segmented variant |
606
+ | `tooltip` | `string\|localized` | Tooltip on hover (on mobile it is exposed to assistive technology rather than hover UI) |
607
+
608
+ ```yaml
609
+ - component: progressBar
610
+ name: laneMovesProgress
611
+ props:
612
+ label: Moves
613
+ items: '{{ row.items }}'
614
+ completedPath: orderMove.orderMoveStatus.statusStage
615
+ completedValue: Completed
616
+ ```
617
+
618
+ ---
619
+
537
620
  ## icon
538
621
 
539
622
  Icon renderer. Supports FontAwesome, Tabler, and Feather icons.
@@ -448,6 +448,32 @@ still control the displayed label and stored value.
448
448
  fields: ["name", "address_components", "formatted_address", "geometry", "place_id"]
449
449
  ```
450
450
 
451
+ **Attachment options (under `options`):**
452
+ | Prop | Type | Description |
453
+ |------|------|-------------|
454
+ | `parentId` / `parentType` | `string` | Primary parent of the uploaded attachment (`parentType` defaults to `Order`) |
455
+ | `links` | `{entityType, entityId}[]` or template | More entities to link the same upload to: `Order`, `Contact`, `Job`, `TrackingEvent`. Entries whose `entityType` or `entityId` resolves empty are skipped |
456
+ | `category` | `string` | Attachment category (web) |
457
+ | `allowMultiple` / `allowCamera` / `clearAfterUpload` | `boolean` | Upload behavior |
458
+ | `maxSize` / `allowedExtensions` | `number` / `string[]` | File limits |
459
+ | `onUploaded` | actions | Runs after each upload; `attachment` is in scope |
460
+
461
+ ```yaml
462
+ # One upload linked to the order (parent) and a tracking event
463
+ - component: field
464
+ name: eventPhoto
465
+ props:
466
+ type: attachment
467
+ options:
468
+ parentType: Order
469
+ parentId: "{{ orderId }}"
470
+ links:
471
+ - entityType: TrackingEvent
472
+ entityId: "{{ trackingEventId }}"
473
+ ```
474
+
475
+ `Attachments/ListAttachments` (cx-app-core) takes the same `links` prop next to `parentId`/`parentType` and passes it to its upload field; its grid still lists by `parentId`/`parentType`.
476
+
451
477
  **Events:**
452
478
  | Event | Description |
453
479
  |-------|-------------|
@@ -567,7 +593,7 @@ still control the displayed label and stored value.
567
593
  type: textarea
568
594
  label: { en-US: "Notes" }
569
595
  rows: 4
570
- disabled: "{{ eval !canEdit }}"
596
+ disabled: "{{ !canEdit }}"
571
597
  ```
572
598
 
573
599
  ---
@@ -83,7 +83,7 @@ MUI Button with icon, label, loading state, and action dispatch.
83
83
  icon: check-circle
84
84
  options:
85
85
  variant: success
86
- disabled: "{{ eval status !== 'Pending' }}"
86
+ disabled: "{{ !isEqual status 'Pending' }}"
87
87
  onClick:
88
88
  - mutation:
89
89
  command: "mutation($id: Int!) { approveOrder(id: $id) { success } }"
@@ -131,7 +131,7 @@ props:
131
131
  - dialog:
132
132
  component: Module/ImportDialog
133
133
  - label: { en-US: "Archive All" }
134
- disabled: "{{ eval selectedItems.length === 0 }}"
134
+ disabled: "{{ !any selectedItems }}"
135
135
  onClick:
136
136
  - confirm: { title: { en-US: "Archive?" }, message: { en-US: "Archive selected items?" } }
137
137
  ```
@@ -216,7 +216,7 @@ Programmatic navigation — renders nothing (or spinner with delay).
216
216
  component: redirect
217
217
  name: createRedirect
218
218
  props:
219
- condition: "{{ eval !id }}"
219
+ condition: "{{ !id }}"
220
220
  path: "/orders/create"
221
221
 
222
222
  # External redirect with delay
@@ -468,7 +468,7 @@ children:
468
468
  - name: advanced
469
469
  props:
470
470
  label: { en-US: "Advanced" }
471
- isHidden: "{{ eval !isAdmin }}"
471
+ isHidden: "{{ !isAdmin }}"
472
472
  children:
473
473
  - component: field
474
474
  name: config
@@ -347,6 +347,7 @@ MUI Lab Timeline for chronological events or milestone-based tracking progress.
347
347
  | `startDate` / `endDate` | `string` | current week | Initial date range for normal mode |
348
348
  | `eventSources` | `EventSource[]` | — | GraphQL/static event sources |
349
349
  | `eventTemplate` | `ComponentProps` | — | Custom event template for normal mode |
350
+ | `itemGroups` | `{key, accent?, background?, headerTemplate?}` | — | Mobile normal-mode grouping for consecutive events with the same non-empty key |
350
351
  | `milestones` | `Milestone[]` | `[]` | Tracking milestones |
351
352
  | `options.height` | `string \| number` | `400`/`auto` | Component height |
352
353
  | `options.showTodayMarker` | `boolean` | `true` | Today marker in normal mode |
@@ -364,6 +365,8 @@ MUI Lab Timeline for chronological events or milestone-based tracking progress.
364
365
 
365
366
  **Events:** `onEventClick` (data: `event`)
366
367
 
368
+ On mobile vertical timelines, `itemGroups` frames adjacent runs of two or more events whose templated `key` resolves equally. Runs are order-sensitive; isolated or empty keys remain ungrouped. `headerTemplate`, `accent`, and `background` resolve with `group = { key, items, size, startIndex, endIndex }` and `collection`. Grouped event templates also receive `groupPosition` and `groupOffset`. Tracking mode ignores grouping.
369
+
367
370
  ```yaml
368
371
  component: timeline
369
372
  name: orderTimeline