@nyaruka/temba-components 0.156.12 → 0.156.13

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.
@@ -29,14 +29,18 @@ import {
29
29
  snapToGrid
30
30
  } from './utils';
31
31
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
32
- import { getTranslatableCategoriesForNode } from './categoryLocalization';
33
32
  import { PRIMARY_LANGUAGE_OPTION_VALUE } from './EditorToolbar';
33
+ import {
34
+ buildTranslationBundles,
35
+ getTranslationCounts
36
+ } from './flow-translations';
34
37
  import { calculateLayeredLayout, placeStickyNotes } from './reflow';
35
38
  import type { RevisionsWindow } from './RevisionsWindow';
36
39
 
37
40
  import {
38
41
  ACTION_GROUP_METADATA,
39
42
  CONTEXT_MENU_SHORTCUTS,
43
+ Features,
40
44
  FlowType,
41
45
  FlowTypes
42
46
  } from './types';
@@ -85,22 +89,6 @@ export interface SelectionBox {
85
89
  endY: number;
86
90
  }
87
91
 
88
- type TranslationType = 'property' | 'category';
89
-
90
- interface TranslationEntry {
91
- uuid: string;
92
- type: TranslationType;
93
- attribute: string;
94
- from: string;
95
- to: string | null;
96
- }
97
-
98
- interface TranslationBundle {
99
- nodeUuid: string;
100
- actionUuid?: string;
101
- translations: TranslationEntry[];
102
- }
103
-
104
92
  export type ToolbarAction =
105
93
  | { action: 'view-change'; view: 'flow' | 'table' }
106
94
  | { action: 'zoom-in' }
@@ -109,7 +97,8 @@ export type ToolbarAction =
109
97
  | { action: 'zoom-to-full' }
110
98
  | { action: 'revisions' }
111
99
  | { action: 'search' }
112
- | { action: 'language-change'; isPrimary?: boolean; languageCode?: string };
100
+ | { action: 'language-change'; isPrimary?: boolean; languageCode?: string }
101
+ | { action: 'auto-translate' };
113
102
  const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
114
103
 
115
104
  // How long the pending-changes auto-save countdown runs (in ms).
@@ -211,6 +200,10 @@ export class Editor extends RapidElement {
211
200
  @property({ type: Array })
212
201
  public features: string[] = [];
213
202
 
203
+ private get autoTranslateEnabled(): boolean {
204
+ return this.features?.includes(Features.AUTO_TRANSLATE) ?? false;
205
+ }
206
+
214
207
  private activityTimer: number | null = null;
215
208
  private activityInterval = 100; // Start with 100ms interval for fast initial load
216
209
 
@@ -303,6 +296,9 @@ export class Editor extends RapidElement {
303
296
  @state()
304
297
  public zoom = 1.0;
305
298
 
299
+ @state()
300
+ private autoTranslating = false;
301
+
306
302
  // Non-reactive flag set in willUpdate to suppress the debouncedSave
307
303
  // call in updated() when the dirtyDate change comes from a reflow/copy
308
304
  private _suppressDirtySave = false;
@@ -897,54 +893,6 @@ export class Editor extends RapidElement {
897
893
  transition: opacity 0.2s ease;
898
894
  }
899
895
 
900
- .auto-translate-button {
901
- background: var(--color-primary-dark);
902
- border: none;
903
- color: #fff;
904
- padding: 10px 12px;
905
- border-radius: var(--curvature);
906
- font-size: 12px;
907
- font-weight: 600;
908
- cursor: pointer;
909
- transition: opacity 0.2s ease;
910
- }
911
-
912
- .auto-translate-button[disabled] {
913
- opacity: 0.5;
914
- cursor: not-allowed;
915
- }
916
-
917
- .auto-translate-error {
918
- font-size: 12px;
919
- color: #b91c1c;
920
- }
921
-
922
- .auto-translate-dialog-content {
923
- padding: 20px;
924
- display: flex;
925
- flex-direction: column;
926
- gap: 12px;
927
- font-size: 14px;
928
- color: #374151;
929
- }
930
-
931
- .auto-translate-dialog-content p {
932
- margin: 0;
933
- }
934
-
935
- .auto-translate-loading {
936
- display: flex;
937
- align-items: center;
938
- gap: 8px;
939
- font-size: 13px;
940
- color: #6b7280;
941
- }
942
-
943
- .auto-translate-empty {
944
- font-size: 13px;
945
- color: #6b7280;
946
- }
947
-
948
896
  .localization-empty {
949
897
  font-size: 13px;
950
898
  color: #9ca3af;
@@ -1799,239 +1747,8 @@ export class Editor extends RapidElement {
1799
1747
  total: number;
1800
1748
  localized: number;
1801
1749
  } {
1802
- if (
1803
- !this.definition ||
1804
- !languageCode ||
1805
- languageCode === this.definition.language
1806
- ) {
1807
- return { total: 0, localized: 0 };
1808
- }
1809
-
1810
- const bundles = this.buildTranslationBundles(languageCode);
1811
- return this.getTranslationCounts(bundles);
1812
- }
1813
-
1814
- private getLanguageLocalization(languageCode: string): Record<string, any> {
1815
- if (!this.definition?.localization) {
1816
- return {};
1817
- }
1818
- return this.definition.localization[languageCode] || {};
1819
- }
1820
-
1821
- private buildTranslationBundles(
1822
- languageCode: string = this.languageCode
1823
- ): TranslationBundle[] {
1824
- if (
1825
- !this.definition ||
1826
- !languageCode ||
1827
- languageCode === this.definition.language
1828
- ) {
1829
- return [];
1830
- }
1831
-
1832
- const languageLocalization = this.getLanguageLocalization(languageCode);
1833
- const bundles: TranslationBundle[] = [];
1834
-
1835
- this.definition.nodes.forEach((node) => {
1836
- node.actions?.forEach((action) => {
1837
- const config = ACTION_CONFIG[action.type];
1838
- if (!config?.localizable || config.localizable.length === 0) {
1839
- return;
1840
- }
1841
-
1842
- // For send_msg actions, only count 'text' for progress tracking
1843
- // (quick_replies and attachments are still localizable but don't count toward progress)
1844
- const localizableKeys =
1845
- action.type === 'send_msg'
1846
- ? config.localizable.filter((key) => key === 'text')
1847
- : config.localizable;
1848
-
1849
- const translations = this.findTranslations(
1850
- 'property',
1851
- action.uuid,
1852
- localizableKeys,
1853
- action,
1854
- languageLocalization
1855
- );
1856
-
1857
- if (translations.length > 0) {
1858
- bundles.push({
1859
- nodeUuid: node.uuid,
1860
- actionUuid: action.uuid,
1861
- translations
1862
- });
1863
- }
1864
- });
1865
-
1866
- const nodeUI = this.definition._ui?.nodes?.[node.uuid];
1867
- const nodeType = nodeUI?.type;
1868
- if (!nodeType) {
1869
- return;
1870
- }
1871
-
1872
- // Include rule (case argument) translations when localizeRules is set
1873
- if (nodeUI?.config?.localizeRules && node.router?.cases?.length) {
1874
- const ruleTranslations = node.router.cases
1875
- .filter((c) => c.arguments?.length > 0 && c.arguments.some((a) => a))
1876
- .flatMap((c) =>
1877
- this.findTranslations(
1878
- 'property',
1879
- c.uuid,
1880
- ['arguments'],
1881
- c,
1882
- languageLocalization
1883
- )
1884
- );
1885
-
1886
- if (ruleTranslations.length > 0) {
1887
- bundles.push({
1888
- nodeUuid: node.uuid,
1889
- translations: ruleTranslations
1890
- });
1891
- }
1892
- }
1893
-
1894
- const nodeConfig = NODE_CONFIG[nodeType];
1895
- if (
1896
- nodeUI?.config?.localizeCategories &&
1897
- nodeConfig?.localizable === 'categories' &&
1898
- node.router?.categories?.length
1899
- ) {
1900
- const translatableCategories = getTranslatableCategoriesForNode(
1901
- nodeType,
1902
- node.router.categories
1903
- );
1904
- const categoryTranslations = translatableCategories.flatMap(
1905
- (category) =>
1906
- this.findTranslations(
1907
- 'category',
1908
- category.uuid,
1909
- ['name'],
1910
- category,
1911
- languageLocalization
1912
- )
1913
- );
1914
-
1915
- if (categoryTranslations.length > 0) {
1916
- bundles.push({
1917
- nodeUuid: node.uuid,
1918
- translations: categoryTranslations
1919
- });
1920
- }
1921
- }
1922
- });
1923
-
1924
- return bundles;
1925
- }
1926
-
1927
- private findTranslations(
1928
- type: TranslationType,
1929
- uuid: string,
1930
- localizeableKeys: string[],
1931
- source: any,
1932
- localization: Record<string, any>
1933
- ): TranslationEntry[] {
1934
- const translations: TranslationEntry[] = [];
1935
-
1936
- localizeableKeys.forEach((attribute) => {
1937
- if (attribute === 'quick_replies') {
1938
- return;
1939
- }
1940
-
1941
- const pathSegments = attribute.split('.');
1942
- let from: any = source;
1943
- let to: any = [];
1944
-
1945
- while (pathSegments.length > 0 && from) {
1946
- if (from.uuid) {
1947
- to = localization[from.uuid];
1948
- }
1949
-
1950
- const path = pathSegments.shift();
1951
- if (!path) {
1952
- break;
1953
- }
1954
-
1955
- if (to) {
1956
- to = to[path];
1957
- }
1958
- from = from[path];
1959
- }
1960
-
1961
- if (!from) {
1962
- return;
1963
- }
1964
-
1965
- const fromValue = this.formatTranslationValue(from);
1966
- if (!fromValue) {
1967
- return;
1968
- }
1969
-
1970
- const toValue = to ? this.formatTranslationValue(to) : null;
1971
-
1972
- translations.push({
1973
- uuid,
1974
- type,
1975
- attribute,
1976
- from: fromValue,
1977
- to: toValue
1978
- });
1979
- });
1980
-
1981
- return translations;
1982
- }
1983
-
1984
- private formatTranslationValue(value: any): string | null {
1985
- if (value === null || value === undefined) {
1986
- return null;
1987
- }
1988
-
1989
- if (Array.isArray(value)) {
1990
- const normalized = value
1991
- .map((entry) => this.formatTranslationValue(entry))
1992
- .filter((entry) => !!entry) as string[];
1993
- return normalized.length > 0 ? normalized.join(', ') : null;
1994
- }
1995
-
1996
- if (typeof value === 'object') {
1997
- if ('name' in value && value.name) {
1998
- return String(value.name);
1999
- }
2000
-
2001
- if ('arguments' in value && Array.isArray(value.arguments)) {
2002
- return value.arguments.join(' ');
2003
- }
2004
-
2005
- return null;
2006
- }
2007
-
2008
- if (typeof value === 'number') {
2009
- return value.toString();
2010
- }
2011
-
2012
- if (typeof value === 'string') {
2013
- const trimmed = value.trim();
2014
- return trimmed.length > 0 ? trimmed : null;
2015
- }
2016
-
2017
- return null;
2018
- }
2019
-
2020
- private getTranslationCounts(bundles: TranslationBundle[]): {
2021
- total: number;
2022
- localized: number;
2023
- } {
2024
- return bundles.reduce(
2025
- (counts, bundle) => {
2026
- bundle.translations.forEach((translation) => {
2027
- counts.total += 1;
2028
- if (translation.to && translation.to.trim().length > 0) {
2029
- counts.localized += 1;
2030
- }
2031
- });
2032
- return counts;
2033
- },
2034
- { total: 0, localized: 0 }
1750
+ return getTranslationCounts(
1751
+ buildTranslationBundles(this.definition, languageCode)
2035
1752
  );
2036
1753
  }
2037
1754
 
@@ -2042,6 +1759,18 @@ export class Editor extends RapidElement {
2042
1759
  );
2043
1760
  }
2044
1761
 
1762
+ private handleAutoTranslateClick(): void {
1763
+ if (!this.autoTranslateEnabled || this.viewingRevision) {
1764
+ return;
1765
+ }
1766
+ const at = this.querySelector('temba-auto-translate') as any;
1767
+ at?.start();
1768
+ }
1769
+
1770
+ private handleAutoTranslateChanged(e: CustomEvent): void {
1771
+ this.autoTranslating = !!e.detail?.running;
1772
+ }
1773
+
2045
1774
  disconnectedCallback(): void {
2046
1775
  super.disconnectedCallback();
2047
1776
  this.zoomManager.teardownLoupe();
@@ -3961,6 +3690,12 @@ export class Editor extends RapidElement {
3961
3690
  })
3962
3691
  ];
3963
3692
 
3693
+ const hasPendingTranslations =
3694
+ this.autoTranslateEnabled &&
3695
+ Boolean(activeLanguage) &&
3696
+ progress.total > 0 &&
3697
+ progress.localized < progress.total;
3698
+
3964
3699
  return html`
3965
3700
  <temba-editor-toolbar
3966
3701
  ?message-view=${this.showMessageTable}
@@ -3976,6 +3711,10 @@ export class Editor extends RapidElement {
3976
3711
  ?is-base-language=${isBaseSelected}
3977
3712
  .languagePercent=${percent}
3978
3713
  ?show-localization-tools=${Boolean(activeLanguage)}
3714
+ ?has-pending-translations=${hasPendingTranslations ||
3715
+ this.autoTranslating}
3716
+ ?auto-translate-disabled=${this.viewingRevision}
3717
+ ?auto-translating=${this.autoTranslating}
3979
3718
  @temba-button-clicked=${this.handleToolbarAction}
3980
3719
  ></temba-editor-toolbar>
3981
3720
  `;
@@ -4011,6 +3750,9 @@ export class Editor extends RapidElement {
4011
3750
  case 'search':
4012
3751
  this.openFlowSearch();
4013
3752
  break;
3753
+ case 'auto-translate':
3754
+ this.handleAutoTranslateClick();
3755
+ break;
4014
3756
  case 'language-change':
4015
3757
  if (detail.isPrimary) {
4016
3758
  this.handleLanguageChange(this.definition?.language || '');
@@ -4186,6 +3928,14 @@ export class Editor extends RapidElement {
4186
3928
  @temba-revision-reverted=${this.handleRevisionReverted}
4187
3929
  @temba-revisions-closed=${this.handleRevisionsClosed}
4188
3930
  ></temba-revisions-window>
3931
+ ${this.autoTranslateEnabled
3932
+ ? html`<temba-auto-translate
3933
+ .definition=${this.definition}
3934
+ language-code=${this.languageCode}
3935
+ ?disabled=${this.viewingRevision}
3936
+ @temba-auto-translate-changed=${this.handleAutoTranslateChanged}
3937
+ ></temba-auto-translate>`
3938
+ : ''}
4189
3939
  <div id="editor-container">
4190
3940
  ${this.renderToolbarElement()}
4191
3941
  <div id="editor">
@@ -249,6 +249,15 @@ export class EditorToolbar extends RapidElement {
249
249
  @property({ type: Boolean, attribute: 'show-localization-tools' })
250
250
  showLocalizationTools = false;
251
251
 
252
+ @property({ type: Boolean, attribute: 'has-pending-translations' })
253
+ hasPendingTranslations = false;
254
+
255
+ @property({ type: Boolean, attribute: 'auto-translate-disabled' })
256
+ autoTranslateDisabled = false;
257
+
258
+ @property({ type: Boolean, attribute: 'auto-translating' })
259
+ autoTranslating = false;
260
+
252
261
  @state()
253
262
  private showLanguageOptions = false;
254
263
 
@@ -559,7 +568,34 @@ export class EditorToolbar extends RapidElement {
559
568
  }
560
569
 
561
570
  private renderTranslationTools(): TemplateResult {
562
- // auto translate button hidden pending backend changes
563
- return html``;
571
+ if (!this.hasPendingTranslations && !this.autoTranslating) {
572
+ return html``;
573
+ }
574
+ const label = this.autoTranslating
575
+ ? 'Stop auto translate'
576
+ : 'Auto translate';
577
+ return html`
578
+ <div class="toolbar-translation">
579
+ ${this.renderTip(
580
+ label,
581
+ html`
582
+ <button
583
+ class="toolbar-btn language-tool ${this.autoTranslating
584
+ ? 'active'
585
+ : ''}"
586
+ @click=${() => this.fireToolbarAction('auto-translate')}
587
+ ?disabled=${this.autoTranslateDisabled}
588
+ aria-label=${label}
589
+ >
590
+ <temba-icon
591
+ name=${this.autoTranslating ? 'progress_spinner' : Icon.ai}
592
+ size="1"
593
+ ?spin=${this.autoTranslating}
594
+ ></temba-icon>
595
+ </button>
596
+ `
597
+ )}
598
+ </div>
599
+ `;
564
600
  }
565
601
  }
@@ -0,0 +1,229 @@
1
+ import { FlowDefinition } from '../store/flow-definition';
2
+ import { ACTION_CONFIG, NODE_CONFIG } from './config';
3
+ import { getTranslatableCategoriesForNode } from './categoryLocalization';
4
+
5
+ export type TranslationType = 'property' | 'category';
6
+
7
+ export interface TranslationEntry {
8
+ uuid: string;
9
+ type: TranslationType;
10
+ attribute: string;
11
+ from: string;
12
+ to: string | null;
13
+ }
14
+
15
+ export interface TranslationBundle {
16
+ nodeUuid: string;
17
+ actionUuid?: string;
18
+ translations: TranslationEntry[];
19
+ }
20
+
21
+ export function formatTranslationValue(value: any): string | null {
22
+ if (value === null || value === undefined) {
23
+ return null;
24
+ }
25
+
26
+ if (Array.isArray(value)) {
27
+ const normalized = value
28
+ .map((entry) => formatTranslationValue(entry))
29
+ .filter((entry) => !!entry) as string[];
30
+ return normalized.length > 0 ? normalized.join(', ') : null;
31
+ }
32
+
33
+ if (typeof value === 'object') {
34
+ if ('name' in value && value.name) {
35
+ return String(value.name);
36
+ }
37
+ if ('arguments' in value && Array.isArray(value.arguments)) {
38
+ return value.arguments.join(' ');
39
+ }
40
+ return null;
41
+ }
42
+
43
+ if (typeof value === 'number') {
44
+ return value.toString();
45
+ }
46
+
47
+ if (typeof value === 'string') {
48
+ const trimmed = value.trim();
49
+ return trimmed.length > 0 ? trimmed : null;
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ export function findTranslations(
56
+ type: TranslationType,
57
+ uuid: string,
58
+ localizableKeys: string[],
59
+ source: any,
60
+ localization: Record<string, any>
61
+ ): TranslationEntry[] {
62
+ const translations: TranslationEntry[] = [];
63
+
64
+ localizableKeys.forEach((attribute) => {
65
+ if (attribute === 'quick_replies') {
66
+ return;
67
+ }
68
+
69
+ const pathSegments = attribute.split('.');
70
+ let from: any = source;
71
+ let to: any = [];
72
+
73
+ while (pathSegments.length > 0 && from) {
74
+ if (from.uuid) {
75
+ to = localization[from.uuid];
76
+ }
77
+
78
+ const path = pathSegments.shift();
79
+ if (!path) {
80
+ break;
81
+ }
82
+
83
+ if (to) {
84
+ to = to[path];
85
+ }
86
+ from = from[path];
87
+ }
88
+
89
+ if (!from) {
90
+ return;
91
+ }
92
+
93
+ const fromValue = formatTranslationValue(from);
94
+ if (!fromValue) {
95
+ return;
96
+ }
97
+
98
+ const toValue = to ? formatTranslationValue(to) : null;
99
+
100
+ translations.push({
101
+ uuid,
102
+ type,
103
+ attribute,
104
+ from: fromValue,
105
+ to: toValue
106
+ });
107
+ });
108
+
109
+ return translations;
110
+ }
111
+
112
+ export function buildTranslationBundles(
113
+ definition: FlowDefinition | null | undefined,
114
+ languageCode: string
115
+ ): TranslationBundle[] {
116
+ if (!definition || !languageCode || languageCode === definition.language) {
117
+ return [];
118
+ }
119
+
120
+ const languageLocalization = definition.localization?.[languageCode] || {};
121
+ const bundles: TranslationBundle[] = [];
122
+
123
+ definition.nodes.forEach((node) => {
124
+ node.actions?.forEach((action) => {
125
+ const config = ACTION_CONFIG[action.type];
126
+ if (!config?.localizable || config.localizable.length === 0) {
127
+ return;
128
+ }
129
+
130
+ // For send_msg actions, only count 'text' for progress tracking
131
+ // (quick_replies and attachments are still localizable but don't count toward progress)
132
+ const localizableKeys =
133
+ action.type === 'send_msg'
134
+ ? config.localizable.filter((key) => key === 'text')
135
+ : config.localizable;
136
+
137
+ const translations = findTranslations(
138
+ 'property',
139
+ action.uuid,
140
+ localizableKeys,
141
+ action,
142
+ languageLocalization
143
+ );
144
+
145
+ if (translations.length > 0) {
146
+ bundles.push({
147
+ nodeUuid: node.uuid,
148
+ actionUuid: action.uuid,
149
+ translations
150
+ });
151
+ }
152
+ });
153
+
154
+ const nodeUI = definition._ui?.nodes?.[node.uuid];
155
+ const nodeType = nodeUI?.type;
156
+ if (!nodeType) {
157
+ return;
158
+ }
159
+
160
+ if (nodeUI?.config?.localizeRules && node.router?.cases?.length) {
161
+ const ruleTranslations = node.router.cases
162
+ .filter((c) => c.arguments?.length > 0 && c.arguments.some((a) => a))
163
+ .flatMap((c) =>
164
+ findTranslations(
165
+ 'property',
166
+ c.uuid,
167
+ ['arguments'],
168
+ c,
169
+ languageLocalization
170
+ )
171
+ );
172
+
173
+ if (ruleTranslations.length > 0) {
174
+ bundles.push({
175
+ nodeUuid: node.uuid,
176
+ translations: ruleTranslations
177
+ });
178
+ }
179
+ }
180
+
181
+ const nodeConfig = NODE_CONFIG[nodeType];
182
+ if (
183
+ nodeUI?.config?.localizeCategories &&
184
+ nodeConfig?.localizable === 'categories' &&
185
+ node.router?.categories?.length
186
+ ) {
187
+ const translatableCategories = getTranslatableCategoriesForNode(
188
+ nodeType,
189
+ node.router.categories
190
+ );
191
+ const categoryTranslations = translatableCategories.flatMap((category) =>
192
+ findTranslations(
193
+ 'category',
194
+ category.uuid,
195
+ ['name'],
196
+ category,
197
+ languageLocalization
198
+ )
199
+ );
200
+
201
+ if (categoryTranslations.length > 0) {
202
+ bundles.push({
203
+ nodeUuid: node.uuid,
204
+ translations: categoryTranslations
205
+ });
206
+ }
207
+ }
208
+ });
209
+
210
+ return bundles;
211
+ }
212
+
213
+ export function getTranslationCounts(bundles: TranslationBundle[]): {
214
+ total: number;
215
+ localized: number;
216
+ } {
217
+ return bundles.reduce(
218
+ (counts, bundle) => {
219
+ bundle.translations.forEach((translation) => {
220
+ counts.total += 1;
221
+ if (translation.to && translation.to.trim().length > 0) {
222
+ counts.localized += 1;
223
+ }
224
+ });
225
+ return counts;
226
+ },
227
+ { total: 0, localized: 0 }
228
+ );
229
+ }