@nyaruka/temba-components 0.139.0 → 0.140.0

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 (155) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/CHANGELOG.md +17 -0
  4. package/demo/data/flows/sample-flow.json +24 -0
  5. package/dist/temba-components.js +562 -296
  6. package/dist/temba-components.js.map +1 -1
  7. package/out-tsc/src/display/Chat.js +10 -7
  8. package/out-tsc/src/display/Chat.js.map +1 -1
  9. package/out-tsc/src/display/Dropdown.js +3 -1
  10. package/out-tsc/src/display/Dropdown.js.map +1 -1
  11. package/out-tsc/src/display/FloatingTab.js +3 -3
  12. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  13. package/out-tsc/src/display/Thumbnail.js +163 -5
  14. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  15. package/out-tsc/src/flow/CanvasNode.js +64 -22
  16. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  17. package/out-tsc/src/flow/Editor.js +142 -8
  18. package/out-tsc/src/flow/Editor.js.map +1 -1
  19. package/out-tsc/src/flow/NodeEditor.js +118 -10
  20. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  21. package/out-tsc/src/flow/StickyNote.js +13 -4
  22. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  23. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  24. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  25. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  26. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  27. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  28. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  29. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  30. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  31. package/out-tsc/src/flow/config.js +11 -3
  32. package/out-tsc/src/flow/config.js.map +1 -1
  33. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  34. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  35. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  36. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  37. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  38. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  39. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  40. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  41. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  42. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  43. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  44. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  45. package/out-tsc/src/flow/operators.js +21 -5
  46. package/out-tsc/src/flow/operators.js.map +1 -1
  47. package/out-tsc/src/flow/types.js.map +1 -1
  48. package/out-tsc/src/flow/utils.js +79 -3
  49. package/out-tsc/src/flow/utils.js.map +1 -1
  50. package/out-tsc/src/form/ArrayEditor.js +4 -2
  51. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  52. package/out-tsc/src/form/FieldRenderer.js +49 -0
  53. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  54. package/out-tsc/src/interfaces.js +1 -0
  55. package/out-tsc/src/interfaces.js.map +1 -1
  56. package/out-tsc/src/layout/Dialog.js +52 -7
  57. package/out-tsc/src/layout/Dialog.js.map +1 -1
  58. package/out-tsc/src/live/TembaChart.js.map +1 -1
  59. package/out-tsc/src/simulator/Simulator.js +10 -4
  60. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  61. package/out-tsc/src/store/AppState.js +89 -3
  62. package/out-tsc/src/store/AppState.js.map +1 -1
  63. package/out-tsc/test/actions/play_audio.test.js +118 -0
  64. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  65. package/out-tsc/test/actions/say_msg.test.js +158 -0
  66. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  67. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  68. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  69. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  70. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  71. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  72. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  73. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  74. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  75. package/out-tsc/test/temba-flow-collision.test.js +261 -6
  76. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  77. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  78. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  79. package/package.json +1 -1
  80. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  81. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  82. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  83. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  84. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  85. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  86. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  87. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  88. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  89. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  90. package/screenshots/truth/editor/router.png +0 -0
  91. package/screenshots/truth/editor/wait.png +0 -0
  92. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  93. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  94. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  95. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  96. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  97. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  98. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  99. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  100. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  101. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  102. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  103. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  104. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  105. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  106. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  112. package/src/display/Chat.ts +13 -7
  113. package/src/display/Dropdown.ts +3 -1
  114. package/src/display/FloatingTab.ts +3 -3
  115. package/src/display/Thumbnail.ts +162 -2
  116. package/src/flow/CanvasNode.ts +69 -23
  117. package/src/flow/Editor.ts +156 -13
  118. package/src/flow/NodeEditor.ts +137 -9
  119. package/src/flow/StickyNote.ts +14 -4
  120. package/src/flow/actions/audio-player.ts +127 -0
  121. package/src/flow/actions/enter_flow.ts +44 -0
  122. package/src/flow/actions/play_audio.ts +64 -5
  123. package/src/flow/actions/say_msg.ts +94 -4
  124. package/src/flow/config.ts +11 -3
  125. package/src/flow/nodes/shared-rules.ts +1 -1
  126. package/src/flow/nodes/terminal.ts +9 -0
  127. package/src/flow/nodes/wait_for_audio.ts +88 -0
  128. package/src/flow/nodes/wait_for_dial.ts +176 -0
  129. package/src/flow/nodes/wait_for_digits.ts +86 -2
  130. package/src/flow/nodes/wait_for_menu.ts +209 -3
  131. package/src/flow/operators.ts +23 -5
  132. package/src/flow/types.ts +23 -1
  133. package/src/flow/utils.ts +82 -3
  134. package/src/form/ArrayEditor.ts +4 -2
  135. package/src/form/FieldRenderer.ts +64 -1
  136. package/src/interfaces.ts +2 -1
  137. package/src/layout/Dialog.ts +53 -7
  138. package/src/live/TembaChart.ts +1 -1
  139. package/src/simulator/Simulator.ts +13 -4
  140. package/src/store/AppState.ts +105 -1
  141. package/src/store/flow-definition.d.ts +2 -0
  142. package/test/actions/play_audio.test.ts +155 -0
  143. package/test/actions/say_msg.test.ts +196 -0
  144. package/test/nodes/wait_for_audio.test.ts +182 -0
  145. package/test/nodes/wait_for_dial.test.ts +382 -0
  146. package/test/nodes/wait_for_digits.test.ts +233 -109
  147. package/test/nodes/wait_for_menu.test.ts +383 -0
  148. package/test/temba-flow-collision.test.ts +286 -6
  149. package/test/temba-node-type-selector.test.ts +6 -6
  150. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  151. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  152. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  153. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  154. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  155. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
@@ -20,9 +20,10 @@ import {
20
20
  } from './types';
21
21
  import { CustomEventType } from '../interfaces';
22
22
  import { generateUUID } from '../utils';
23
+ import { formatIssueMessage } from './utils';
23
24
  import { FieldRenderer } from '../form/FieldRenderer';
24
25
  import { renderMarkdownInline } from '../markdown';
25
- import { AppState, fromStore, zustand } from '../store/AppState';
26
+ import { AppState, FlowIssue, fromStore, zustand } from '../store/AppState';
26
27
  import { getStore } from '../store/Store';
27
28
 
28
29
  export class NodeEditor extends RapidElement {
@@ -55,6 +56,19 @@ export class NodeEditor extends RapidElement {
55
56
  margin-top: 15px;
56
57
  }
57
58
 
59
+ .issue-warning {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 8px;
63
+ color: var(--color-error, tomato);
64
+ font-size: 13px;
65
+ cursor: pointer;
66
+ }
67
+
68
+ .issue-warning:hover .issue-text {
69
+ text-decoration: underline;
70
+ }
71
+
58
72
  .form-actions {
59
73
  display: flex;
60
74
  gap: 10px;
@@ -112,12 +126,29 @@ export class NodeEditor extends RapidElement {
112
126
  color: var(--color-label, #777);
113
127
  }
114
128
 
129
+ .form-row-inline-label {
130
+ font-size: 20px;
131
+ font-weight: 300;
132
+ color: #ccc;
133
+ min-width: 20px;
134
+ text-align: center;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ user-select: none;
139
+ }
140
+
115
141
  .form-row-help {
116
142
  font-size: 12px;
117
143
  color: #666;
118
144
  margin-top: 6px;
119
145
  }
120
146
 
147
+ .form-text {
148
+ color: #666;
149
+ font-size: 13px;
150
+ }
151
+
121
152
  .form-group {
122
153
  border: 1px solid #e0e0e0;
123
154
  border-radius: 6px;
@@ -381,6 +412,9 @@ export class NodeEditor extends RapidElement {
381
412
  @property({ type: Object })
382
413
  nodeUI?: NodeUI;
383
414
 
415
+ @property({ attribute: false })
416
+ dialogOrigin?: { x: number; y: number };
417
+
384
418
  @property({ type: Boolean })
385
419
  isOpen: boolean = false;
386
420
 
@@ -411,6 +445,12 @@ export class NodeEditor extends RapidElement {
411
445
  @fromStore(zustand, (state: AppState) => state.flowDefinition)
412
446
  private flowDefinition!: FlowDefinition;
413
447
 
448
+ @fromStore(zustand, (state: AppState) => state.issuesByNode)
449
+ private issuesByNode!: Map<string, FlowIssue[]>;
450
+
451
+ @fromStore(zustand, (state: AppState) => state.issuesByAction)
452
+ private issuesByAction!: Map<string, FlowIssue[]>;
453
+
414
454
  connectedCallback(): void {
415
455
  super.connectedCallback();
416
456
  this.initializeFormData();
@@ -447,7 +487,13 @@ export class NodeEditor extends RapidElement {
447
487
  private initializeFormData(): void {
448
488
  const nodeConfig = this.getNodeConfig();
449
489
 
450
- if ((!nodeConfig || nodeConfig.type === 'execute_actions') && this.action) {
490
+ // Temporary: terminal nodes defer to action configs, same as execute_actions
491
+ if (
492
+ (!nodeConfig ||
493
+ nodeConfig.type === 'execute_actions' ||
494
+ nodeConfig.type === 'terminal') &&
495
+ this.action
496
+ ) {
451
497
  // Action editing mode - use action config
452
498
  const actionConfig = ACTION_CONFIG[this.action.type];
453
499
 
@@ -597,8 +643,13 @@ export class NodeEditor extends RapidElement {
597
643
  if (this.node && this.nodeUI) {
598
644
  const nodeConfig = this.getNodeConfig();
599
645
 
600
- // For execute_actions nodes, defer to action editing if an action is selected
601
- if (this.nodeUI.type === 'execute_actions' && this.action) {
646
+ // Temporary: terminal nodes defer to action configs for editing, same as execute_actions
647
+ // For execute_actions/terminal nodes, defer to action editing if an action is selected
648
+ if (
649
+ (this.nodeUI.type === 'execute_actions' ||
650
+ this.nodeUI.type === ('terminal' as any)) &&
651
+ this.action
652
+ ) {
602
653
  return ACTION_CONFIG[this.action.type] || null;
603
654
  }
604
655
 
@@ -1462,6 +1513,11 @@ export class NodeEditor extends RapidElement {
1462
1513
  } else if (fieldName && config.type === 'message-editor') {
1463
1514
  // Special handling for message editor
1464
1515
  this.handleMessageEditorChange(fieldName, e);
1516
+ } else if (fieldName && config.type === 'media') {
1517
+ // Extract URL from media picker's attachment
1518
+ const picker = e.target as any;
1519
+ const url = picker.attachments?.[0]?.url || '';
1520
+ this.handleNewFieldChange(fieldName, url);
1465
1521
  } else {
1466
1522
  // Default handling for most field types
1467
1523
  this.handleFormFieldChange(fieldName, e);
@@ -1575,6 +1631,12 @@ export class NodeEditor extends RapidElement {
1575
1631
  case 'group':
1576
1632
  return this.renderGroup(item, config, renderedFields);
1577
1633
 
1634
+ case 'spacer':
1635
+ return html``;
1636
+
1637
+ case 'text':
1638
+ return html`<div class="form-text">${item.text}</div>`;
1639
+
1578
1640
  default:
1579
1641
  return html``;
1580
1642
  }
@@ -1585,7 +1647,14 @@ export class NodeEditor extends RapidElement {
1585
1647
  config: ActionConfig | NodeConfig,
1586
1648
  renderedFields: Set<string>
1587
1649
  ): TemplateResult {
1588
- const { items, gap = '1rem', label, helpText } = rowConfig;
1650
+ const {
1651
+ items,
1652
+ gap = '1rem',
1653
+ label,
1654
+ helpText,
1655
+ inlineLabels,
1656
+ marginBottom
1657
+ } = rowConfig;
1589
1658
 
1590
1659
  // Collect all fields from this row for width calculations
1591
1660
  const fieldsInRow = this.collectFieldsFromItems(items);
@@ -1619,8 +1688,18 @@ export class NodeEditor extends RapidElement {
1619
1688
  });
1620
1689
 
1621
1690
  const rowContent = html`
1622
- <div class="form-row" style="display: flex; gap: ${gap};">
1691
+ <div
1692
+ class="form-row"
1693
+ style="display: flex; gap: ${gap};${marginBottom
1694
+ ? ` margin-bottom: ${marginBottom};`
1695
+ : ''}"
1696
+ >
1623
1697
  ${items.map((item) => {
1698
+ // Spacer items render as empty flex children
1699
+ if (typeof item !== 'string' && item.type === 'spacer') {
1700
+ return html`<div style="flex: 1 1 0;"></div>`;
1701
+ }
1702
+
1624
1703
  // Get the field name from the item
1625
1704
  const fieldName =
1626
1705
  typeof item === 'string'
@@ -1641,10 +1720,22 @@ export class NodeEditor extends RapidElement {
1641
1720
  renderedFields
1642
1721
  );
1643
1722
 
1723
+ // When inlineLabels is provided, render the label inline to the left
1724
+ const inlineLabel =
1725
+ inlineLabels && fieldName ? inlineLabels[fieldName] : null;
1726
+
1644
1727
  // Wrap in a div with flex style if we have a flex style
1645
- return flexStyle
1646
- ? html`<div style="${flexStyle}">${itemContent}</div>`
1647
- : itemContent;
1728
+ if (flexStyle) {
1729
+ return inlineLabel
1730
+ ? html`<div
1731
+ style="${flexStyle} display: flex; align-items: center; gap: 0.35rem;"
1732
+ >
1733
+ <span class="form-row-inline-label">${inlineLabel}</span>
1734
+ <div style="flex: 1 1 0; min-width: 0;">${itemContent}</div>
1735
+ </div>`
1736
+ : html`<div style="${flexStyle}">${itemContent}</div>`;
1737
+ }
1738
+ return itemContent;
1648
1739
  })}
1649
1740
  </div>
1650
1741
  `;
@@ -2038,6 +2129,40 @@ export class NodeEditor extends RapidElement {
2038
2129
  `;
2039
2130
  }
2040
2131
 
2132
+ private handleIssueClick(issue: FlowIssue): void {
2133
+ this.fireCustomEvent(CustomEventType.ShowIssue, { issue });
2134
+ }
2135
+
2136
+ private renderIssueWarnings(): TemplateResult | string {
2137
+ const issues: FlowIssue[] = [];
2138
+
2139
+ // Check for action-level issues
2140
+ if (this.action && this.issuesByAction?.has(this.action.uuid)) {
2141
+ issues.push(...this.issuesByAction.get(this.action.uuid));
2142
+ }
2143
+
2144
+ // Check for node-level issues (issues without action_uuid)
2145
+ if (this.node && this.issuesByNode?.has(this.node.uuid)) {
2146
+ issues.push(...this.issuesByNode.get(this.node.uuid));
2147
+ }
2148
+
2149
+ if (issues.length === 0) return '';
2150
+
2151
+ return html`
2152
+ ${issues.map(
2153
+ (issue) => html`
2154
+ <div
2155
+ class="issue-warning"
2156
+ @click=${() => this.handleIssueClick(issue)}
2157
+ >
2158
+ <temba-icon name="alert_warning" size="1.2"></temba-icon>
2159
+ <span class="issue-text">${formatIssueMessage(issue)}</span>
2160
+ </div>
2161
+ `
2162
+ )}
2163
+ `;
2164
+ }
2165
+
2041
2166
  render(): TemplateResult {
2042
2167
  if (!this.isOpen) {
2043
2168
  return html``;
@@ -2061,6 +2186,8 @@ export class NodeEditor extends RapidElement {
2061
2186
  <temba-dialog
2062
2187
  header="${header}"
2063
2188
  .open="${this.isOpen}"
2189
+ .originX=${this.dialogOrigin?.x ?? null}
2190
+ .originY=${this.dialogOrigin?.y ?? null}
2064
2191
  @temba-button-clicked=${this.handleDialogButtonClick}
2065
2192
  primaryButtonName="Save"
2066
2193
  cancelButtonName="Cancel"
@@ -2072,6 +2199,7 @@ export class NodeEditor extends RapidElement {
2072
2199
  ${this.getNodeConfig()?.router?.configurable
2073
2200
  ? this.renderRouterSection()
2074
2201
  : null}
2202
+ ${this.renderIssueWarnings()}
2075
2203
  </div>
2076
2204
 
2077
2205
  <div slot="gutter">${this.renderGutter()}</div>
@@ -305,7 +305,7 @@ export class StickyNote extends RapidElement {
305
305
 
306
306
  private handleBodyBlur(event: FocusEvent): void {
307
307
  const target = event.target as HTMLElement;
308
- const newBody = target.textContent || '';
308
+ const newBody = target.innerText || '';
309
309
 
310
310
  if (this.data && newBody !== this.data.body) {
311
311
  getStore()
@@ -328,7 +328,17 @@ export class StickyNote extends RapidElement {
328
328
  event.stopPropagation();
329
329
  }
330
330
 
331
- private handleKeyDown(event: KeyboardEvent): void {
331
+ private handleTitleKeyDown(event: KeyboardEvent): void {
332
+ if (event.key === 'Enter') {
333
+ event.preventDefault();
334
+ (event.target as HTMLElement).blur();
335
+ }
336
+ if (event.key === 'Escape') {
337
+ (event.target as HTMLElement).blur();
338
+ }
339
+ }
340
+
341
+ private handleBodyKeyDown(event: KeyboardEvent): void {
332
342
  if (event.key === 'Enter' && !event.shiftKey) {
333
343
  event.preventDefault();
334
344
  (event.target as HTMLElement).blur();
@@ -386,7 +396,7 @@ export class StickyNote extends RapidElement {
386
396
  class="sticky-title"
387
397
  contenteditable="${!this.isTranslating}"
388
398
  @blur="${this.handleTitleBlur}"
389
- @keydown="${this.handleKeyDown}"
399
+ @keydown="${this.handleTitleKeyDown}"
390
400
  @mousedown="${this.handleContentMouseDown}"
391
401
  .textContent="${this.data.title}"
392
402
  ></div>
@@ -396,7 +406,7 @@ export class StickyNote extends RapidElement {
396
406
  class="sticky-body"
397
407
  contenteditable="${!this.isTranslating}"
398
408
  @blur="${this.handleBodyBlur}"
399
- @keydown="${this.handleKeyDown}"
409
+ @keydown="${this.handleBodyKeyDown}"
400
410
  @mousedown="${this.handleContentMouseDown}"
401
411
  .textContent="${this.data.body}"
402
412
  ></div>
@@ -0,0 +1,127 @@
1
+ import { html, TemplateResult } from 'lit-html';
2
+
3
+ // SVG paths for play and pause icons
4
+ const PLAY_SVG = html`<svg
5
+ viewBox="0 0 24 24"
6
+ width="16"
7
+ height="16"
8
+ fill="currentColor"
9
+ >
10
+ <polygon points="6,3 20,12 6,21" />
11
+ </svg>`;
12
+
13
+ // Track active audio so only one plays at a time
14
+ let activeAudio: HTMLAudioElement | null = null;
15
+ let activeContainer: HTMLElement | null = null;
16
+
17
+ function stopActive() {
18
+ if (activeAudio) {
19
+ activeAudio.pause();
20
+ activeAudio.currentTime = 0;
21
+ if (activeContainer) {
22
+ resetPlayer(activeContainer);
23
+ }
24
+ activeAudio = null;
25
+ activeContainer = null;
26
+ }
27
+ }
28
+
29
+ function resetPlayer(container: HTMLElement) {
30
+ const btn = container.querySelector('.audio-play-btn') as HTMLElement;
31
+ const progress = container.querySelector('.audio-progress') as HTMLElement;
32
+ if (btn)
33
+ btn.innerHTML =
34
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>';
35
+ if (progress) progress.style.width = '0%';
36
+ }
37
+
38
+ function handlePlayClick(e: MouseEvent) {
39
+ e.stopPropagation();
40
+ e.preventDefault();
41
+
42
+ const container = (e.currentTarget as HTMLElement).closest(
43
+ '.audio-player'
44
+ ) as HTMLElement;
45
+ if (!container) return;
46
+
47
+ const url = container.dataset.url;
48
+ if (!url) return;
49
+
50
+ const btn = container.querySelector('.audio-play-btn') as HTMLElement;
51
+ const progress = container.querySelector('.audio-progress') as HTMLElement;
52
+
53
+ // If this is already playing, pause it
54
+ if (activeAudio && activeContainer === container && !activeAudio.paused) {
55
+ activeAudio.pause();
56
+ btn.innerHTML =
57
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>';
58
+ return;
59
+ }
60
+
61
+ // Stop any other playing audio
62
+ stopActive();
63
+
64
+ const audio = new Audio(url);
65
+ activeAudio = audio;
66
+ activeContainer = container;
67
+
68
+ btn.innerHTML =
69
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/></svg>';
70
+
71
+ audio.addEventListener('timeupdate', () => {
72
+ if (audio.duration && progress) {
73
+ const pct = (audio.currentTime / audio.duration) * 100;
74
+ progress.style.width = `${pct}%`;
75
+ }
76
+ });
77
+
78
+ audio.addEventListener('ended', () => {
79
+ resetPlayer(container);
80
+ activeAudio = null;
81
+ activeContainer = null;
82
+ });
83
+
84
+ audio.addEventListener('error', () => {
85
+ resetPlayer(container);
86
+ activeAudio = null;
87
+ activeContainer = null;
88
+ });
89
+
90
+ audio.play().catch(() => {
91
+ resetPlayer(container);
92
+ activeAudio = null;
93
+ activeContainer = null;
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Renders an inline audio player with play/pause button and progress bar.
99
+ * Used on canvas nodes for play_audio and say_msg actions.
100
+ */
101
+ export function renderAudioPlayer(audioUrl: string): TemplateResult {
102
+ return html`
103
+ <div
104
+ class="audio-player"
105
+ data-url="${audioUrl}"
106
+ style="display: flex; align-items: center; gap: 0.4em; cursor: default;"
107
+ @mousedown=${(e: MouseEvent) => e.stopPropagation()}
108
+ @mouseup=${(e: MouseEvent) => e.stopPropagation()}
109
+ >
110
+ <div
111
+ class="audio-play-btn"
112
+ @click=${handlePlayClick}
113
+ style="cursor: pointer; color: #666; display: flex; align-items: center; flex-shrink: 0;"
114
+ >
115
+ ${PLAY_SVG}
116
+ </div>
117
+ <div
118
+ style="flex: 1; height: 4px; background: #e0e0e0; border-radius: 2px; overflow: hidden; min-width: 40px;"
119
+ >
120
+ <div
121
+ class="audio-progress"
122
+ style="width: 0%; height: 100%; background: var(--color-primary, #2387ca); border-radius: 2px; transition: width 0.2s linear;"
123
+ ></div>
124
+ </div>
125
+ </div>
126
+ `;
127
+ }
@@ -0,0 +1,44 @@
1
+ import { html } from 'lit-html';
2
+ import { ActionConfig, ACTION_GROUPS, FlowTypes } from '../types';
3
+ import { Node, EnterFlow } from '../../store/flow-definition';
4
+ import { renderNamedObjects } from '../utils';
5
+
6
+ export const enter_flow: ActionConfig = {
7
+ name: 'Enter a Flow',
8
+ group: ACTION_GROUPS.trigger,
9
+ hideFromActions: true,
10
+ flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
11
+ render: (_node: Node, action: EnterFlow) => {
12
+ return html`${renderNamedObjects([action.flow], 'flow')}`;
13
+ },
14
+ toFormData: (action: EnterFlow) => {
15
+ return {
16
+ uuid: action.uuid,
17
+ flow: action.flow ? [action.flow] : []
18
+ };
19
+ },
20
+ form: {
21
+ flow: {
22
+ type: 'select',
23
+ required: true,
24
+ placeholder: 'Select a flow...',
25
+ helpText: 'The contact will enter this flow and not return',
26
+ endpoint: '/api/v2/flows.json',
27
+ valueKey: 'uuid',
28
+ nameKey: 'name'
29
+ }
30
+ },
31
+ layout: ['flow'],
32
+ fromFormData: (formData: any): EnterFlow => {
33
+ const selected = formData.flow[0];
34
+ return {
35
+ uuid: formData.uuid,
36
+ type: 'enter_flow',
37
+ terminal: true,
38
+ flow: {
39
+ uuid: selected.uuid || selected.value,
40
+ name: selected.name
41
+ }
42
+ };
43
+ }
44
+ };
@@ -1,13 +1,72 @@
1
1
  import { html } from 'lit-html';
2
- import { ActionConfig, ACTION_GROUPS, FlowTypes } from '../types';
2
+ import { ActionConfig, ACTION_GROUPS, FormData, FlowTypes } from '../types';
3
3
  import { Node, PlayAudio } from '../../store/flow-definition';
4
4
 
5
5
  export const play_audio: ActionConfig = {
6
- name: 'Play Audio',
6
+ name: 'Play Recording',
7
7
  group: ACTION_GROUPS.send,
8
8
  flowTypes: [FlowTypes.VOICE],
9
- render: (_node: Node, _action: PlayAudio) => {
10
- // This will need to be implemented based on the actual render logic
11
- return html`<div>Play Audio</div>`;
9
+ render: (_node: Node, action: PlayAudio) => {
10
+ return html`
11
+ <div style="display: flex; align-items: center; gap: 0.3em;">
12
+ <temba-icon name="recording" size="1"></temba-icon>
13
+ <div
14
+ style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;"
15
+ title="${action.audio_url || ''}"
16
+ >
17
+ ${action.audio_url || ''}
18
+ </div>
19
+ </div>
20
+ `;
21
+ },
22
+ form: {
23
+ audio_url: {
24
+ type: 'text',
25
+ label: 'Recording URL',
26
+ required: true,
27
+ evaluated: true
28
+ }
29
+ },
30
+ layout: ['audio_url'],
31
+ toFormData: (action: PlayAudio) => {
32
+ return {
33
+ uuid: action.uuid,
34
+ audio_url: action.audio_url || ''
35
+ };
36
+ },
37
+ fromFormData: (data: FormData) => {
38
+ return {
39
+ uuid: data.uuid,
40
+ type: 'play_audio',
41
+ audio_url: (data.audio_url || '').trim()
42
+ } as PlayAudio;
43
+ },
44
+ localizable: ['audio_url'],
45
+ toLocalizationFormData: (
46
+ action: PlayAudio,
47
+ localization: Record<string, any>
48
+ ) => {
49
+ const formData: FormData = {
50
+ uuid: action.uuid
51
+ };
52
+
53
+ if (localization.audio_url && Array.isArray(localization.audio_url)) {
54
+ formData.audio_url = localization.audio_url[0] || '';
55
+ } else {
56
+ formData.audio_url = '';
57
+ }
58
+
59
+ return formData;
60
+ },
61
+ fromLocalizationFormData: (formData: FormData, action: PlayAudio) => {
62
+ const localization: Record<string, any> = {};
63
+
64
+ if (formData.audio_url && formData.audio_url.trim() !== '') {
65
+ if (formData.audio_url !== action.audio_url) {
66
+ localization.audio_url = [formData.audio_url];
67
+ }
68
+ }
69
+
70
+ return localization;
12
71
  }
13
72
  };
@@ -1,13 +1,103 @@
1
1
  import { html } from 'lit-html';
2
- import { ActionConfig, ACTION_GROUPS, FlowTypes } from '../types';
2
+ import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
3
+ import { ActionConfig, ACTION_GROUPS, FormData, FlowTypes } from '../types';
3
4
  import { Node, SayMsg } from '../../store/flow-definition';
5
+ import { renderAudioPlayer } from './audio-player';
4
6
 
5
7
  export const say_msg: ActionConfig = {
6
8
  name: 'Say Message',
7
9
  group: ACTION_GROUPS.send,
8
10
  flowTypes: [FlowTypes.VOICE],
9
- render: (_node: Node, _action: SayMsg) => {
10
- // This will need to be implemented based on the actual render logic
11
- return html`<div>Say Message</div>`;
11
+ render: (_node: Node, action: SayMsg) => {
12
+ const text = (action.text || '').replace(/\n/g, '<br>');
13
+ return html`
14
+ ${unsafeHTML(text)}
15
+ ${action.audio_url
16
+ ? html`<div style="margin-top: 0.5em;">
17
+ ${renderAudioPlayer(action.audio_url)}
18
+ </div>`
19
+ : null}
20
+ `;
21
+ },
22
+ form: {
23
+ text: {
24
+ type: 'textarea',
25
+ label: 'Message',
26
+ required: true,
27
+ evaluated: true,
28
+ placeholder: 'Enter message to speak...',
29
+ minHeight: 80
30
+ },
31
+ audio_url: {
32
+ type: 'media',
33
+ label: 'Recording',
34
+ required: false,
35
+ accept: 'audio/*',
36
+ optionalLink: 'Add a recording'
37
+ }
38
+ },
39
+ layout: ['text', 'audio_url'],
40
+ toFormData: (action: SayMsg) => {
41
+ return {
42
+ uuid: action.uuid,
43
+ text: action.text || '',
44
+ audio_url: action.audio_url || ''
45
+ };
46
+ },
47
+ fromFormData: (data: FormData) => {
48
+ const result: any = {
49
+ uuid: data.uuid,
50
+ type: 'say_msg',
51
+ text: data.text || ''
52
+ };
53
+ if (data.audio_url && data.audio_url.trim() !== '') {
54
+ result.audio_url = data.audio_url.trim();
55
+ }
56
+ return result as SayMsg;
57
+ },
58
+ sanitize: (formData: FormData): void => {
59
+ if (formData.text && typeof formData.text === 'string') {
60
+ formData.text = formData.text.trim();
61
+ }
62
+ },
63
+ localizable: ['text', 'audio_url'],
64
+ toLocalizationFormData: (
65
+ action: SayMsg,
66
+ localization: Record<string, any>
67
+ ) => {
68
+ const formData: FormData = {
69
+ uuid: action.uuid
70
+ };
71
+
72
+ if (localization.text && Array.isArray(localization.text)) {
73
+ formData.text = localization.text[0] || '';
74
+ } else {
75
+ formData.text = '';
76
+ }
77
+
78
+ if (localization.audio_url && Array.isArray(localization.audio_url)) {
79
+ formData.audio_url = localization.audio_url[0] || '';
80
+ } else {
81
+ formData.audio_url = '';
82
+ }
83
+
84
+ return formData;
85
+ },
86
+ fromLocalizationFormData: (formData: FormData, action: SayMsg) => {
87
+ const localization: Record<string, any> = {};
88
+
89
+ if (formData.text && formData.text.trim() !== '') {
90
+ if (formData.text !== action.text) {
91
+ localization.text = [formData.text];
92
+ }
93
+ }
94
+
95
+ if (formData.audio_url && formData.audio_url.trim() !== '') {
96
+ if (formData.audio_url !== action.audio_url) {
97
+ localization.audio_url = [formData.audio_url];
98
+ }
99
+ }
100
+
101
+ return localization;
12
102
  }
13
103
  };