@nyaruka/temba-components 0.156.7 → 0.156.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.156.7",
3
+ "version": "0.156.8",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -22,12 +22,15 @@ import {
22
22
  } from '../utils';
23
23
  import { TEMBA_COMPONENTS_VERSION } from '../version';
24
24
  import {
25
+ getLanguageDisplayName,
25
26
  getNodeBounds,
26
27
  calculateReflowPositions,
27
28
  NodeBounds,
28
29
  snapToGrid
29
30
  } from './utils';
30
31
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
32
+ import { getTranslatableCategoriesForNode } from './categoryLocalization';
33
+ import { PRIMARY_LANGUAGE_OPTION_VALUE } from './EditorToolbar';
31
34
  import { calculateLayeredLayout, placeStickyNotes } from './reflow';
32
35
  import type { RevisionsWindow } from './RevisionsWindow';
33
36
 
@@ -77,6 +80,22 @@ export interface SelectionBox {
77
80
  endY: number;
78
81
  }
79
82
 
83
+ type TranslationType = 'property' | 'category';
84
+
85
+ interface TranslationEntry {
86
+ uuid: string;
87
+ type: TranslationType;
88
+ attribute: string;
89
+ from: string;
90
+ to: string | null;
91
+ }
92
+
93
+ interface TranslationBundle {
94
+ nodeUuid: string;
95
+ actionUuid?: string;
96
+ translations: TranslationEntry[];
97
+ }
98
+
80
99
  export type ToolbarAction =
81
100
  | { action: 'view-change'; view: 'flow' | 'table' }
82
101
  | { action: 'zoom-in' }
@@ -84,7 +103,8 @@ export type ToolbarAction =
84
103
  | { action: 'zoom-to-fit' }
85
104
  | { action: 'zoom-to-full' }
86
105
  | { action: 'revisions' }
87
- | { action: 'search' };
106
+ | { action: 'search' }
107
+ | { action: 'language-change'; isPrimary?: boolean; languageCode?: string };
88
108
  const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
89
109
 
90
110
  // How long the pending-changes auto-save countdown runs (in ms).
@@ -1736,6 +1756,287 @@ export class Editor extends RapidElement {
1736
1756
  zustand.getState().setLanguageCode(languageCode);
1737
1757
  }
1738
1758
 
1759
+ private getAvailableLanguages(): Array<{ code: string; name: string }> {
1760
+ // Use languages from workspace if available
1761
+ if (this.workspace?.languages && this.workspace.languages.length > 0) {
1762
+ return this.workspace.languages
1763
+ .map((code) => ({ code, name: getLanguageDisplayName(code) }))
1764
+ .filter((lang) => lang.code && lang.name);
1765
+ }
1766
+
1767
+ // Fall back to flow definition languages if available
1768
+ if (
1769
+ this.definition?._ui?.languages &&
1770
+ this.definition._ui.languages.length > 0
1771
+ ) {
1772
+ return this.definition._ui.languages.map((lang: any) => ({
1773
+ code: typeof lang === 'string' ? lang : lang.iso || lang.code,
1774
+ name: typeof lang === 'string' ? lang : lang.name
1775
+ }));
1776
+ }
1777
+
1778
+ // No languages available
1779
+ return [];
1780
+ }
1781
+
1782
+ private getLocalizationLanguages(): Array<{ code: string; name: string }> {
1783
+ if (!this.definition) {
1784
+ return [];
1785
+ }
1786
+
1787
+ const baseLanguage = this.definition.language;
1788
+ return this.getAvailableLanguages().filter(
1789
+ (lang) => lang.code !== baseLanguage
1790
+ );
1791
+ }
1792
+
1793
+ private getLocalizationProgress(languageCode: string): {
1794
+ total: number;
1795
+ localized: number;
1796
+ } {
1797
+ if (
1798
+ !this.definition ||
1799
+ !languageCode ||
1800
+ languageCode === this.definition.language
1801
+ ) {
1802
+ return { total: 0, localized: 0 };
1803
+ }
1804
+
1805
+ const bundles = this.buildTranslationBundles(languageCode);
1806
+ return this.getTranslationCounts(bundles);
1807
+ }
1808
+
1809
+ private getLanguageLocalization(languageCode: string): Record<string, any> {
1810
+ if (!this.definition?.localization) {
1811
+ return {};
1812
+ }
1813
+ return this.definition.localization[languageCode] || {};
1814
+ }
1815
+
1816
+ private buildTranslationBundles(
1817
+ languageCode: string = this.languageCode
1818
+ ): TranslationBundle[] {
1819
+ if (
1820
+ !this.definition ||
1821
+ !languageCode ||
1822
+ languageCode === this.definition.language
1823
+ ) {
1824
+ return [];
1825
+ }
1826
+
1827
+ const languageLocalization = this.getLanguageLocalization(languageCode);
1828
+ const bundles: TranslationBundle[] = [];
1829
+
1830
+ this.definition.nodes.forEach((node) => {
1831
+ node.actions?.forEach((action) => {
1832
+ const config = ACTION_CONFIG[action.type];
1833
+ if (!config?.localizable || config.localizable.length === 0) {
1834
+ return;
1835
+ }
1836
+
1837
+ // For send_msg actions, only count 'text' for progress tracking
1838
+ // (quick_replies and attachments are still localizable but don't count toward progress)
1839
+ const localizableKeys =
1840
+ action.type === 'send_msg'
1841
+ ? config.localizable.filter((key) => key === 'text')
1842
+ : config.localizable;
1843
+
1844
+ const translations = this.findTranslations(
1845
+ 'property',
1846
+ action.uuid,
1847
+ localizableKeys,
1848
+ action,
1849
+ languageLocalization
1850
+ );
1851
+
1852
+ if (translations.length > 0) {
1853
+ bundles.push({
1854
+ nodeUuid: node.uuid,
1855
+ actionUuid: action.uuid,
1856
+ translations
1857
+ });
1858
+ }
1859
+ });
1860
+
1861
+ const nodeUI = this.definition._ui?.nodes?.[node.uuid];
1862
+ const nodeType = nodeUI?.type;
1863
+ if (!nodeType) {
1864
+ return;
1865
+ }
1866
+
1867
+ // Include rule (case argument) translations when localizeRules is set
1868
+ if (nodeUI?.config?.localizeRules && node.router?.cases?.length) {
1869
+ const ruleTranslations = node.router.cases
1870
+ .filter((c) => c.arguments?.length > 0 && c.arguments.some((a) => a))
1871
+ .flatMap((c) =>
1872
+ this.findTranslations(
1873
+ 'property',
1874
+ c.uuid,
1875
+ ['arguments'],
1876
+ c,
1877
+ languageLocalization
1878
+ )
1879
+ );
1880
+
1881
+ if (ruleTranslations.length > 0) {
1882
+ bundles.push({
1883
+ nodeUuid: node.uuid,
1884
+ translations: ruleTranslations
1885
+ });
1886
+ }
1887
+ }
1888
+
1889
+ const nodeConfig = NODE_CONFIG[nodeType];
1890
+ if (
1891
+ nodeUI?.config?.localizeCategories &&
1892
+ nodeConfig?.localizable === 'categories' &&
1893
+ node.router?.categories?.length
1894
+ ) {
1895
+ const translatableCategories = getTranslatableCategoriesForNode(
1896
+ nodeType,
1897
+ node.router.categories
1898
+ );
1899
+ const categoryTranslations = translatableCategories.flatMap(
1900
+ (category) =>
1901
+ this.findTranslations(
1902
+ 'category',
1903
+ category.uuid,
1904
+ ['name'],
1905
+ category,
1906
+ languageLocalization
1907
+ )
1908
+ );
1909
+
1910
+ if (categoryTranslations.length > 0) {
1911
+ bundles.push({
1912
+ nodeUuid: node.uuid,
1913
+ translations: categoryTranslations
1914
+ });
1915
+ }
1916
+ }
1917
+ });
1918
+
1919
+ return bundles;
1920
+ }
1921
+
1922
+ private findTranslations(
1923
+ type: TranslationType,
1924
+ uuid: string,
1925
+ localizeableKeys: string[],
1926
+ source: any,
1927
+ localization: Record<string, any>
1928
+ ): TranslationEntry[] {
1929
+ const translations: TranslationEntry[] = [];
1930
+
1931
+ localizeableKeys.forEach((attribute) => {
1932
+ if (attribute === 'quick_replies') {
1933
+ return;
1934
+ }
1935
+
1936
+ const pathSegments = attribute.split('.');
1937
+ let from: any = source;
1938
+ let to: any = [];
1939
+
1940
+ while (pathSegments.length > 0 && from) {
1941
+ if (from.uuid) {
1942
+ to = localization[from.uuid];
1943
+ }
1944
+
1945
+ const path = pathSegments.shift();
1946
+ if (!path) {
1947
+ break;
1948
+ }
1949
+
1950
+ if (to) {
1951
+ to = to[path];
1952
+ }
1953
+ from = from[path];
1954
+ }
1955
+
1956
+ if (!from) {
1957
+ return;
1958
+ }
1959
+
1960
+ const fromValue = this.formatTranslationValue(from);
1961
+ if (!fromValue) {
1962
+ return;
1963
+ }
1964
+
1965
+ const toValue = to ? this.formatTranslationValue(to) : null;
1966
+
1967
+ translations.push({
1968
+ uuid,
1969
+ type,
1970
+ attribute,
1971
+ from: fromValue,
1972
+ to: toValue
1973
+ });
1974
+ });
1975
+
1976
+ return translations;
1977
+ }
1978
+
1979
+ private formatTranslationValue(value: any): string | null {
1980
+ if (value === null || value === undefined) {
1981
+ return null;
1982
+ }
1983
+
1984
+ if (Array.isArray(value)) {
1985
+ const normalized = value
1986
+ .map((entry) => this.formatTranslationValue(entry))
1987
+ .filter((entry) => !!entry) as string[];
1988
+ return normalized.length > 0 ? normalized.join(', ') : null;
1989
+ }
1990
+
1991
+ if (typeof value === 'object') {
1992
+ if ('name' in value && value.name) {
1993
+ return String(value.name);
1994
+ }
1995
+
1996
+ if ('arguments' in value && Array.isArray(value.arguments)) {
1997
+ return value.arguments.join(' ');
1998
+ }
1999
+
2000
+ return null;
2001
+ }
2002
+
2003
+ if (typeof value === 'number') {
2004
+ return value.toString();
2005
+ }
2006
+
2007
+ if (typeof value === 'string') {
2008
+ const trimmed = value.trim();
2009
+ return trimmed.length > 0 ? trimmed : null;
2010
+ }
2011
+
2012
+ return null;
2013
+ }
2014
+
2015
+ private getTranslationCounts(bundles: TranslationBundle[]): {
2016
+ total: number;
2017
+ localized: number;
2018
+ } {
2019
+ return bundles.reduce(
2020
+ (counts, bundle) => {
2021
+ bundle.translations.forEach((translation) => {
2022
+ counts.total += 1;
2023
+ if (translation.to && translation.to.trim().length > 0) {
2024
+ counts.localized += 1;
2025
+ }
2026
+ });
2027
+ return counts;
2028
+ },
2029
+ { total: 0, localized: 0 }
2030
+ );
2031
+ }
2032
+
2033
+ private hasAnyNodeWithLocalizeCategories(): boolean {
2034
+ if (!this.definition?._ui?.nodes) return false;
2035
+ return Object.values(this.definition._ui.nodes).some(
2036
+ (nodeUI: any) => nodeUI?.config?.localizeCategories
2037
+ );
2038
+ }
2039
+
1739
2040
  disconnectedCallback(): void {
1740
2041
  super.disconnectedCallback();
1741
2042
  this.zoomManager.teardownLoupe();
@@ -1892,7 +2193,7 @@ export class Editor extends RapidElement {
1892
2193
  search.definition = this.definition;
1893
2194
  search.languageCode = this.languageCode || '';
1894
2195
  search.scope = this.showMessageTable ? 'table' : 'flow';
1895
- search.includeCategories = false;
2196
+ search.includeCategories = this.isTranslating && this.hasAnyNodeWithLocalizeCategories();
1896
2197
  search.show();
1897
2198
  }
1898
2199
 
@@ -3609,6 +3910,50 @@ export class Editor extends RapidElement {
3609
3910
  }
3610
3911
 
3611
3912
  private renderToolbarElement(): TemplateResult {
3913
+ const languages = this.getLocalizationLanguages();
3914
+ const availableLanguages = this.getAvailableLanguages();
3915
+ const baseLanguage = this.definition?.language;
3916
+ const baseLanguageName =
3917
+ availableLanguages.find((lang) => lang.code === baseLanguage)?.name ||
3918
+ baseLanguage ||
3919
+ 'Primary language';
3920
+ const isBaseSelected =
3921
+ !this.languageCode ||
3922
+ this.languageCode === baseLanguage ||
3923
+ !languages.some((lang) => lang.code === this.languageCode);
3924
+ const activeLanguage = !isBaseSelected
3925
+ ? languages.find((lang) => lang.code === this.languageCode)
3926
+ : null;
3927
+ const currentLanguage = activeLanguage || {
3928
+ code: baseLanguage || '',
3929
+ name: baseLanguageName
3930
+ };
3931
+ const progress = this.getLocalizationProgress(
3932
+ isBaseSelected ? '' : this.languageCode
3933
+ );
3934
+ const percent = Math.round(
3935
+ (progress.localized / Math.max(progress.total, 1)) * 100
3936
+ );
3937
+ const languageOptions = [
3938
+ {
3939
+ name: baseLanguageName,
3940
+ value: PRIMARY_LANGUAGE_OPTION_VALUE
3941
+ },
3942
+ ...languages.map((lang) => {
3943
+ const localizationProgress = this.getLocalizationProgress(lang.code);
3944
+ const localizationPercent = Math.round(
3945
+ (localizationProgress.localized /
3946
+ Math.max(localizationProgress.total, 1)) *
3947
+ 100
3948
+ );
3949
+ return {
3950
+ name: lang.name,
3951
+ value: lang.code,
3952
+ percent: localizationPercent
3953
+ };
3954
+ })
3955
+ ];
3956
+
3612
3957
  return html`
3613
3958
  <temba-editor-toolbar
3614
3959
  ?message-view=${this.showMessageTable}
@@ -3618,6 +3963,11 @@ export class Editor extends RapidElement {
3618
3963
  ?revisions-active=${!this.revisionsWindowHidden}
3619
3964
  ?is-saving=${this.isSaving}
3620
3965
  ?search-disabled=${this.getRevisionsWindow()?.isViewingRevision ?? false}
3966
+ .languageOptions=${languageOptions}
3967
+ current-language-name=${currentLanguage.name}
3968
+ ?is-base-language=${isBaseSelected}
3969
+ .languagePercent=${percent}
3970
+ ?show-localization-tools=${Boolean(activeLanguage)}
3621
3971
  @temba-button-clicked=${this.handleToolbarAction}
3622
3972
  ></temba-editor-toolbar>
3623
3973
  `;
@@ -3653,6 +4003,13 @@ export class Editor extends RapidElement {
3653
4003
  case 'search':
3654
4004
  this.openFlowSearch();
3655
4005
  break;
4006
+ case 'language-change':
4007
+ if (detail.isPrimary) {
4008
+ this.handleLanguageChange(this.definition?.language || '');
4009
+ } else if (detail.languageCode) {
4010
+ this.handleLanguageChange(detail.languageCode);
4011
+ }
4012
+ break;
3656
4013
  }
3657
4014
  }
3658
4015
 
@@ -3997,7 +4354,7 @@ export class Editor extends RapidElement {
3997
4354
  : ''}
3998
4355
  <temba-flow-search
3999
4356
  .scope=${this.showMessageTable ? 'table' : 'flow'}
4000
- .includeCategories=${false}
4357
+ .includeCategories=${this.isTranslating && this.hasAnyNodeWithLocalizeCategories()}
4001
4358
  @temba-search-result-selected=${this.handleSearchResultSelected}
4002
4359
  ></temba-flow-search>
4003
4360
  ${!this.showMessageTable && this.flowIssues?.length