@nyaruka/temba-components 0.136.0 → 0.137.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 (137) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/demo/components/webchat/example.html +2 -2
  3. package/dist/temba-components.js +537 -578
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/display/Chat.js +123 -44
  6. package/out-tsc/src/display/Chat.js.map +1 -1
  7. package/out-tsc/src/display/FloatingTab.js +2 -6
  8. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  9. package/out-tsc/src/events/eventRenderers.js +442 -0
  10. package/out-tsc/src/events/eventRenderers.js.map +1 -0
  11. package/out-tsc/src/flow/CanvasNode.js +18 -1
  12. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  13. package/out-tsc/src/flow/Editor.js +10 -7
  14. package/out-tsc/src/flow/Editor.js.map +1 -1
  15. package/out-tsc/src/flow/NodeEditor.js +0 -1
  16. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  17. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  18. package/out-tsc/src/list/ShortcutList.js +1 -1
  19. package/out-tsc/src/list/ShortcutList.js.map +1 -1
  20. package/out-tsc/src/live/ContactChat.js +12 -321
  21. package/out-tsc/src/live/ContactChat.js.map +1 -1
  22. package/out-tsc/src/simulator/Simulator.js +432 -541
  23. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  24. package/out-tsc/src/store/AppState.js +33 -0
  25. package/out-tsc/src/store/AppState.js.map +1 -1
  26. package/out-tsc/test/temba-appstate-node-sorting.test.js +430 -0
  27. package/out-tsc/test/temba-appstate-node-sorting.test.js.map +1 -0
  28. package/out-tsc/test/temba-floating-tab.test.js +0 -9
  29. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  30. package/out-tsc/test/temba-flow-editor.test.js +261 -0
  31. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  32. package/out-tsc/test/temba-simulator.test.js +51 -32
  33. package/out-tsc/test/temba-simulator.test.js.map +1 -1
  34. package/package.json +1 -1
  35. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  36. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  37. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  38. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  39. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  40. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  41. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  42. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  43. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  44. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  45. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  46. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  47. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  50. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  51. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  52. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  53. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  54. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  55. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  56. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  57. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  58. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  59. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  60. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  61. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  62. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  63. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  64. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  65. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  66. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  67. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  68. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  69. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  70. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  71. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  72. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  73. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  74. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  75. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  76. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  77. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  78. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  79. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  80. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  81. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  82. package/screenshots/truth/contacts/chat-failure.png +0 -0
  83. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  84. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  85. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  86. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  87. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  88. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  89. package/screenshots/truth/floating-tab/gray.png +0 -0
  90. package/screenshots/truth/floating-tab/green.png +0 -0
  91. package/screenshots/truth/floating-tab/purple.png +0 -0
  92. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  93. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  94. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  95. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  96. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  97. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  98. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  99. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  102. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  103. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  104. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  105. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  106. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  112. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  113. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  114. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  115. package/screenshots/truth/simulator/after-reset.png +0 -0
  116. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  117. package/screenshots/truth/simulator/context-expanded.png +0 -0
  118. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  119. package/screenshots/truth/simulator/event-info.png +0 -0
  120. package/screenshots/truth/simulator/image-attachment.png +0 -0
  121. package/screenshots/truth/simulator/open-initial.png +0 -0
  122. package/screenshots/truth/simulator/quick-replies.png +0 -0
  123. package/src/display/Chat.ts +123 -44
  124. package/src/display/FloatingTab.ts +2 -7
  125. package/src/events/eventRenderers.ts +527 -0
  126. package/src/flow/CanvasNode.ts +18 -1
  127. package/src/flow/Editor.ts +11 -7
  128. package/src/flow/NodeEditor.ts +0 -1
  129. package/src/layout/FloatingWindow.ts +1 -1
  130. package/src/list/ShortcutList.ts +1 -1
  131. package/src/live/ContactChat.ts +17 -376
  132. package/src/simulator/Simulator.ts +492 -564
  133. package/src/store/AppState.ts +56 -0
  134. package/test/temba-appstate-node-sorting.test.ts +506 -0
  135. package/test/temba-floating-tab.test.ts +0 -11
  136. package/test/temba-flow-editor.test.ts +297 -0
  137. package/test/temba-simulator.test.ts +64 -34
@@ -0,0 +1,527 @@
1
+ import { html, TemplateResult } from 'lit';
2
+ import {
3
+ AirtimeTransferredEvent,
4
+ CallEvent,
5
+ ChatStartedEvent,
6
+ ContactGroupsEvent,
7
+ ContactLanguageChangedEvent,
8
+ ContactStatusChangedEvent,
9
+ NameChangedEvent,
10
+ OptInEvent,
11
+ RunEvent,
12
+ TicketEvent,
13
+ UpdateFieldEvent,
14
+ URNsChangedEvent
15
+ } from '../events';
16
+ import { oxfordFn } from '../utils';
17
+
18
+ export enum Events {
19
+ AIRTIME_TRANSFERRED = 'airtime_transferred',
20
+ BROADCAST_CREATED = 'broadcast_created',
21
+ CALL_CREATED = 'call_created',
22
+ CALL_MISSED = 'call_missed',
23
+ CALL_RECEIVED = 'call_received',
24
+ CHAT_STARTED = 'chat_started',
25
+ CONTACT_FIELD_CHANGED = 'contact_field_changed',
26
+ CONTACT_GROUPS_CHANGED = 'contact_groups_changed',
27
+ CONTACT_LANGUAGE_CHANGED = 'contact_language_changed',
28
+ CONTACT_NAME_CHANGED = 'contact_name_changed',
29
+ CONTACT_STATUS_CHANGED = 'contact_status_changed',
30
+ CONTACT_URNS_CHANGED = 'contact_urns_changed',
31
+ EMAIL_CREATED = 'email_created',
32
+ EMAIL_SENT = 'email_sent',
33
+ ERROR = 'error',
34
+ FAILURE = 'failure',
35
+ FLOW_ENTERED = 'flow_entered',
36
+ INPUT_LABELS_ADDED = 'input_labels_added',
37
+ IVR_CREATED = 'ivr_created',
38
+ MSG_CREATED = 'msg_created',
39
+ MSG_RECEIVED = 'msg_received',
40
+ OPTIN_REQUESTED = 'optin_requested',
41
+ OPTIN_STARTED = 'optin_started',
42
+ OPTIN_STOPPED = 'optin_stopped',
43
+ RESTHOOK_CALLED = 'resthook_called',
44
+ RUN_ENDED = 'run_ended',
45
+ RUN_RESULT_CHANGED = 'run_result_changed',
46
+ RUN_STARTED = 'run_started',
47
+ SERVICE_CALLED = 'service_called',
48
+ SESSION_TRIGGERED = 'session_triggered',
49
+ TICKET_ASSIGNEE_CHANGED = 'ticket_assignee_changed',
50
+ TICKET_CLOSED = 'ticket_closed',
51
+ TICKET_NOTE_ADDED = 'ticket_note_added',
52
+ TICKET_OPENED = 'ticket_opened',
53
+ TICKET_REOPENED = 'ticket_reopened',
54
+ TICKET_TOPIC_CHANGED = 'ticket_topic_changed',
55
+ WARNING = 'warning',
56
+ WEBHOOK_CALLED = 'webhook_called'
57
+ }
58
+
59
+ const renderInfoList = (
60
+ singular: string,
61
+ plural: string,
62
+ items: any[]
63
+ ): TemplateResult => {
64
+ if (items.length === 1) {
65
+ return html`<div>${singular} <strong>${items[0].name}</strong></div>`;
66
+ } else {
67
+ const list = items.map((item) => item.name);
68
+ if (list.length === 2) {
69
+ return html`<div>
70
+ ${plural} <strong>${list[0]}</strong> and <strong>${list[1]}</strong>
71
+ </div>`;
72
+ } else {
73
+ const last = list.pop();
74
+ const middle = list.map(
75
+ (name, index) =>
76
+ html`<strong>${name}</strong>${index < list.length - 1 ? ', ' : ''}`
77
+ );
78
+ return html`<div>${plural} ${middle}, and <strong>${last}</strong></div>`;
79
+ }
80
+ }
81
+ };
82
+
83
+ export const renderRunEvent = (event: RunEvent): TemplateResult => {
84
+ let verb = 'Started';
85
+ if (event.type === Events.RUN_ENDED) {
86
+ if (event.status === 'completed') {
87
+ verb = 'Completed';
88
+ } else if (event.status === 'expired') {
89
+ verb = 'Expired from';
90
+ } else {
91
+ verb = 'Interrupted';
92
+ }
93
+ }
94
+
95
+ return html`<div>
96
+ ${verb}
97
+ <a href="/flow/editor/${event.flow.uuid}/"
98
+ ><strong>${event.flow.name}</strong></a
99
+ >
100
+ </div>`;
101
+ };
102
+
103
+ export const renderChatStartedEvent = (
104
+ event: ChatStartedEvent
105
+ ): TemplateResult => {
106
+ if (event.params) {
107
+ return html`<div>Chat referral</div>`;
108
+ } else {
109
+ return html`<div>Chat started</div>`;
110
+ }
111
+ };
112
+
113
+ export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
114
+ return event.value
115
+ ? html`<div>
116
+ Updated <strong>${event.field.name}</strong> to
117
+ <strong>${event.value.text}</strong>
118
+ </div>`
119
+ : html`<div>Cleared <strong>${event.field.name}</strong></div>`;
120
+ };
121
+
122
+ export const renderNameChanged = (event: NameChangedEvent): TemplateResult => {
123
+ return html`<div>
124
+ Updated <strong>name</strong> to <strong>${event.name}</strong>
125
+ </div>`;
126
+ };
127
+
128
+ export const renderContactURNsChanged = (
129
+ event: URNsChangedEvent
130
+ ): TemplateResult => {
131
+ return html`<div>
132
+ Updated <strong>URNs</strong> to
133
+ ${oxfordFn(
134
+ event.urns,
135
+ (urn: string) => html`<strong>${urn.split(':')[1].split('?')[0]}</strong>`
136
+ )}
137
+ </div>`;
138
+ };
139
+
140
+ export const renderTicketAction = (
141
+ event: TicketEvent,
142
+ action: string
143
+ ): TemplateResult => {
144
+ const ticketUUID = event.ticket?.uuid || event.ticket_uuid;
145
+
146
+ const actionNote = event.note
147
+ ? html`<div
148
+ style="width:85%; background: #fffac3; padding: 1em;margin-bottom: 1em;margin-top:1em; border: 1px solid #ffe97f;border-radius: var(--curvature);line-height: 1.2em; word-break: break-word;"
149
+ >
150
+ <div style="color: #8e830fff; font-size: 1em;margin-bottom:0.25em; ">
151
+ <strong>${event._user ? event._user.name : 'Someone'}</strong> added a
152
+ note
153
+ <temba-date
154
+ value=${event.created_on.toISOString()}
155
+ display="relative"
156
+ ></temba-date>
157
+ </div>
158
+ <div style="white-space: pre-wrap;">${event.note}</div>
159
+ </div>`
160
+ : null;
161
+
162
+ if (action === 'noted') {
163
+ return html`${actionNote}`;
164
+ }
165
+
166
+ const description = event._user
167
+ ? html`<div>
168
+ <strong>${event._user.name}</strong> ${action} a
169
+ <strong><a href="/ticket/all/closed/${ticketUUID}/">ticket</a></strong>
170
+ </div>`
171
+ : html`<div>
172
+ A
173
+ <strong><a href="/ticket/all/closed/${ticketUUID}/">ticket</a></strong>
174
+ was <strong>${action}</strong>
175
+ </div>`;
176
+
177
+ return html`<div style="${actionNote ? 'margin-bottom: 1em;' : ''}">
178
+ ${description}
179
+ </div>
180
+ ${actionNote}`;
181
+ };
182
+
183
+ export const renderTicketAssigneeChanged = (
184
+ event: TicketEvent
185
+ ): TemplateResult => {
186
+ if (event._user) {
187
+ if (event.assignee) {
188
+ return html`<div>
189
+ <strong>${event._user.name}</strong> assigned this ticket to
190
+ <strong>${event.assignee.name}</strong>
191
+ </div>`;
192
+ } else {
193
+ return html`<div>
194
+ <strong>${event._user.name}</strong> unassigned this ticket
195
+ </div>`;
196
+ }
197
+ } else {
198
+ if (event.assignee) {
199
+ return html`<div>
200
+ This ticket was assigned to <strong>${event.assignee.name}</strong>
201
+ </div>`;
202
+ } else {
203
+ return html`<div>This ticket was unassigned</div>`;
204
+ }
205
+ }
206
+ };
207
+
208
+ export const renderTicketOpened = (event: TicketEvent): TemplateResult => {
209
+ return html`<div>${event.ticket.topic.name} ticket was opened</div>`;
210
+ };
211
+
212
+ export const renderContactGroupsEvent = (
213
+ event: ContactGroupsEvent
214
+ ): TemplateResult => {
215
+ const groupsEvent = event as ContactGroupsEvent;
216
+ if (groupsEvent.groups_added) {
217
+ return renderInfoList(
218
+ 'Added to group',
219
+ 'Added to groups',
220
+ groupsEvent.groups_added
221
+ );
222
+ } else if (groupsEvent.groups_removed) {
223
+ return renderInfoList(
224
+ 'Removed from group',
225
+ 'Removed from groups',
226
+ groupsEvent.groups_removed
227
+ );
228
+ }
229
+ };
230
+
231
+ export const renderAirtimeTransferredEvent = (
232
+ event: AirtimeTransferredEvent
233
+ ): TemplateResult => {
234
+ if (parseFloat(event.amount) === 0) {
235
+ return html`<div>Airtime transfer failed</div>`;
236
+ }
237
+ return html`<div>
238
+ Transferred <strong>${event.amount}</strong> ${event.currency} of airtime
239
+ </div>`;
240
+ };
241
+
242
+ export const renderContactLanguageChangedEvent = (
243
+ event: ContactLanguageChangedEvent
244
+ ): TemplateResult => {
245
+ return html`<div>
246
+ Language updated to <strong>${event.language}</strong>
247
+ </div>`;
248
+ };
249
+
250
+ export const renderContactStatusChangedEvent = (
251
+ event: ContactStatusChangedEvent
252
+ ): TemplateResult => {
253
+ return html`<div>Status updated to <strong>${event.status}</strong></div>`;
254
+ };
255
+
256
+ export const renderCallEvent = (event: CallEvent): TemplateResult => {
257
+ if (event.type === Events.CALL_CREATED) {
258
+ return html`<div>Call started</div>`;
259
+ } else if (event.type === Events.CALL_MISSED) {
260
+ return html`<div>Call missed</div>`;
261
+ } else if (event.type === Events.CALL_RECEIVED) {
262
+ return html`<div>Call answered</div>`;
263
+ }
264
+ };
265
+
266
+ export const renderOptInEvent = (event: OptInEvent): TemplateResult => {
267
+ if (event.type === Events.OPTIN_REQUESTED) {
268
+ return html`<div>
269
+ Requested opt-in for <strong>${event.optin.name}</strong>
270
+ </div>`;
271
+ } else if (event.type === Events.OPTIN_STARTED) {
272
+ return html`<div>Opted in to <strong>${event.optin.name}</strong></div>`;
273
+ } else if (event.type === Events.OPTIN_STOPPED) {
274
+ return html`<div>Opted out of <strong>${event.optin.name}</strong></div>`;
275
+ }
276
+ };
277
+
278
+ export const renderDiagnosticEvent = (
279
+ event: any,
280
+ _isSimulation: boolean = false
281
+ ): TemplateResult | null => {
282
+ if (event.text) {
283
+ let icon = '⚠️';
284
+ let bgColor = '#fff3cd';
285
+ let textColor = '#856404';
286
+
287
+ if (event.type === 'error') {
288
+ icon = '❗';
289
+ bgColor = '#fee3e3';
290
+ textColor = '#c01829';
291
+ } else if (event.type === 'failure') {
292
+ icon = '💥';
293
+ bgColor = '#fee3e3';
294
+ textColor = '#c01829';
295
+ }
296
+
297
+ return html`<div
298
+ style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: ${bgColor}; color: ${textColor}; border-radius: 12px; margin: 4px 18px;"
299
+ >
300
+ <span style="font-size: 16px; line-height: 1.4;">${icon}</span>
301
+ <span style="flex: 1; line-height: 1.4;">${event.text}</span>
302
+ </div>`;
303
+ }
304
+ return null;
305
+ };
306
+
307
+ export const renderRunResultChanged = (
308
+ event: any,
309
+ isSimulation: boolean = false
310
+ ): TemplateResult | null => {
311
+ const val = String(event.value);
312
+ const MAX_LEN = isSimulation ? 30 : 100;
313
+
314
+ if (val.length > MAX_LEN) {
315
+ const displayVal = val.substring(0, MAX_LEN) + '...';
316
+ return html`<div>
317
+ Set result <strong>${event.name}</strong> to "<span
318
+ title="${val}"
319
+ style="cursor: help; border-bottom: 1px dotted #999;"
320
+ >${displayVal}</span
321
+ >"
322
+ </div>`;
323
+ }
324
+ return html`<div>Set result <strong>${event.name}</strong> to "${val}"</div>`;
325
+ };
326
+
327
+ export const renderInputLabelsAdded = (event: any): TemplateResult | null => {
328
+ const labels = event.labels || [];
329
+ if (labels.length > 0) {
330
+ const labelList = labels.map((l: any) => l.name);
331
+ if (labelList.length === 1) {
332
+ return html`<div>
333
+ Message labeled with <strong>${labelList[0]}</strong>
334
+ </div>`;
335
+ } else {
336
+ const last = labelList.pop();
337
+ return html`<div>
338
+ Message labeled with
339
+ ${labelList.map(
340
+ (name: string, index: number) =>
341
+ html`<strong>${name}</strong>${index < labelList.length - 1
342
+ ? ', '
343
+ : ''}`
344
+ )}
345
+ and <strong>${last}</strong>
346
+ </div>`;
347
+ }
348
+ }
349
+ return null;
350
+ };
351
+
352
+ export const renderEmailSent = (event: any): TemplateResult | null => {
353
+ const recipients = event.to || event.addresses || [];
354
+ const subject = event.subject;
355
+ if (recipients.length > 0) {
356
+ const recipientList = recipients.join(', ');
357
+ return html`<div>
358
+ Sent email to <strong>${recipientList}</strong> with subject "${subject}"
359
+ </div>`;
360
+ }
361
+ return null;
362
+ };
363
+
364
+ export const renderBroadcastCreated = (event: any): TemplateResult | null => {
365
+ const translations = event.translations;
366
+ const baseLanguage = event.base_language;
367
+ if (translations && translations[baseLanguage]) {
368
+ return html`<div>
369
+ Sent broadcast: "${translations[baseLanguage].text}"
370
+ </div>`;
371
+ }
372
+ return html`<div>Sent broadcast</div>`;
373
+ };
374
+
375
+ export const renderSessionTriggered = (event: any): TemplateResult | null => {
376
+ const flow = event.flow;
377
+ if (flow) {
378
+ return html`<div>
379
+ Started somebody else in <strong>${flow.name}</strong>
380
+ </div>`;
381
+ }
382
+ return null;
383
+ };
384
+
385
+ export const renderResthookCalled = (event: any): TemplateResult | null => {
386
+ return html`<div>
387
+ Triggered flow event <strong>${event.resthook}</strong>
388
+ </div>`;
389
+ };
390
+
391
+ export const renderWebhookCalled = (event: any): TemplateResult | null => {
392
+ return html`<div>Called <strong>${event.url}</strong></div>`;
393
+ };
394
+
395
+ export const renderServiceCalled = (event: any): TemplateResult | null => {
396
+ const service = event.service;
397
+ if (service === 'classifier') {
398
+ return html`<div>Called classifier</div>`;
399
+ }
400
+ return html`<div>Called <strong>${service}</strong></div>`;
401
+ };
402
+
403
+ /**
404
+ * Unified event renderer that handles both simulation and contact chat contexts.
405
+ * @param event - The event to render
406
+ * @param isSimulation - Whether this is for simulation (true) or contact chat (false)
407
+ * @returns A template result or null if the event cannot be rendered
408
+ */
409
+ export const renderEvent = (
410
+ event: any,
411
+ isSimulation: boolean
412
+ ): TemplateResult | null => {
413
+ let content: TemplateResult | null = null;
414
+
415
+ switch (event.type) {
416
+ case Events.ERROR:
417
+ case Events.FAILURE:
418
+ case Events.WARNING:
419
+ content = renderDiagnosticEvent(event, isSimulation);
420
+ break;
421
+ case Events.AIRTIME_TRANSFERRED:
422
+ content = renderAirtimeTransferredEvent(event as AirtimeTransferredEvent);
423
+ break;
424
+ case Events.CALL_CREATED:
425
+ case Events.CALL_MISSED:
426
+ case Events.CALL_RECEIVED:
427
+ content = renderCallEvent(event as CallEvent);
428
+ break;
429
+ case Events.CHAT_STARTED:
430
+ content = renderChatStartedEvent(event as ChatStartedEvent);
431
+ break;
432
+ case Events.CONTACT_FIELD_CHANGED:
433
+ content = renderUpdateEvent(event as UpdateFieldEvent);
434
+ break;
435
+ case Events.CONTACT_GROUPS_CHANGED:
436
+ content = renderContactGroupsEvent(event as ContactGroupsEvent);
437
+ break;
438
+ case Events.CONTACT_LANGUAGE_CHANGED:
439
+ content = renderContactLanguageChangedEvent(
440
+ event as ContactLanguageChangedEvent
441
+ );
442
+ break;
443
+ case Events.CONTACT_NAME_CHANGED:
444
+ content = renderNameChanged(event as NameChangedEvent);
445
+ break;
446
+ case Events.CONTACT_STATUS_CHANGED:
447
+ content = renderContactStatusChangedEvent(
448
+ event as ContactStatusChangedEvent
449
+ );
450
+ break;
451
+ case Events.CONTACT_URNS_CHANGED:
452
+ content = renderContactURNsChanged(event as URNsChangedEvent);
453
+ break;
454
+ case Events.INPUT_LABELS_ADDED:
455
+ content = renderInputLabelsAdded(event);
456
+ break;
457
+ case Events.RUN_RESULT_CHANGED:
458
+ content = renderRunResultChanged(event, isSimulation);
459
+ break;
460
+ case Events.OPTIN_REQUESTED:
461
+ case Events.OPTIN_STARTED:
462
+ case Events.OPTIN_STOPPED:
463
+ content = renderOptInEvent(event as OptInEvent);
464
+ break;
465
+ case Events.RUN_STARTED:
466
+ case Events.RUN_ENDED:
467
+ case Events.FLOW_ENTERED:
468
+ content = renderRunEvent(event as RunEvent);
469
+ break;
470
+ case Events.EMAIL_CREATED:
471
+ case Events.EMAIL_SENT:
472
+ content = renderEmailSent(event);
473
+ break;
474
+ case Events.BROADCAST_CREATED:
475
+ content = renderBroadcastCreated(event);
476
+ break;
477
+ case Events.SESSION_TRIGGERED:
478
+ content = renderSessionTriggered(event);
479
+ break;
480
+ case Events.RESTHOOK_CALLED:
481
+ content = renderResthookCalled(event);
482
+ break;
483
+ case Events.WEBHOOK_CALLED:
484
+ content = renderWebhookCalled(event);
485
+ break;
486
+ case Events.SERVICE_CALLED:
487
+ content = renderServiceCalled(event);
488
+ break;
489
+ case Events.TICKET_ASSIGNEE_CHANGED:
490
+ content = renderTicketAssigneeChanged(event as TicketEvent);
491
+ break;
492
+ case Events.TICKET_CLOSED:
493
+ content = renderTicketAction(event as TicketEvent, 'closed');
494
+ break;
495
+ case Events.TICKET_OPENED:
496
+ content = renderTicketAction(event as TicketEvent, 'opened');
497
+ break;
498
+ case Events.TICKET_NOTE_ADDED:
499
+ content = renderTicketAction(event as TicketEvent, 'noted');
500
+ break;
501
+ case Events.TICKET_REOPENED:
502
+ content = renderTicketAction(event as TicketEvent, 'reopened');
503
+ break;
504
+ case Events.TICKET_TOPIC_CHANGED:
505
+ content = html`<div>
506
+ Topic changed to <strong>${(event as TicketEvent).topic.name}</strong>
507
+ </div>`;
508
+ break;
509
+ default:
510
+ return null;
511
+ }
512
+
513
+ if (content === null) {
514
+ return null;
515
+ }
516
+
517
+ // wrap in a div with appropriate font size
518
+ const fontSize = isSimulation ? '11px' : '14px';
519
+ return html`<div style="font-size: ${fontSize}">${content}</div>`;
520
+ };
521
+
522
+ /**
523
+ * @deprecated Use renderEvent(event, true) instead
524
+ */
525
+ export const renderSimulatorEvent = (event: any): TemplateResult | null => {
526
+ return renderEvent(event, true);
527
+ };
@@ -98,6 +98,22 @@ export class CanvasNode extends RapidElement {
98
98
 
99
99
  }
100
100
 
101
+ /* Flow start indicator */
102
+ temba-flow-node.flow-start .node::before {
103
+ content: 'FLOW START';
104
+ position: absolute;
105
+ top: -16px;
106
+ left: 50%;
107
+ transform: translateX(-50%);
108
+ font-size: 10px;
109
+ font-weight: 600;
110
+ letter-spacing: 0.5px;
111
+ color: var(--color-primary-dark, #3b82f6);
112
+ opacity: 0.7;
113
+ z-index: 10;
114
+ white-space: nowrap;
115
+ }
116
+
101
117
  /* Cap width for execute_actions nodes */
102
118
  .node.execute-actions {
103
119
  max-width: 200px;
@@ -283,11 +299,12 @@ export class CanvasNode extends RapidElement {
283
299
  .categories {
284
300
  display: flex;
285
301
  flex-direction: row;
302
+ border-collapse: collapse;
286
303
 
287
304
  }
288
305
 
289
306
  .category {
290
- margin:-1px -0.5px;
307
+ border-collapse: collapse;
291
308
  border: 1px solid #f3f3f3;
292
309
  padding: 0.75em;
293
310
  flex-grow:1;
@@ -309,6 +309,7 @@ export class Editor extends RapidElement {
309
309
  background-position: 10px 10px;
310
310
  width: 100%;
311
311
  display: flex;
312
+ padding-top: 20px;
312
313
  }
313
314
 
314
315
  #canvas {
@@ -406,16 +407,16 @@ export class Editor extends RapidElement {
406
407
  /* Active contact count on nodes */
407
408
  .active-count {
408
409
  position: absolute;
409
- background: var(--color-primary-dark, #3498db);
410
- border: 1px solid var(--color-primary-darker, #2980b9);
410
+ background: #3498db;
411
+ border: 1px solid #2980b9;
411
412
  border-radius: 12px;
412
- padding: 3px 5px;
413
+ padding: 3px 6px;
413
414
  color: #fff;
414
415
  font-weight: 500;
415
416
  top: -10px;
416
417
  left: -10px;
417
418
  font-size: 13px;
418
- min-width: 22px;
419
+
419
420
  text-align: center;
420
421
  z-index: 600;
421
422
  line-height: 1;
@@ -2956,7 +2957,7 @@ export class Editor extends RapidElement {
2956
2957
  header="Translations"
2957
2958
  .width=${360}
2958
2959
  .maxHeight=${600}
2959
- .top=${170}
2960
+ .top=${75}
2960
2961
  color="#6b7280"
2961
2962
  .hidden=${this.localizationWindowHidden}
2962
2963
  @temba-dialog-hidden=${this.handleLocalizationWindowClosed}
@@ -3188,7 +3189,7 @@ export class Editor extends RapidElement {
3188
3189
  ? repeat(
3189
3190
  this.definition.nodes,
3190
3191
  (node) => node.uuid,
3191
- (node) => {
3192
+ (node, index) => {
3192
3193
  const position = this.definition._ui?.nodes[node.uuid]
3193
3194
  ?.position || {
3194
3195
  left: 0,
@@ -3201,10 +3202,13 @@ export class Editor extends RapidElement {
3201
3202
 
3202
3203
  const selected = this.selectedItems.has(node.uuid);
3203
3204
 
3205
+ // first node is the flow start (nodes are sorted by position)
3206
+ const isFlowStart = index === 0;
3207
+
3204
3208
  return html`<temba-flow-node
3205
3209
  class="draggable ${dragging ? 'dragging' : ''} ${selected
3206
3210
  ? 'selected'
3207
- : ''}"
3211
+ : ''} ${isFlowStart ? 'flow-start' : ''}"
3208
3212
  @mousedown=${this.handleMouseDown.bind(this)}
3209
3213
  uuid=${node.uuid}
3210
3214
  data-node-uuid=${node.uuid}
@@ -1331,7 +1331,6 @@ export class NodeEditor extends RapidElement {
1331
1331
  return html`
1332
1332
  <div class="optional-field-link">
1333
1333
  <a
1334
- href="#"
1335
1334
  @click="${(e: Event) => {
1336
1335
  e.preventDefault();
1337
1336
  this.revealOptionalField(fieldName);
@@ -238,7 +238,7 @@ export class FloatingWindow extends RapidElement {
238
238
  }
239
239
  }
240
240
 
241
- private handleClose() {
241
+ public handleClose() {
242
242
  this.hidden = true;
243
243
  // show all tabs when window is closed
244
244
  FloatingTab.showAllTabs();
@@ -79,7 +79,7 @@ export class ShortcutList extends StoreMonitorElement {
79
79
  style="
80
80
  overflow: hidden;
81
81
  text-overflow: ellipsis;
82
- width:100px;
82
+ width:175px;
83
83
  padding-right: 10px;
84
84
  white-space: nowrap;"
85
85
  >