@nyaruka/temba-components 0.131.2 → 0.131.3

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 (223) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/demo/components/floating-tabs/example.html +400 -0
  3. package/demo/components/flow/index.html +1 -1
  4. package/demo/data/flows/sample-flow.json +41 -2
  5. package/demo/data/flows/voicemail.json +613 -0
  6. package/demo/index.html +6 -0
  7. package/dist/locales/es.js +5 -5
  8. package/dist/locales/es.js.map +1 -1
  9. package/dist/locales/fr.js +5 -5
  10. package/dist/locales/fr.js.map +1 -1
  11. package/dist/locales/locale-codes.js +11 -2
  12. package/dist/locales/locale-codes.js.map +1 -1
  13. package/dist/locales/pt.js +5 -5
  14. package/dist/locales/pt.js.map +1 -1
  15. package/dist/temba-components.js +1109 -535
  16. package/dist/temba-components.js.map +1 -1
  17. package/out-tsc/src/display/FloatingTab.js +167 -0
  18. package/out-tsc/src/display/FloatingTab.js.map +1 -0
  19. package/out-tsc/src/display/ProgressBar.js +22 -2
  20. package/out-tsc/src/display/ProgressBar.js.map +1 -1
  21. package/out-tsc/src/events.js.map +1 -1
  22. package/out-tsc/src/flow/CanvasNode.js +165 -31
  23. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  24. package/out-tsc/src/flow/Editor.js +857 -3
  25. package/out-tsc/src/flow/Editor.js.map +1 -1
  26. package/out-tsc/src/flow/NodeEditor.js +239 -19
  27. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  28. package/out-tsc/src/flow/NodeTypeSelector.js +44 -3
  29. package/out-tsc/src/flow/NodeTypeSelector.js.map +1 -1
  30. package/out-tsc/src/flow/StickyNote.js +12 -3
  31. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  32. package/out-tsc/src/flow/actions/add_contact_groups.js +2 -1
  33. package/out-tsc/src/flow/actions/add_contact_groups.js.map +1 -1
  34. package/out-tsc/src/flow/actions/add_contact_urn.js +2 -1
  35. package/out-tsc/src/flow/actions/add_contact_urn.js.map +1 -1
  36. package/out-tsc/src/flow/actions/add_input_labels.js +2 -1
  37. package/out-tsc/src/flow/actions/add_input_labels.js.map +1 -1
  38. package/out-tsc/src/flow/actions/play_audio.js +2 -1
  39. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  40. package/out-tsc/src/flow/actions/remove_contact_groups.js +2 -1
  41. package/out-tsc/src/flow/actions/remove_contact_groups.js.map +1 -1
  42. package/out-tsc/src/flow/actions/request_optin.js +1 -0
  43. package/out-tsc/src/flow/actions/request_optin.js.map +1 -1
  44. package/out-tsc/src/flow/actions/say_msg.js +2 -1
  45. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  46. package/out-tsc/src/flow/actions/send_broadcast.js +2 -1
  47. package/out-tsc/src/flow/actions/send_broadcast.js.map +1 -1
  48. package/out-tsc/src/flow/actions/send_email.js +2 -1
  49. package/out-tsc/src/flow/actions/send_email.js.map +1 -1
  50. package/out-tsc/src/flow/actions/send_msg.js +93 -3
  51. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  52. package/out-tsc/src/flow/actions/set_contact_channel.js +2 -1
  53. package/out-tsc/src/flow/actions/set_contact_channel.js.map +1 -1
  54. package/out-tsc/src/flow/actions/set_contact_field.js +2 -1
  55. package/out-tsc/src/flow/actions/set_contact_field.js.map +1 -1
  56. package/out-tsc/src/flow/actions/set_contact_language.js +2 -1
  57. package/out-tsc/src/flow/actions/set_contact_language.js.map +1 -1
  58. package/out-tsc/src/flow/actions/set_contact_name.js +2 -1
  59. package/out-tsc/src/flow/actions/set_contact_name.js.map +1 -1
  60. package/out-tsc/src/flow/actions/set_contact_status.js +2 -1
  61. package/out-tsc/src/flow/actions/set_contact_status.js.map +1 -1
  62. package/out-tsc/src/flow/actions/set_run_result.js +2 -1
  63. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -1
  64. package/out-tsc/src/flow/actions/start_session.js +2 -1
  65. package/out-tsc/src/flow/actions/start_session.js.map +1 -1
  66. package/out-tsc/src/flow/config.js +2 -10
  67. package/out-tsc/src/flow/config.js.map +1 -1
  68. package/out-tsc/src/flow/nodes/shared.js +54 -0
  69. package/out-tsc/src/flow/nodes/shared.js.map +1 -1
  70. package/out-tsc/src/flow/nodes/split_by_airtime.js +9 -3
  71. package/out-tsc/src/flow/nodes/split_by_airtime.js.map +1 -1
  72. package/out-tsc/src/flow/nodes/split_by_contact_field.js +8 -3
  73. package/out-tsc/src/flow/nodes/split_by_contact_field.js.map +1 -1
  74. package/out-tsc/src/flow/nodes/split_by_expression.js +8 -3
  75. package/out-tsc/src/flow/nodes/split_by_expression.js.map +1 -1
  76. package/out-tsc/src/flow/nodes/split_by_groups.js +8 -3
  77. package/out-tsc/src/flow/nodes/split_by_groups.js.map +1 -1
  78. package/out-tsc/src/flow/nodes/split_by_intent.js +3 -2
  79. package/out-tsc/src/flow/nodes/split_by_intent.js.map +1 -1
  80. package/out-tsc/src/flow/nodes/split_by_llm.js +9 -2
  81. package/out-tsc/src/flow/nodes/split_by_llm.js.map +1 -1
  82. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +9 -2
  83. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -1
  84. package/out-tsc/src/flow/nodes/split_by_random.js +8 -2
  85. package/out-tsc/src/flow/nodes/split_by_random.js.map +1 -1
  86. package/out-tsc/src/flow/nodes/split_by_resthook.js +8 -3
  87. package/out-tsc/src/flow/nodes/split_by_resthook.js.map +1 -1
  88. package/out-tsc/src/flow/nodes/split_by_run_result.js +8 -3
  89. package/out-tsc/src/flow/nodes/split_by_run_result.js.map +1 -1
  90. package/out-tsc/src/flow/nodes/split_by_scheme.js +8 -3
  91. package/out-tsc/src/flow/nodes/split_by_scheme.js.map +1 -1
  92. package/out-tsc/src/flow/nodes/split_by_subflow.js +8 -2
  93. package/out-tsc/src/flow/nodes/split_by_subflow.js.map +1 -1
  94. package/out-tsc/src/flow/nodes/split_by_ticket.js +8 -2
  95. package/out-tsc/src/flow/nodes/split_by_ticket.js.map +1 -1
  96. package/out-tsc/src/flow/nodes/split_by_webhook.js +8 -2
  97. package/out-tsc/src/flow/nodes/split_by_webhook.js.map +1 -1
  98. package/out-tsc/src/flow/nodes/wait_for_digits.js +3 -2
  99. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  100. package/out-tsc/src/flow/nodes/wait_for_menu.js +3 -2
  101. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  102. package/out-tsc/src/flow/nodes/wait_for_response.js +8 -3
  103. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  104. package/out-tsc/src/flow/types.js +15 -0
  105. package/out-tsc/src/flow/types.js.map +1 -1
  106. package/out-tsc/src/layout/FloatingWindow.js +346 -0
  107. package/out-tsc/src/layout/FloatingWindow.js.map +1 -0
  108. package/out-tsc/src/live/ContactChat.js +3 -19
  109. package/out-tsc/src/live/ContactChat.js.map +1 -1
  110. package/out-tsc/src/locales/es.js +5 -5
  111. package/out-tsc/src/locales/es.js.map +1 -1
  112. package/out-tsc/src/locales/fr.js +5 -5
  113. package/out-tsc/src/locales/fr.js.map +1 -1
  114. package/out-tsc/src/locales/locale-codes.js +11 -2
  115. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  116. package/out-tsc/src/locales/pt.js +5 -5
  117. package/out-tsc/src/locales/pt.js.map +1 -1
  118. package/out-tsc/src/store/AppState.js +67 -0
  119. package/out-tsc/src/store/AppState.js.map +1 -1
  120. package/out-tsc/temba-modules.js +4 -0
  121. package/out-tsc/temba-modules.js.map +1 -1
  122. package/out-tsc/test/temba-floating-tab.test.js +91 -0
  123. package/out-tsc/test/temba-floating-tab.test.js.map +1 -0
  124. package/out-tsc/test/temba-floating-window.test.js +301 -0
  125. package/out-tsc/test/temba-floating-window.test.js.map +1 -0
  126. package/out-tsc/test/temba-flow-editor-node.test.js +117 -0
  127. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  128. package/out-tsc/test/temba-localization.test.js +471 -0
  129. package/out-tsc/test/temba-localization.test.js.map +1 -0
  130. package/out-tsc/test/temba-node-type-selector.test.js +150 -0
  131. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  132. package/out-tsc/test/utils.test.js +18 -0
  133. package/out-tsc/test/utils.test.js.map +1 -1
  134. package/package.json +1 -1
  135. package/screenshots/truth/floating-tab/default.png +0 -0
  136. package/screenshots/truth/floating-tab/gray.png +0 -0
  137. package/screenshots/truth/floating-tab/green.png +0 -0
  138. package/screenshots/truth/floating-tab/hidden.png +0 -0
  139. package/screenshots/truth/floating-tab/hover.png +0 -0
  140. package/screenshots/truth/floating-tab/purple.png +0 -0
  141. package/screenshots/truth/floating-window/chromeless.png +0 -0
  142. package/screenshots/truth/floating-window/custom-size.png +0 -0
  143. package/screenshots/truth/floating-window/default.png +0 -0
  144. package/screenshots/truth/floating-window/with-header.png +0 -0
  145. package/screenshots/truth/node-type-selector/action-mode.png +0 -0
  146. package/screenshots/truth/node-type-selector/split-mode.png +0 -0
  147. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  148. package/src/display/FloatingTab.ts +174 -0
  149. package/src/display/ProgressBar.ts +22 -2
  150. package/src/events.ts +2 -4
  151. package/src/flow/CanvasNode.ts +190 -32
  152. package/src/flow/Editor.ts +1040 -3
  153. package/src/flow/NodeEditor.ts +317 -19
  154. package/src/flow/NodeTypeSelector.ts +47 -3
  155. package/src/flow/StickyNote.ts +12 -3
  156. package/src/flow/actions/add_contact_groups.ts +2 -1
  157. package/src/flow/actions/add_contact_urn.ts +3 -1
  158. package/src/flow/actions/add_input_labels.ts +2 -1
  159. package/src/flow/actions/play_audio.ts +2 -1
  160. package/src/flow/actions/remove_contact_groups.ts +3 -1
  161. package/src/flow/actions/request_optin.ts +1 -0
  162. package/src/flow/actions/say_msg.ts +2 -1
  163. package/src/flow/actions/send_broadcast.ts +2 -1
  164. package/src/flow/actions/send_email.ts +3 -1
  165. package/src/flow/actions/send_msg.ts +134 -3
  166. package/src/flow/actions/set_contact_channel.ts +2 -1
  167. package/src/flow/actions/set_contact_field.ts +2 -1
  168. package/src/flow/actions/set_contact_language.ts +3 -1
  169. package/src/flow/actions/set_contact_name.ts +2 -1
  170. package/src/flow/actions/set_contact_status.ts +2 -1
  171. package/src/flow/actions/set_run_result.ts +2 -1
  172. package/src/flow/actions/start_session.ts +3 -1
  173. package/src/flow/config.ts +2 -12
  174. package/src/flow/nodes/shared.ts +70 -1
  175. package/src/flow/nodes/split_by_airtime.ts +20 -3
  176. package/src/flow/nodes/split_by_contact_field.ts +13 -3
  177. package/src/flow/nodes/split_by_expression.ts +13 -3
  178. package/src/flow/nodes/split_by_groups.ts +13 -3
  179. package/src/flow/nodes/split_by_intent.ts +3 -2
  180. package/src/flow/nodes/split_by_llm.ts +19 -2
  181. package/src/flow/nodes/split_by_llm_categorize.ts +19 -2
  182. package/src/flow/nodes/split_by_random.ts +12 -2
  183. package/src/flow/nodes/split_by_resthook.ts +13 -3
  184. package/src/flow/nodes/split_by_run_result.ts +13 -3
  185. package/src/flow/nodes/split_by_scheme.ts +13 -3
  186. package/src/flow/nodes/split_by_subflow.ts +12 -2
  187. package/src/flow/nodes/split_by_ticket.ts +12 -2
  188. package/src/flow/nodes/split_by_webhook.ts +12 -2
  189. package/src/flow/nodes/wait_for_digits.ts +3 -2
  190. package/src/flow/nodes/wait_for_menu.ts +3 -2
  191. package/src/flow/nodes/wait_for_response.ts +13 -3
  192. package/src/flow/types.ts +47 -0
  193. package/src/layout/FloatingWindow.ts +386 -0
  194. package/src/live/ContactChat.ts +4 -19
  195. package/src/locales/es.ts +18 -13
  196. package/src/locales/fr.ts +18 -13
  197. package/src/locales/locale-codes.ts +11 -2
  198. package/src/locales/pt.ts +18 -13
  199. package/src/store/AppState.ts +104 -0
  200. package/static/api/llms.json +18 -0
  201. package/temba-modules.ts +4 -0
  202. package/test/temba-floating-tab.test.ts +110 -0
  203. package/test/temba-floating-window.test.ts +477 -0
  204. package/test/temba-flow-editor-node.test.ts +144 -0
  205. package/test/temba-localization.test.ts +611 -0
  206. package/test/temba-node-type-selector.test.ts +203 -0
  207. package/test/utils.test.ts +20 -0
  208. package/test-assets/contacts/history.json +5 -6
  209. package/test-assets/select/llms.json +2 -2
  210. package/web-dev-server.config.mjs +47 -1
  211. package/web-test-runner.config.mjs +0 -1
  212. package/out-tsc/src/flow/nodes/wait_for_audio.js +0 -7
  213. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +0 -1
  214. package/out-tsc/src/flow/nodes/wait_for_image.js +0 -7
  215. package/out-tsc/src/flow/nodes/wait_for_image.js.map +0 -1
  216. package/out-tsc/src/flow/nodes/wait_for_location.js +0 -7
  217. package/out-tsc/src/flow/nodes/wait_for_location.js.map +0 -1
  218. package/out-tsc/src/flow/nodes/wait_for_video.js +0 -7
  219. package/out-tsc/src/flow/nodes/wait_for_video.js.map +0 -1
  220. package/src/flow/nodes/wait_for_audio.ts +0 -7
  221. package/src/flow/nodes/wait_for_image.ts +0 -7
  222. package/src/flow/nodes/wait_for_location.ts +0 -7
  223. package/src/flow/nodes/wait_for_video.ts +0 -7
@@ -13,9 +13,10 @@ import { AppState, fromStore, zustand } from '../store/AppState';
13
13
  import { RapidElement } from '../RapidElement';
14
14
  import { repeat } from 'lit-html/directives/repeat.js';
15
15
  import { CustomEventType } from '../interfaces';
16
- import { generateUUID } from '../utils';
16
+ import { generateUUID, postJSON } from '../utils';
17
17
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
18
18
  import { ACTION_GROUP_METADATA } from './types';
19
+ import { Checkbox } from '../form/Checkbox';
19
20
 
20
21
  import { Plumber } from './Plumber';
21
22
  import { CanvasNode } from './CanvasNode';
@@ -60,6 +61,35 @@ export interface SelectionBox {
60
61
 
61
62
  const DRAG_THRESHOLD = 5;
62
63
 
64
+ type TranslationType = 'property' | 'category';
65
+
66
+ interface TranslationEntry {
67
+ uuid: string;
68
+ type: TranslationType;
69
+ attribute: string;
70
+ from: string;
71
+ to: string | null;
72
+ }
73
+
74
+ interface TranslationBundle {
75
+ nodeUuid: string;
76
+ actionUuid?: string;
77
+ translations: TranslationEntry[];
78
+ }
79
+
80
+ interface TranslationModel {
81
+ uuid: string;
82
+ name: string;
83
+ description?: string;
84
+ }
85
+
86
+ interface LocalizationUpdate {
87
+ uuid: string;
88
+ translations: Record<string, string>;
89
+ }
90
+
91
+ const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
92
+
63
93
  // Offset for positioning dropped action node relative to mouse cursor
64
94
  // Keep small to make drop location close to cursor position
65
95
  const DROP_PREVIEW_OFFSET_X = 20;
@@ -83,6 +113,12 @@ export class Editor extends RapidElement {
83
113
  @property({ type: String })
84
114
  public version: string;
85
115
 
116
+ @property({ type: String })
117
+ public flowType: string = 'message';
118
+
119
+ @property({ type: Array })
120
+ public features: string[] = [];
121
+
86
122
  @fromStore(zustand, (state: AppState) => state.flowDefinition)
87
123
  private definition!: FlowDefinition;
88
124
 
@@ -92,6 +128,12 @@ export class Editor extends RapidElement {
92
128
  @fromStore(zustand, (state: AppState) => state.dirtyDate)
93
129
  private dirtyDate!: Date;
94
130
 
131
+ @fromStore(zustand, (state: AppState) => state.languageCode)
132
+ private languageCode!: string;
133
+
134
+ @fromStore(zustand, (state: AppState) => state.isTranslating)
135
+ private isTranslating!: boolean;
136
+
95
137
  // Drag state
96
138
  @state()
97
139
  private isDragging = false;
@@ -129,6 +171,31 @@ export class Editor extends RapidElement {
129
171
  @state()
130
172
  private isValidTarget = true;
131
173
 
174
+ @state()
175
+ private localizationWindowHidden = true;
176
+
177
+ @state()
178
+ private translationFilters: { categories: boolean } = {
179
+ categories: false
180
+ };
181
+
182
+ @state()
183
+ private translationSettingsExpanded = false;
184
+
185
+ @state()
186
+ private autoTranslateDialogOpen = false;
187
+
188
+ @state()
189
+ private autoTranslating = false;
190
+
191
+ @state()
192
+ private autoTranslateModel: TranslationModel | null = null;
193
+
194
+ @state()
195
+ private autoTranslateError: string | null = null;
196
+
197
+ private translationCache = new Map<string, string>();
198
+
132
199
  // NodeEditor state - handles both node and action editing
133
200
  @state()
134
201
  private editingNode: Node | null = null;
@@ -166,6 +233,27 @@ export class Editor extends RapidElement {
166
233
 
167
234
  private canvasMouseDown = false;
168
235
 
236
+ // Default languages if not specified in flow definition
237
+ private readonly DEFAULT_LANGUAGES = [
238
+ { code: 'eng', name: 'English' },
239
+ { code: 'fra', name: 'French' },
240
+ { code: 'esp', name: 'Spanish' }
241
+ ];
242
+
243
+ private getAvailableLanguages(): Array<{ code: string; name: string }> {
244
+ // Use languages from flow definition if available, otherwise use defaults
245
+ if (
246
+ this.definition?._ui?.languages &&
247
+ this.definition._ui.languages.length > 0
248
+ ) {
249
+ return this.definition._ui.languages.map((lang: any) => ({
250
+ code: typeof lang === 'string' ? lang : lang.iso || lang.code,
251
+ name: typeof lang === 'string' ? lang : lang.name
252
+ }));
253
+ }
254
+ return this.DEFAULT_LANGUAGES;
255
+ }
256
+
169
257
  // Bound event handlers to maintain proper 'this' context
170
258
  private boundMouseMove = this.handleMouseMove.bind(this);
171
259
  private boundMouseUp = this.handleMouseUp.bind(this);
@@ -316,6 +404,185 @@ export class Editor extends RapidElement {
316
404
  .jtk-floating-endpoint {
317
405
  pointer-events: none;
318
406
  }
407
+
408
+ .localization-window-content {
409
+ display: flex;
410
+ flex-direction: column;
411
+ gap: 16px;
412
+ height: 100%;
413
+ }
414
+
415
+ .localization-header {
416
+ font-size: 13px;
417
+ color: #4b5563;
418
+ line-height: 1.4;
419
+ }
420
+
421
+ .localization-language-select {
422
+ --color-widget-border: #d1d5db;
423
+ --color-widget-background: #fff;
424
+ }
425
+
426
+ .localization-language-row {
427
+ display: flex;
428
+ align-items: flex-end;
429
+ gap: 12px;
430
+ }
431
+
432
+ .localization-language-row temba-select {
433
+ flex: 1;
434
+ }
435
+
436
+ .localization-progress {
437
+ margin-top: auto;
438
+ display: flex;
439
+ flex-direction: column;
440
+ gap: 8px;
441
+ }
442
+
443
+ .localization-progress-bar-row {
444
+ display: flex;
445
+ align-items: center;
446
+ gap: 8px;
447
+ }
448
+
449
+ .localization-progress-trigger {
450
+ flex: 1;
451
+ border-radius: 6px;
452
+ cursor: pointer;
453
+ display: flex;
454
+ align-items: center;
455
+ }
456
+
457
+ .localization-progress-trigger:focus-visible {
458
+ outline: 2px solid #94a3b8;
459
+ outline-offset: 2px;
460
+ }
461
+
462
+ .localization-progress-trigger temba-progress {
463
+ flex: 1;
464
+ }
465
+
466
+ .localization-progress h5 {
467
+ margin: 0;
468
+ font-size: 13px;
469
+ font-weight: 600;
470
+ color: #374151;
471
+ }
472
+
473
+ .localization-progress-summary {
474
+ font-size: 12px;
475
+ color: #6b7280;
476
+ display: flex;
477
+ align-items: center;
478
+ gap: 6px;
479
+ min-height: 20px;
480
+ }
481
+
482
+ .translation-settings-toggle {
483
+ display: inline-flex;
484
+ align-items: center;
485
+ gap: 6px;
486
+ background: transparent;
487
+ border: none;
488
+ color: #6b7280;
489
+ font-size: 12px;
490
+ font-weight: 600;
491
+ cursor: pointer;
492
+ padding: 4px;
493
+ border-radius: 4px;
494
+ }
495
+
496
+ .translation-settings-label {
497
+ font-size: 12px;
498
+ color: #6b7280;
499
+ }
500
+
501
+ .translation-settings-toggle:focus-visible {
502
+ outline: 2px solid #94a3b8;
503
+ outline-offset: 2px;
504
+ }
505
+
506
+ .translation-settings-arrow {
507
+ width: 8px;
508
+ height: 8px;
509
+ border-right: 2px solid currentColor;
510
+ border-bottom: 2px solid currentColor;
511
+ transform: rotate(-45deg);
512
+ transition: transform 0.2s ease;
513
+ margin-left: 2px;
514
+ }
515
+
516
+ .translation-settings-arrow.expanded {
517
+ transform: rotate(45deg);
518
+ }
519
+
520
+ .translation-settings {
521
+ }
522
+
523
+ .translation-settings-row {
524
+ display: flex;
525
+ align-items: center;
526
+ justify-content: space-between;
527
+ }
528
+
529
+ .translation-settings-row temba-checkbox {
530
+ width: 100%;
531
+ }
532
+
533
+ .auto-translate-button {
534
+ background: var(--color-primary-dark);
535
+ border: none;
536
+ color: #fff;
537
+ padding: 10px 12px;
538
+ border-radius: var(--curvature);
539
+ font-size: 12px;
540
+ font-weight: 600;
541
+ cursor: pointer;
542
+ transition: opacity 0.2s ease;
543
+ }
544
+
545
+ .auto-translate-button[disabled] {
546
+ opacity: 0.5;
547
+ cursor: not-allowed;
548
+ }
549
+
550
+ .auto-translate-error {
551
+ font-size: 12px;
552
+ color: #b91c1c;
553
+ }
554
+
555
+ .auto-translate-dialog-content {
556
+ padding: 20px;
557
+ display: flex;
558
+ flex-direction: column;
559
+ gap: 12px;
560
+ font-size: 14px;
561
+ color: #374151;
562
+ }
563
+
564
+ .auto-translate-dialog-content p {
565
+ margin: 0;
566
+ }
567
+
568
+ .auto-translate-loading {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: 8px;
572
+ font-size: 13px;
573
+ color: #6b7280;
574
+ }
575
+
576
+ .auto-translate-empty {
577
+ font-size: 13px;
578
+ color: #6b7280;
579
+ }
580
+
581
+ .localization-empty {
582
+ font-size: 13px;
583
+ color: #9ca3af;
584
+ white-space: nowrap;
585
+ }
319
586
  `;
320
587
  }
321
588
 
@@ -389,6 +656,24 @@ export class Editor extends RapidElement {
389
656
 
390
657
  if (changes.has('definition')) {
391
658
  this.updateCanvasSize();
659
+
660
+ // Set flowType from the loaded definition
661
+ if (this.definition?.type) {
662
+ this.flowType = this.getFlowTypeFromDefinition(this.definition.type);
663
+ }
664
+
665
+ const filters = this.definition?._ui?.translation_filters || {
666
+ categories: false
667
+ };
668
+ const normalizedFilters = {
669
+ categories: !!filters.categories
670
+ };
671
+
672
+ if (this.translationFilters.categories !== normalizedFilters.categories) {
673
+ this.translationFilters = normalizedFilters;
674
+ }
675
+
676
+ this.translationCache.clear();
392
677
  }
393
678
 
394
679
  if (changes.has('dirtyDate')) {
@@ -396,6 +681,29 @@ export class Editor extends RapidElement {
396
681
  this.debouncedSave();
397
682
  }
398
683
  }
684
+
685
+ if (changes.has('languageCode')) {
686
+ this.translationCache.clear();
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Map FlowDefinition type to Editor flowType
692
+ * FlowDefinition uses: 'messaging', 'messaging_background', 'messaging_offline', 'voice'
693
+ * Editor uses: 'message', 'voice', 'background'
694
+ */
695
+ private getFlowTypeFromDefinition(definitionType: string): string {
696
+ if (definitionType === 'voice') {
697
+ return 'voice';
698
+ } else if (
699
+ definitionType === 'messaging_background' ||
700
+ definitionType === 'messaging_offline'
701
+ ) {
702
+ return 'background';
703
+ } else {
704
+ // 'messaging' or any other messaging type defaults to 'message'
705
+ return 'message';
706
+ }
399
707
  }
400
708
 
401
709
  private debouncedSave(): void {
@@ -434,6 +742,17 @@ export class Editor extends RapidElement {
434
742
  getStore().getState().setDirtyDate(null);
435
743
  }
436
744
 
745
+ private handleLanguageChange(languageCode: string): void {
746
+ zustand.getState().setLanguageCode(languageCode);
747
+
748
+ // Repaint connections after language change since node sizes can change
749
+ if (this.plumber) {
750
+ requestAnimationFrame(() => {
751
+ this.plumber.repaintEverything();
752
+ });
753
+ }
754
+ }
755
+
437
756
  disconnectedCallback(): void {
438
757
  super.disconnectedCallback();
439
758
  if (this.saveTimer !== null) {
@@ -1699,6 +2018,719 @@ export class Editor extends RapidElement {
1699
2018
  }
1700
2019
  }
1701
2020
 
2021
+ private getLocalizationLanguages(): Array<{ code: string; name: string }> {
2022
+ if (!this.definition) {
2023
+ return [];
2024
+ }
2025
+
2026
+ const baseLanguage = this.definition.language;
2027
+ return this.getAvailableLanguages().filter(
2028
+ (lang) => lang.code !== baseLanguage
2029
+ );
2030
+ }
2031
+
2032
+ private getLocalizationProgress(languageCode: string): {
2033
+ total: number;
2034
+ localized: number;
2035
+ } {
2036
+ if (
2037
+ !this.definition ||
2038
+ !languageCode ||
2039
+ languageCode === this.definition.language
2040
+ ) {
2041
+ return { total: 0, localized: 0 };
2042
+ }
2043
+
2044
+ const bundles = this.buildTranslationBundles(
2045
+ this.translationFilters.categories,
2046
+ languageCode
2047
+ );
2048
+ return this.getTranslationCounts(bundles);
2049
+ }
2050
+
2051
+ private getLanguageLocalization(languageCode: string): Record<string, any> {
2052
+ if (!this.definition?.localization) {
2053
+ return {};
2054
+ }
2055
+ return this.definition.localization[languageCode] || {};
2056
+ }
2057
+
2058
+ private buildTranslationBundles(
2059
+ includeCategories: boolean,
2060
+ languageCode: string = this.languageCode
2061
+ ): TranslationBundle[] {
2062
+ if (
2063
+ !this.definition ||
2064
+ !languageCode ||
2065
+ languageCode === this.definition.language
2066
+ ) {
2067
+ return [];
2068
+ }
2069
+
2070
+ const languageLocalization = this.getLanguageLocalization(languageCode);
2071
+ const bundles: TranslationBundle[] = [];
2072
+
2073
+ this.definition.nodes.forEach((node) => {
2074
+ node.actions.forEach((action) => {
2075
+ const config = ACTION_CONFIG[action.type];
2076
+ if (!config?.localizable || config.localizable.length === 0) {
2077
+ return;
2078
+ }
2079
+
2080
+ // For send_msg actions, only count 'text' for progress tracking
2081
+ // (quick_replies and attachments are still localizable but don't count toward progress)
2082
+ const localizableKeys =
2083
+ action.type === 'send_msg'
2084
+ ? config.localizable.filter((key) => key === 'text')
2085
+ : config.localizable;
2086
+
2087
+ const translations = this.findTranslations(
2088
+ 'property',
2089
+ action.uuid,
2090
+ localizableKeys,
2091
+ action,
2092
+ languageLocalization
2093
+ );
2094
+
2095
+ if (translations.length > 0) {
2096
+ bundles.push({
2097
+ nodeUuid: node.uuid,
2098
+ actionUuid: action.uuid,
2099
+ translations
2100
+ });
2101
+ }
2102
+ });
2103
+
2104
+ if (!includeCategories) {
2105
+ return;
2106
+ }
2107
+
2108
+ const nodeUI = this.definition._ui?.nodes?.[node.uuid];
2109
+ const nodeType = nodeUI?.type;
2110
+ if (!nodeType) {
2111
+ return;
2112
+ }
2113
+
2114
+ const nodeConfig = NODE_CONFIG[nodeType];
2115
+ if (
2116
+ nodeConfig?.localizable === 'categories' &&
2117
+ node.router?.categories?.length
2118
+ ) {
2119
+ const categoryTranslations = node.router.categories.flatMap(
2120
+ (category) =>
2121
+ this.findTranslations(
2122
+ 'category',
2123
+ category.uuid,
2124
+ ['name'],
2125
+ category,
2126
+ languageLocalization
2127
+ )
2128
+ );
2129
+
2130
+ if (categoryTranslations.length > 0) {
2131
+ bundles.push({
2132
+ nodeUuid: node.uuid,
2133
+ translations: categoryTranslations
2134
+ });
2135
+ }
2136
+ }
2137
+ });
2138
+
2139
+ return bundles;
2140
+ }
2141
+
2142
+ private findTranslations(
2143
+ type: TranslationType,
2144
+ uuid: string,
2145
+ localizeableKeys: string[],
2146
+ source: any,
2147
+ localization: Record<string, any>
2148
+ ): TranslationEntry[] {
2149
+ const translations: TranslationEntry[] = [];
2150
+
2151
+ localizeableKeys.forEach((attribute) => {
2152
+ if (attribute === 'quick_replies') {
2153
+ return;
2154
+ }
2155
+
2156
+ const pathSegments = attribute.split('.');
2157
+ let from: any = source;
2158
+ let to: any = [];
2159
+
2160
+ while (pathSegments.length > 0 && from) {
2161
+ if (from.uuid) {
2162
+ to = localization[from.uuid];
2163
+ }
2164
+
2165
+ const path = pathSegments.shift();
2166
+ if (!path) {
2167
+ break;
2168
+ }
2169
+
2170
+ if (to) {
2171
+ to = to[path];
2172
+ }
2173
+ from = from[path];
2174
+ }
2175
+
2176
+ if (!from) {
2177
+ return;
2178
+ }
2179
+
2180
+ const fromValue = this.formatTranslationValue(from);
2181
+ if (!fromValue) {
2182
+ return;
2183
+ }
2184
+
2185
+ const toValue = to ? this.formatTranslationValue(to) : null;
2186
+
2187
+ translations.push({
2188
+ uuid,
2189
+ type,
2190
+ attribute,
2191
+ from: fromValue,
2192
+ to: toValue
2193
+ });
2194
+ });
2195
+
2196
+ return translations;
2197
+ }
2198
+
2199
+ private formatTranslationValue(value: any): string | null {
2200
+ if (value === null || value === undefined) {
2201
+ return null;
2202
+ }
2203
+
2204
+ if (Array.isArray(value)) {
2205
+ const normalized = value
2206
+ .map((entry) => this.formatTranslationValue(entry))
2207
+ .filter((entry) => !!entry) as string[];
2208
+ return normalized.length > 0 ? normalized.join(', ') : null;
2209
+ }
2210
+
2211
+ if (typeof value === 'object') {
2212
+ if ('name' in value && value.name) {
2213
+ return String(value.name);
2214
+ }
2215
+
2216
+ if ('arguments' in value && Array.isArray(value.arguments)) {
2217
+ return value.arguments.join(' ');
2218
+ }
2219
+
2220
+ return null;
2221
+ }
2222
+
2223
+ if (typeof value === 'number') {
2224
+ return value.toString();
2225
+ }
2226
+
2227
+ if (typeof value === 'string') {
2228
+ const trimmed = value.trim();
2229
+ return trimmed.length > 0 ? trimmed : null;
2230
+ }
2231
+
2232
+ return null;
2233
+ }
2234
+
2235
+ private getTranslationCounts(bundles: TranslationBundle[]): {
2236
+ total: number;
2237
+ localized: number;
2238
+ } {
2239
+ return bundles.reduce(
2240
+ (counts, bundle) => {
2241
+ bundle.translations.forEach((translation) => {
2242
+ counts.total += 1;
2243
+ if (translation.to && translation.to.trim().length > 0) {
2244
+ counts.localized += 1;
2245
+ }
2246
+ });
2247
+ return counts;
2248
+ },
2249
+ { total: 0, localized: 0 }
2250
+ );
2251
+ }
2252
+
2253
+ private handleLocalizationTabClick(): void {
2254
+ const languages = this.getLocalizationLanguages();
2255
+ if (!languages.length) {
2256
+ return;
2257
+ }
2258
+
2259
+ this.localizationWindowHidden = false;
2260
+
2261
+ const alreadySelected = languages.some(
2262
+ (lang) => lang.code === this.languageCode
2263
+ );
2264
+
2265
+ if (!alreadySelected) {
2266
+ this.handleLanguageChange(languages[0].code);
2267
+ }
2268
+ }
2269
+
2270
+ private handleLocalizationLanguageSelect(languageCode: string): void {
2271
+ if (languageCode === this.languageCode) {
2272
+ return;
2273
+ }
2274
+ this.handleLanguageChange(languageCode);
2275
+ }
2276
+
2277
+ private handleLocalizationLanguageSelectChange(event: CustomEvent): void {
2278
+ const select = event.target as any;
2279
+ const nextValue = select?.values?.[0]?.value;
2280
+ if (nextValue) {
2281
+ this.handleLocalizationLanguageSelect(nextValue);
2282
+ }
2283
+ }
2284
+
2285
+ private handleLocalizationWindowClosed(): void {
2286
+ this.localizationWindowHidden = true;
2287
+
2288
+ const baseLanguage = this.definition?.language;
2289
+ if (baseLanguage && this.languageCode !== baseLanguage) {
2290
+ this.handleLanguageChange(baseLanguage);
2291
+ }
2292
+ }
2293
+
2294
+ private toggleTranslationSettings(): void {
2295
+ this.translationSettingsExpanded = !this.translationSettingsExpanded;
2296
+ }
2297
+
2298
+ private handleLocalizationProgressToggleClick(event: MouseEvent): void {
2299
+ const target = event.target as HTMLElement;
2300
+ if (target.closest('.translation-settings-toggle')) {
2301
+ return;
2302
+ }
2303
+ this.toggleTranslationSettings();
2304
+ }
2305
+
2306
+ private handleLocalizationProgressToggleKeydown(event: KeyboardEvent): void {
2307
+ if (event.key === 'Enter' || event.key === ' ') {
2308
+ event.preventDefault();
2309
+ this.toggleTranslationSettings();
2310
+ }
2311
+ }
2312
+
2313
+ private handleIncludeCategoriesChange(event: Event): void {
2314
+ const checkbox = event.target as Checkbox;
2315
+ const categories = checkbox?.checked ?? false;
2316
+ this.translationFilters = { categories };
2317
+ getStore()?.getState().setTranslationFilters({ categories });
2318
+ this.requestUpdate();
2319
+ }
2320
+
2321
+ private async handleAutoTranslateClick(event: Event): Promise<void> {
2322
+ event.preventDefault();
2323
+ event.stopPropagation();
2324
+
2325
+ if (this.autoTranslating) {
2326
+ this.autoTranslating = false;
2327
+ return;
2328
+ }
2329
+
2330
+ this.autoTranslateDialogOpen = true;
2331
+ }
2332
+
2333
+ private handleAutoTranslateDialogButton(event: CustomEvent): void {
2334
+ const button = event.detail?.button;
2335
+ if (!button) {
2336
+ return;
2337
+ }
2338
+
2339
+ if (button.name === 'Translate') {
2340
+ if (!this.autoTranslateModel) {
2341
+ return;
2342
+ }
2343
+ this.autoTranslateDialogOpen = false;
2344
+ this.autoTranslateError = null;
2345
+ this.autoTranslating = true;
2346
+ this.runAutoTranslation().catch((error) => {
2347
+ console.error('Auto translation failed', error);
2348
+ this.autoTranslateError = 'Auto translation failed. Please try again.';
2349
+ this.autoTranslating = false;
2350
+ });
2351
+ } else if (button.name === 'Cancel' || button.name === 'Close') {
2352
+ this.autoTranslateDialogOpen = false;
2353
+ }
2354
+ }
2355
+
2356
+ private handleAutoTranslateModelChange(event: Event): void {
2357
+ const select = event.target as any;
2358
+ const nextModel = select?.values?.[0] || null;
2359
+ this.autoTranslateModel = nextModel;
2360
+ }
2361
+
2362
+ private shouldTranslateValue(text: string): boolean {
2363
+ if (!text) {
2364
+ return false;
2365
+ }
2366
+ const trimmed = text.trim();
2367
+ if (trimmed.length <= 1) {
2368
+ return false;
2369
+ }
2370
+ if (/^\d+$/.test(trimmed)) {
2371
+ return false;
2372
+ }
2373
+ return true;
2374
+ }
2375
+
2376
+ private async requestAutoTranslation(text: string): Promise<string | null> {
2377
+ if (!this.autoTranslateModel || !this.definition) {
2378
+ return null;
2379
+ }
2380
+
2381
+ const payload = {
2382
+ text,
2383
+ lang: {
2384
+ from: this.definition.language,
2385
+ to: this.languageCode
2386
+ }
2387
+ };
2388
+
2389
+ const response = await postJSON(
2390
+ `/llm/translate/${this.autoTranslateModel.uuid}/`,
2391
+ payload
2392
+ );
2393
+
2394
+ if (response?.status === 200) {
2395
+ const result = response.json?.result || response.json?.text;
2396
+ return result ? String(result) : null;
2397
+ }
2398
+
2399
+ throw new Error('Auto translation request failed');
2400
+ }
2401
+
2402
+ private applyLocalizationUpdates(
2403
+ updates: LocalizationUpdate[],
2404
+ autoTranslated = false
2405
+ ): void {
2406
+ if (!updates.length || !this.definition) {
2407
+ return;
2408
+ }
2409
+
2410
+ const store = getStore();
2411
+ if (!store) {
2412
+ return;
2413
+ }
2414
+
2415
+ updates.forEach(({ uuid, translations }) => {
2416
+ const normalized = Object.entries(translations).reduce(
2417
+ (acc, [key, value]) => {
2418
+ if (!value) {
2419
+ return acc;
2420
+ }
2421
+ acc[key] = Array.isArray(value) ? value : [value];
2422
+ return acc;
2423
+ },
2424
+ {} as Record<string, any>
2425
+ );
2426
+
2427
+ const existing =
2428
+ this.definition.localization?.[this.languageCode]?.[uuid] || {};
2429
+ const merged = { ...existing, ...normalized };
2430
+
2431
+ store.getState().updateLocalization(this.languageCode, uuid, merged);
2432
+
2433
+ if (autoTranslated) {
2434
+ zustand
2435
+ .getState()
2436
+ .markAutoTranslated(
2437
+ this.languageCode,
2438
+ uuid,
2439
+ Object.keys(translations)
2440
+ );
2441
+ }
2442
+ });
2443
+ }
2444
+
2445
+ private async runAutoTranslation(): Promise<void> {
2446
+ if (
2447
+ !this.definition ||
2448
+ this.languageCode === this.definition.language ||
2449
+ !this.autoTranslateModel
2450
+ ) {
2451
+ this.autoTranslating = false;
2452
+ return;
2453
+ }
2454
+
2455
+ const bundles = this.buildTranslationBundles(
2456
+ this.translationFilters.categories
2457
+ );
2458
+
2459
+ for (const bundle of bundles) {
2460
+ if (!this.autoTranslating) {
2461
+ break;
2462
+ }
2463
+
2464
+ const untranslated = bundle.translations.filter(
2465
+ (translation) => !translation.to || translation.to.trim().length === 0
2466
+ );
2467
+
2468
+ if (untranslated.length === 0) {
2469
+ continue;
2470
+ }
2471
+
2472
+ const updates: LocalizationUpdate[] = [];
2473
+
2474
+ for (const translation of untranslated) {
2475
+ if (!this.autoTranslating) {
2476
+ break;
2477
+ }
2478
+
2479
+ if (!this.shouldTranslateValue(translation.from)) {
2480
+ continue;
2481
+ }
2482
+
2483
+ const cached = this.translationCache.get(translation.from);
2484
+ if (cached) {
2485
+ updates.push({
2486
+ uuid: translation.uuid,
2487
+ translations: { [translation.attribute]: cached }
2488
+ });
2489
+ continue;
2490
+ }
2491
+
2492
+ try {
2493
+ const result = await this.requestAutoTranslation(translation.from);
2494
+ if (result) {
2495
+ this.translationCache.set(translation.from, result);
2496
+ updates.push({
2497
+ uuid: translation.uuid,
2498
+ translations: { [translation.attribute]: result }
2499
+ });
2500
+ }
2501
+ } catch (error) {
2502
+ console.error('Auto translation request failed', error);
2503
+ this.autoTranslateError =
2504
+ 'Auto translation failed. Please try again.';
2505
+ this.autoTranslating = false;
2506
+ break;
2507
+ }
2508
+ }
2509
+
2510
+ if (updates.length > 0) {
2511
+ this.applyLocalizationUpdates(updates, true);
2512
+ }
2513
+
2514
+ if (!this.autoTranslating) {
2515
+ break;
2516
+ }
2517
+ }
2518
+
2519
+ this.autoTranslating = false;
2520
+ }
2521
+
2522
+ private renderLocalizationWindow(): TemplateResult | string {
2523
+ const languages = this.getLocalizationLanguages();
2524
+ if (!languages.length) {
2525
+ return html``;
2526
+ }
2527
+
2528
+ const baseLanguage = this.definition?.language;
2529
+ const availableLanguages = this.getAvailableLanguages();
2530
+ const baseName =
2531
+ availableLanguages.find((lang) => lang.code === baseLanguage)?.name ||
2532
+ 'Base Language';
2533
+
2534
+ const activeLanguageCode = languages.some(
2535
+ (lang) => lang.code === this.languageCode
2536
+ )
2537
+ ? this.languageCode
2538
+ : languages[0]?.code;
2539
+ const activeLanguage = activeLanguageCode
2540
+ ? languages.find((lang) => lang.code === activeLanguageCode)
2541
+ : null;
2542
+ const progress = this.getLocalizationProgress(activeLanguageCode || '');
2543
+ const includeCategories = this.translationFilters.categories;
2544
+ const settingsPanelId = 'translation-settings-panel';
2545
+ const remainingTranslations = Math.max(
2546
+ progress.total - progress.localized,
2547
+ 0
2548
+ );
2549
+ const hasTranslations = progress.total > 0;
2550
+ const hasPendingTranslations = remainingTranslations > 0;
2551
+ const autoTranslateButtonLabel = this.autoTranslating
2552
+ ? 'Stop Auto Translate'
2553
+ : 'Auto Translate';
2554
+ const autoTranslateButtonDisabled =
2555
+ !this.autoTranslating && !hasTranslations;
2556
+
2557
+ return html`
2558
+ <temba-floating-window
2559
+ id="localization-window"
2560
+ header="Translations"
2561
+ .width=${360}
2562
+ .maxHeight=${600}
2563
+ .top=${20}
2564
+ color="#6b7280"
2565
+ .hidden=${this.localizationWindowHidden}
2566
+ @temba-dialog-hidden=${this.handleLocalizationWindowClosed}
2567
+ >
2568
+ <div class="localization-window-content">
2569
+ <div class="localization-header">
2570
+ Translate from <strong>${baseName}</strong> to the languages below.
2571
+ Closing this window returns you to editing in ${baseName}.
2572
+ </div>
2573
+ <div class="localization-language-row">
2574
+ <temba-select
2575
+ flavor="small"
2576
+ class="localization-language-select"
2577
+ .values=${activeLanguage
2578
+ ? [{ name: activeLanguage.name, value: activeLanguage.code }]
2579
+ : []}
2580
+ @change=${this.handleLocalizationLanguageSelectChange}
2581
+ >
2582
+ ${languages.map(
2583
+ (lang) => html`<temba-option
2584
+ value="${lang.code}"
2585
+ name="${lang.name}"
2586
+ ></temba-option>`
2587
+ )}
2588
+ </temba-select>
2589
+ <button
2590
+ class="auto-translate-button"
2591
+ type="button"
2592
+ ?disabled=${autoTranslateButtonDisabled}
2593
+ @click=${this.handleAutoTranslateClick}
2594
+ >
2595
+ ${autoTranslateButtonLabel}
2596
+ </button>
2597
+ </div>
2598
+ <div class="localization-progress">
2599
+ <div class="localization-progress-summary">
2600
+ ${this.autoTranslating
2601
+ ? html`<temba-loading units="3" size="8"></temba-loading>
2602
+ <span>Auto translating remaining text…</span>`
2603
+ : !hasTranslations
2604
+ ? html`<span>
2605
+ Add content or enable more options to start translating.
2606
+ </span>`
2607
+ : hasPendingTranslations
2608
+ ? html`<span>
2609
+ ${progress.localized} of ${progress.total} items translated
2610
+ </span>`
2611
+ : html`<span>All items are translated.</span>`}
2612
+ </div>
2613
+ ${this.autoTranslateError
2614
+ ? html`<div class="auto-translate-error">
2615
+ ${this.autoTranslateError}
2616
+ </div>`
2617
+ : ''}
2618
+ <div class="localization-progress-bar-row">
2619
+ <div
2620
+ class="localization-progress-trigger"
2621
+ role="button"
2622
+ tabindex="0"
2623
+ aria-expanded="${this.translationSettingsExpanded}"
2624
+ aria-controls="${settingsPanelId}"
2625
+ @click=${this.handleLocalizationProgressToggleClick}
2626
+ @keydown=${this.handleLocalizationProgressToggleKeydown}
2627
+ >
2628
+ <temba-progress
2629
+ .current=${progress.localized}
2630
+ .total=${Math.max(progress.total, 1)}
2631
+ .animated=${false}
2632
+ ></temba-progress>
2633
+ </div>
2634
+ <button
2635
+ class="translation-settings-toggle"
2636
+ type="button"
2637
+ @click=${this.toggleTranslationSettings}
2638
+ aria-expanded="${this.translationSettingsExpanded}"
2639
+ aria-controls="${settingsPanelId}"
2640
+ >
2641
+ <span
2642
+ class="translation-settings-arrow ${this
2643
+ .translationSettingsExpanded
2644
+ ? 'expanded'
2645
+ : ''}"
2646
+ ></span>
2647
+ </button>
2648
+ </div>
2649
+ ${this.translationSettingsExpanded
2650
+ ? html`<div id="${settingsPanelId}" class="translation-settings">
2651
+ <div class="translation-settings-row">
2652
+ <temba-checkbox
2653
+ name="include-categories"
2654
+ label="Include categories"
2655
+ ?checked=${includeCategories}
2656
+ style="--checkbox-padding:5px; border-radius:var(--curvature);"
2657
+ @change=${this.handleIncludeCategoriesChange}
2658
+ ></temba-checkbox>
2659
+ </div>
2660
+ </div>`
2661
+ : ''}
2662
+ </div>
2663
+ </div>
2664
+ </temba-floating-window>
2665
+ `;
2666
+ }
2667
+
2668
+ private renderAutoTranslateDialog(): TemplateResult | string {
2669
+ if (!this.autoTranslateDialogOpen) {
2670
+ return html``;
2671
+ }
2672
+
2673
+ const selectedModel = this.autoTranslateModel
2674
+ ? [this.autoTranslateModel]
2675
+ : [];
2676
+ const disableTranslate = !this.autoTranslateModel;
2677
+
2678
+ return html`
2679
+ <temba-dialog
2680
+ header="Auto translate"
2681
+ .open=${this.autoTranslateDialogOpen}
2682
+ primaryButtonName="Translate"
2683
+ cancelButtonName="Cancel"
2684
+ size="small"
2685
+ .disabled=${disableTranslate}
2686
+ @temba-button-clicked=${this.handleAutoTranslateDialogButton}
2687
+ >
2688
+ <div class="auto-translate-dialog-content">
2689
+ <p>
2690
+ We'll send any untranslated text to the selected AI model and save
2691
+ the responses automatically.
2692
+ </p>
2693
+ <div class="auto-translate-models">
2694
+ <temba-select
2695
+ class="auto-translate-model-select"
2696
+ endpoint="${AUTO_TRANSLATE_MODELS_ENDPOINT}"
2697
+ .valueKey=${'uuid'}
2698
+ .values=${selectedModel}
2699
+ ?searchable=${true}
2700
+ ?clearable=${true}
2701
+ placeholder="Select an AI model"
2702
+ @change=${this.handleAutoTranslateModelChange}
2703
+ ></temba-select>
2704
+ </div>
2705
+ <p>Only text without translations will be sent.</p>
2706
+ ${this.autoTranslateError
2707
+ ? html`<div class="auto-translate-error">
2708
+ ${this.autoTranslateError}
2709
+ </div>`
2710
+ : ''}
2711
+ </div>
2712
+ </temba-dialog>
2713
+ `;
2714
+ }
2715
+
2716
+ private renderLocalizationTab(): TemplateResult | string {
2717
+ const languages = this.getLocalizationLanguages();
2718
+ if (!languages.length) {
2719
+ return html``;
2720
+ }
2721
+
2722
+ return html`
2723
+ <temba-floating-tab
2724
+ id="localization-tab"
2725
+ icon="language"
2726
+ label="Translate Flow"
2727
+ color="#6b7280"
2728
+ .hidden=${!this.localizationWindowHidden}
2729
+ @temba-button-clicked=${this.handleLocalizationTabClick}
2730
+ ></temba-floating-tab>
2731
+ `;
2732
+ }
2733
+
1702
2734
  public render(): TemplateResult {
1703
2735
  // we have to embed our own style since we are in light DOM
1704
2736
  const style = html`<style>
@@ -1708,7 +2740,8 @@ export class Editor extends RapidElement {
1708
2740
 
1709
2741
  const stickies = this.definition?._ui?.stickies || {};
1710
2742
 
1711
- return html`${style}
2743
+ return html`${style} ${this.renderLocalizationWindow()}
2744
+ ${this.renderAutoTranslateDialog()}
1712
2745
  <div id="editor">
1713
2746
  <div
1714
2747
  id="grid"
@@ -1792,6 +2825,10 @@ export class Editor extends RapidElement {
1792
2825
  : ''}
1793
2826
 
1794
2827
  <temba-canvas-menu></temba-canvas-menu>
1795
- <temba-node-type-selector></temba-node-type-selector> `;
2828
+ <temba-node-type-selector
2829
+ .flowType=${this.flowType}
2830
+ .features=${this.features}
2831
+ ></temba-node-type-selector>
2832
+ ${this.renderLocalizationTab()} `;
1796
2833
  }
1797
2834
  }