@nyaruka/temba-components 0.156.2 → 0.156.4

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.
@@ -0,0 +1,566 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { CustomEventType } from '../interfaces';
5
+ import { Icon } from '../Icons';
6
+
7
+ export interface LanguageOption {
8
+ name: string;
9
+ value: string;
10
+ percent?: number;
11
+ }
12
+
13
+ export const PRIMARY_LANGUAGE_OPTION_VALUE = '__primary_language__';
14
+
15
+ export class EditorToolbar extends RapidElement {
16
+ static get styles() {
17
+ return css`
18
+ :host {
19
+ display: block;
20
+ }
21
+
22
+ .editor-toolbar {
23
+ --toolbar-control-height: 28px;
24
+ --toolbar-translation-control-height: 28px;
25
+ display: flex;
26
+ align-items: center;
27
+ padding: 6px 12px;
28
+ background: #fff;
29
+ border-bottom: 1px solid #e8e8e8;
30
+ flex-shrink: 0;
31
+ gap: 8px;
32
+ }
33
+
34
+ .toolbar-left {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 2px;
38
+ }
39
+
40
+ .toolbar-right {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 2px;
44
+ margin-left: auto;
45
+ }
46
+
47
+ .toolbar-btn {
48
+ width: var(--toolbar-control-height);
49
+ height: var(--toolbar-control-height);
50
+ border: none;
51
+ background: transparent;
52
+ border-radius: var(--curvature);
53
+ cursor: pointer;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ padding: 0;
58
+ color: #888;
59
+ font-size: 16px;
60
+ line-height: 1;
61
+ outline: none;
62
+ }
63
+
64
+ .toolbar-btn:focus {
65
+ outline: none;
66
+ }
67
+
68
+ .toolbar-btn:focus-visible {
69
+ outline: 2px solid #0064c8;
70
+ outline-offset: 2px;
71
+ }
72
+
73
+ .toolbar-btn:hover {
74
+ background: rgba(0, 0, 0, 0.06);
75
+ color: #555;
76
+ }
77
+
78
+ .toolbar-btn:disabled {
79
+ opacity: 0.3;
80
+ cursor: default;
81
+ background: transparent;
82
+ }
83
+
84
+ .toolbar-btn.active {
85
+ background: rgba(0, 100, 200, 0.1);
86
+ color: #0064c8;
87
+ }
88
+
89
+ .toolbar-btn.active:hover {
90
+ background: rgba(0, 100, 200, 0.15);
91
+ }
92
+
93
+ .toolbar-tip {
94
+ display: flex;
95
+ align-items: center;
96
+ }
97
+
98
+ .toolbar-divider {
99
+ width: 1px;
100
+ height: 16px;
101
+ background: #e0e0e0;
102
+ margin: 0 4px;
103
+ }
104
+
105
+ .toolbar-group {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 4px;
109
+ height: var(--toolbar-control-height);
110
+ box-sizing: border-box;
111
+ padding: 0 3px;
112
+ border: 1px solid #d7dce2;
113
+ border-radius: calc(var(--curvature) + 2px);
114
+ background: #f7f9fb;
115
+ }
116
+
117
+ .toolbar-group-divider {
118
+ width: 1px;
119
+ height: 18px;
120
+ background: #d7dce2;
121
+ margin: 0 2px;
122
+ }
123
+
124
+ .toolbar-language {
125
+ position: relative;
126
+ display: flex;
127
+ align-items: center;
128
+ }
129
+
130
+ .toolbar-language-group {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 6px;
134
+ margin-left: 2px;
135
+ }
136
+
137
+ .toolbar-zoom-group {
138
+ gap: 2px;
139
+ }
140
+
141
+ .language-pill {
142
+ display: flex;
143
+ align-items: center;
144
+ gap: 6px;
145
+ background: #e9eef4;
146
+ color: #0064c8;
147
+ height: var(--toolbar-translation-control-height);
148
+ padding: 0 8px;
149
+ border-radius: var(--curvature);
150
+ box-sizing: border-box;
151
+ font-size: 13px;
152
+ font-weight: 400;
153
+ white-space: nowrap;
154
+ cursor: pointer;
155
+ --icon-color: #0064c8;
156
+ --icon-size: 16px;
157
+ border: none;
158
+ outline: none;
159
+ }
160
+
161
+ .language-pill:hover {
162
+ filter: brightness(1.04);
163
+ }
164
+
165
+ .language-pill.primary {
166
+ background: #fff;
167
+ border: 1px solid #d7dce2;
168
+ }
169
+
170
+ .language-pill.complete {
171
+ background: #d4f5e0;
172
+ color: #1a7f37;
173
+ --icon-color: #1a7f37;
174
+ }
175
+
176
+ .language-pill-caret {
177
+ margin-left: 1px;
178
+ --icon-color: currentColor;
179
+ --icon-size: 12px;
180
+ }
181
+
182
+ .language-percent {
183
+ display: inline-block;
184
+ font-size: 12px;
185
+ font-weight: 700;
186
+ line-height: 1;
187
+ color: #0064c8;
188
+ white-space: nowrap;
189
+ }
190
+
191
+ .language-pill.complete .language-percent {
192
+ color: #1a7f37;
193
+ }
194
+
195
+ .toolbar-zoom-level {
196
+ font-size: 12px;
197
+ min-width: 40px;
198
+ text-align: center;
199
+ color: #555;
200
+ font-weight: 500;
201
+ }
202
+
203
+ .toolbar-translation {
204
+ display: flex;
205
+ align-items: center;
206
+ gap: 4px;
207
+ }
208
+
209
+ .toolbar-btn.language-tool {
210
+ width: var(--toolbar-translation-control-height);
211
+ height: var(--toolbar-translation-control-height);
212
+ }
213
+ `;
214
+ }
215
+
216
+ @property({ type: Boolean, attribute: 'message-view' })
217
+ messageView = false;
218
+
219
+ @property({ type: Number })
220
+ zoom = 1.0;
221
+
222
+ @property({ type: Boolean, attribute: 'zoom-initialized' })
223
+ zoomInitialized = false;
224
+
225
+ @property({ type: Boolean, attribute: 'zoom-fitted' })
226
+ zoomFitted = false;
227
+
228
+ @property({ type: Boolean, attribute: 'revisions-active' })
229
+ revisionsActive = false;
230
+
231
+ @property({ type: Boolean, attribute: 'is-saving' })
232
+ isSaving = false;
233
+
234
+ @property({ type: Boolean, attribute: 'search-disabled' })
235
+ searchDisabled = false;
236
+
237
+ @property({ type: Array })
238
+ languageOptions: LanguageOption[] = [];
239
+
240
+ @property({ type: String, attribute: 'current-language-name' })
241
+ currentLanguageName = '';
242
+
243
+ @property({ type: Boolean, attribute: 'is-base-language' })
244
+ isBaseLanguage = true;
245
+
246
+ @property({ type: Number, attribute: 'language-percent' })
247
+ languagePercent = 0;
248
+
249
+ @property({ type: Boolean, attribute: 'show-localization-tools' })
250
+ showLocalizationTools = false;
251
+
252
+ @state()
253
+ private showLanguageOptions = false;
254
+
255
+ private fireToolbarAction(action: string, detail: any = {}): void {
256
+ this.fireCustomEvent(CustomEventType.ButtonClicked, { action, ...detail });
257
+ }
258
+
259
+ private handleLanguageIconClick(): void {
260
+ if (this.showLanguageOptions) {
261
+ this.showLanguageOptions = false;
262
+ return;
263
+ }
264
+ this.showLanguageOptions = true;
265
+ requestAnimationFrame(() => {
266
+ const close = () => {
267
+ this.showLanguageOptions = false;
268
+ document.removeEventListener('click', close);
269
+ };
270
+ document.addEventListener('click', close, { once: true });
271
+ });
272
+ }
273
+
274
+ private handleLanguageOptionSelected(event: CustomEvent): void {
275
+ if (!this.showLanguageOptions) return;
276
+ const selected = event.detail?.selected;
277
+ if (selected?.value === PRIMARY_LANGUAGE_OPTION_VALUE) {
278
+ this.fireToolbarAction('language-change', { isPrimary: true });
279
+ } else if (selected?.value) {
280
+ this.fireToolbarAction('language-change', {
281
+ languageCode: selected.value
282
+ });
283
+ }
284
+ this.showLanguageOptions = false;
285
+ }
286
+
287
+ private isMacPlatform(): boolean {
288
+ return (
289
+ typeof navigator !== 'undefined' &&
290
+ /Mac|iPod|iPhone|iPad/.test(navigator.platform)
291
+ );
292
+ }
293
+
294
+ private getSearchShortcutLabel(): string {
295
+ return this.isMacPlatform() ? '⌘F' : 'Ctrl+F';
296
+ }
297
+
298
+ private renderTip(
299
+ text: string | TemplateResult,
300
+ content: TemplateResult
301
+ ): TemplateResult {
302
+ return html`
303
+ <temba-tip
304
+ class="toolbar-tip"
305
+ .text=${typeof text === 'string' ? text : ''}
306
+ .content=${typeof text === 'string' ? null : text}
307
+ position="top"
308
+ >
309
+ ${content}
310
+ </temba-tip>
311
+ `;
312
+ }
313
+
314
+ private renderShortcutLabel(
315
+ label: string,
316
+ shortcut: string
317
+ ): TemplateResult {
318
+ return html`<span style="display:inline-flex; align-items:center; gap:8px;">
319
+ <span>${label}</span>
320
+ <kbd>${shortcut}</kbd>
321
+ </span>`;
322
+ }
323
+
324
+ private renderLanguageOption(
325
+ option: LanguageOption,
326
+ selected: boolean
327
+ ): TemplateResult {
328
+ if (option.value === PRIMARY_LANGUAGE_OPTION_VALUE) {
329
+ const primaryBackground = selected ? '#e1e8ef' : '#edf1f5';
330
+ return html`
331
+ <div
332
+ style="display:flex; align-items:center; justify-content:space-between; gap:8px; background:${primaryBackground}; color:#2f3f52; border-radius:4px; padding:6px 10px;"
333
+ >
334
+ <span>${option.name}</span>
335
+ <span
336
+ style="display:inline-flex; align-items:center; border-radius:999px; background:rgba(47, 63, 82, 0.12); color:#2f3f52; font-size:10px; font-weight:700; line-height:1; padding:3px 7px;"
337
+ >Original</span
338
+ >
339
+ </div>
340
+ `;
341
+ }
342
+
343
+ const isComplete = option.percent === 100;
344
+ const optionBg = isComplete ? '#d4f5e0' : '';
345
+ const optionHoverBg = isComplete ? '#c0edce' : '';
346
+ const optionRadius = isComplete ? 'border-radius:4px;' : '';
347
+ const percentColor = isComplete ? 'color:#1a7f37;' : 'color:#5f6b7a;';
348
+
349
+ return html`
350
+ <div
351
+ style="display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 10px; ${optionBg ? `background:${optionBg};` : ''} ${optionRadius}"
352
+ @mouseenter=${isComplete
353
+ ? (e: MouseEvent) => {
354
+ (e.currentTarget as HTMLElement).style.background = optionHoverBg;
355
+ }
356
+ : null}
357
+ @mouseleave=${isComplete
358
+ ? (e: MouseEvent) => {
359
+ (e.currentTarget as HTMLElement).style.background = optionBg;
360
+ }
361
+ : null}
362
+ >
363
+ <span style="${isComplete ? 'color:#1a7f37;' : ''}"
364
+ >${option.name}</span
365
+ >
366
+ <span style="font-size:11px; font-weight:600; ${percentColor}"
367
+ >${option.percent ?? 0}%</span
368
+ >
369
+ </div>
370
+ `;
371
+ }
372
+
373
+ public render(): TemplateResult {
374
+ const showLanguageControls = this.languageOptions.length > 1;
375
+ const searchTargetLabel = this.messageView
376
+ ? 'Search table'
377
+ : 'Search flow';
378
+
379
+ return html`
380
+ <div class="editor-toolbar">
381
+ <div class="toolbar-left">
382
+ ${this.renderTip(
383
+ 'Flow View',
384
+ html`
385
+ <button
386
+ class="toolbar-btn ${!this.messageView ? 'active' : ''}"
387
+ @click=${() => this.fireToolbarAction('view-change', { view: 'flow' })}
388
+ aria-label="Flow View"
389
+ >
390
+ <temba-icon name="flow" size="1"></temba-icon>
391
+ </button>
392
+ `
393
+ )}
394
+ ${this.renderTip(
395
+ 'Table View',
396
+ html`
397
+ <button
398
+ class="toolbar-btn ${this.messageView ? 'active' : ''}"
399
+ @click=${() => this.fireToolbarAction('view-change', { view: 'table' })}
400
+ aria-label="Table View"
401
+ >
402
+ <temba-icon name=${Icon.quick_replies} size="1"></temba-icon>
403
+ </button>
404
+ `
405
+ )}
406
+ ${showLanguageControls
407
+ ? html`
408
+ <div class="toolbar-divider"></div>
409
+ <div class="toolbar-language-group">
410
+ <div class="toolbar-language">
411
+ ${this.renderTip(
412
+ 'Change language',
413
+ html`
414
+ <button
415
+ class="language-pill ${this.isBaseLanguage ? 'primary' : this.languagePercent === 100 ? 'complete' : ''}"
416
+ id="language-btn"
417
+ @click=${this.handleLanguageIconClick}
418
+ aria-label="Change language"
419
+ >
420
+ <temba-icon name=${Icon.language}></temba-icon>
421
+ <span>${this.currentLanguageName}</span>
422
+ ${!this.isBaseLanguage
423
+ ? html`<span class="language-percent"
424
+ >${this.languagePercent}%</span
425
+ >`
426
+ : ''}
427
+ <temba-icon
428
+ class="language-pill-caret"
429
+ name=${this.showLanguageOptions
430
+ ? Icon.arrow_up
431
+ : Icon.arrow_down}
432
+ ></temba-icon>
433
+ </button>
434
+ `
435
+ )}
436
+ <temba-options
437
+ .anchorTo=${this.shadowRoot?.querySelector('#language-btn') as HTMLElement}
438
+ .options=${this.languageOptions}
439
+ .renderOption=${this.renderLanguageOption}
440
+ ?visible=${this.showLanguageOptions}
441
+ @temba-selection=${this.handleLanguageOptionSelected}
442
+ style="--temba-options-option-margin:4px; --temba-options-option-padding:0; --temba-options-option-radius:4px;"
443
+ min-width="230"
444
+ ></temba-options>
445
+ </div>
446
+ ${this.showLocalizationTools
447
+ ? this.renderTranslationTools()
448
+ : ''}
449
+ </div>
450
+ `
451
+ : ''}
452
+ </div>
453
+ <div class="toolbar-right">
454
+ ${!this.messageView
455
+ ? html`
456
+ ${this.renderTip(
457
+ 'Zoom to fit',
458
+ html`
459
+ <button
460
+ class="toolbar-btn"
461
+ @click=${() => this.fireToolbarAction('zoom-to-fit')}
462
+ ?disabled=${!this.zoomInitialized || this.zoomFitted}
463
+ aria-label="Zoom to fit"
464
+ >
465
+ <temba-icon
466
+ name=${Icon.zoom_fit}
467
+ size="1"
468
+ ></temba-icon>
469
+ </button>
470
+ `
471
+ )}
472
+ <div class="toolbar-divider"></div>
473
+ ${this.renderTip(
474
+ 'Zoom out',
475
+ html`
476
+ <button
477
+ class="toolbar-btn"
478
+ @click=${() => this.fireToolbarAction('zoom-out')}
479
+ ?disabled=${!this.zoomInitialized || this.zoom <= 0.3}
480
+ aria-label="Zoom out"
481
+ >
482
+
483
+ </button>
484
+ `
485
+ )}
486
+ <span class="toolbar-zoom-level"
487
+ >${this.zoomInitialized
488
+ ? `${Math.round(this.zoom * 100)}%`
489
+ : ''}</span
490
+ >
491
+ ${this.renderTip(
492
+ 'Zoom in',
493
+ html`
494
+ <button
495
+ class="toolbar-btn"
496
+ @click=${() => this.fireToolbarAction('zoom-in')}
497
+ ?disabled=${!this.zoomInitialized || this.zoom >= 1.0}
498
+ aria-label="Zoom in"
499
+ >
500
+ +
501
+ </button>
502
+ `
503
+ )}
504
+ <div class="toolbar-divider"></div>
505
+ ${this.renderTip(
506
+ 'Zoom to 100%',
507
+ html`
508
+ <button
509
+ class="toolbar-btn"
510
+ @click=${() => this.fireToolbarAction('zoom-to-full')}
511
+ ?disabled=${!this.zoomInitialized || this.zoom >= 1.0}
512
+ aria-label="Zoom to 100%"
513
+ >
514
+ <temba-icon
515
+ name=${Icon.zoom_in}
516
+ size="1"
517
+ ></temba-icon>
518
+ </button>
519
+ `
520
+ )}
521
+ <div class="toolbar-divider"></div>
522
+ `
523
+ : ''}
524
+ ${this.renderTip(
525
+ 'Revisions',
526
+ html`
527
+ <button
528
+ class="toolbar-btn ${this.revisionsActive ? 'active' : ''}"
529
+ @click=${() => this.fireToolbarAction('revisions')}
530
+ aria-label="Revisions"
531
+ >
532
+ <temba-icon
533
+ name=${this.isSaving ? 'progress_spinner' : 'revisions'}
534
+ size="1"
535
+ ?spin=${this.isSaving}
536
+ ></temba-icon>
537
+ </button>
538
+ `
539
+ )}
540
+ <div class="toolbar-divider"></div>
541
+ ${this.renderTip(
542
+ this.renderShortcutLabel(
543
+ searchTargetLabel,
544
+ this.getSearchShortcutLabel()
545
+ ),
546
+ html`
547
+ <button
548
+ class="toolbar-btn"
549
+ @click=${() => this.fireToolbarAction('search')}
550
+ ?disabled=${this.searchDisabled}
551
+ aria-label=${searchTargetLabel}
552
+ >
553
+ <temba-icon name=${Icon.search} size="1"></temba-icon>
554
+ </button>
555
+ `
556
+ )}
557
+ </div>
558
+ </div>
559
+ `;
560
+ }
561
+
562
+ private renderTranslationTools(): TemplateResult {
563
+ // auto translate button hidden pending backend changes
564
+ return html``;
565
+ }
566
+ }
@@ -221,10 +221,23 @@ function getActionSearchTexts(action: Action): string[] {
221
221
  return texts;
222
222
  }
223
223
 
224
- function getTableMessageSearchTexts(action: SendMsg): string[] {
224
+ function getTableSearchTexts(action: Action): string[] {
225
225
  const texts: string[] = [];
226
- if (action.text) texts.push(action.text);
227
- if (action.quick_replies) texts.push(...action.quick_replies);
226
+ const config = ACTION_CONFIG[action.type];
227
+ if (!config?.localizable) return texts;
228
+ const a = action as Record<string, any>;
229
+ for (const key of config.localizable) {
230
+ const val = a[key];
231
+ if (typeof val === 'string' && val.trim()) {
232
+ texts.push(val);
233
+ } else if (Array.isArray(val)) {
234
+ for (const item of val) {
235
+ if (typeof item === 'string' && item.trim()) {
236
+ texts.push(item);
237
+ }
238
+ }
239
+ }
240
+ }
228
241
  return texts;
229
242
  }
230
243
 
@@ -729,20 +742,37 @@ export class FlowSearch extends LitElement {
729
742
  const nodeUI = this.definition._ui?.nodes[node.uuid];
730
743
  const nodeType = nodeUI?.type || 'execute_actions';
731
744
 
732
- // Message table rows: one row per send_msg action
745
+ // Message table rows: one row per action with localizable fields
733
746
  if (node.actions) {
734
747
  for (const action of node.actions) {
735
- if (action.type !== 'send_msg') {
748
+ const actionConfig = ACTION_CONFIG[action.type];
749
+ if (
750
+ action.type !== 'send_msg' &&
751
+ (!actionConfig?.localizable || actionConfig.localizable.length === 0)
752
+ ) {
736
753
  continue;
737
754
  }
738
755
 
739
- const actionConfig = ACTION_CONFIG[action.type];
740
- const searchAction = localizeAction(
756
+ // Search both original and localized texts, but only add one result per action
757
+ const originalTexts = getTableSearchTexts(action);
758
+ const localizedAction = localizeAction(
741
759
  action,
742
760
  langLocalization?.[action.uuid]
743
- ) as SendMsg;
744
- const texts = getTableMessageSearchTexts(searchAction);
745
- for (const text of texts) {
761
+ );
762
+ const localizedTexts = getTableSearchTexts(localizedAction);
763
+
764
+ // Deduplicate: combine both, originals first
765
+ const allTexts: string[] = [];
766
+ const seen = new Set<string>();
767
+ for (const text of [...originalTexts, ...localizedTexts]) {
768
+ if (!seen.has(text)) {
769
+ seen.add(text);
770
+ allTexts.push(text);
771
+ }
772
+ }
773
+
774
+ let found = false;
775
+ for (const text of allTexts) {
746
776
  const idx = text.toLowerCase().indexOf(query);
747
777
  if (idx !== -1) {
748
778
  results.push({
@@ -754,9 +784,11 @@ export class FlowSearch extends LitElement {
754
784
  matchStart: idx,
755
785
  matchLength: query.length
756
786
  });
787
+ found = true;
757
788
  break;
758
789
  }
759
790
  }
791
+ if (found) continue;
760
792
  }
761
793
  }
762
794