From 28ab3fefcd87a070c75d09cd10f3cd1e02051909 Mon Sep 17 00:00:00 2001 From: Joel Vandal Date: Sat, 26 Jul 2025 14:11:27 -0400 Subject: [PATCH 01/14] Implement full internationalization (i18n) capabilities to the dashboard, allowing users to create multilingual interfaces with runtime language switching. Core Features: - Add centralized i18n Vuex store module for locale management - Implement runtime language switching with persistent locale storage - Support translation objects for widget properties (label, content, text, tooltip) - Enable dynamic translation of page and group names in navigation Widget Support: - Add translation support for ui-button, ui-text, ui-markdown labels/content - Support translated tooltips in ui-text-input and ui-number-input - Enable label translations in ui-switch, ui-slider, ui-chart, ui-gauge - Add comprehensive translation handling for ui-form fields Language Selector Widget: - Create new ui-language-selector widget for runtime language switching - Support both UI-scoped (ex. app bar) and group-scoped deployment modes - Implement teleport functionality for application placement - Configure output formats: code only, full object, or auto mode Translation Features: - Support JSON-based translation objects in widget properties - Automatic fallback to original values - Preserve original values for reference - Support browser language auto-detection --- nodes/config/ui_base.html | 800 +++++++++++++++++- nodes/config/ui_base.js | 138 ++- nodes/config/ui_group.html | 3 +- nodes/config/ui_page.html | 3 +- nodes/widgets/locales/en-US/ui_control.html | 6 + .../locales/en-US/ui_language_selector.html | 22 + nodes/widgets/ui_button.html | 3 +- nodes/widgets/ui_button_group.html | 3 +- nodes/widgets/ui_chart.html | 3 +- nodes/widgets/ui_control.js | 7 + nodes/widgets/ui_dropdown.html | 3 +- nodes/widgets/ui_form.html | 3 +- nodes/widgets/ui_gauge.html | 3 +- nodes/widgets/ui_language_selector.html | 251 ++++++ nodes/widgets/ui_language_selector.js | 180 ++++ nodes/widgets/ui_markdown.html | 3 +- nodes/widgets/ui_notification.html | 3 +- nodes/widgets/ui_number_input.html | 1 + nodes/widgets/ui_progress.html | 3 +- nodes/widgets/ui_radio_group.html | 3 +- nodes/widgets/ui_slider.html | 1 + nodes/widgets/ui_spacer.html | 3 +- nodes/widgets/ui_switch.html | 1 + nodes/widgets/ui_text.html | 3 +- nodes/widgets/ui_text_input.html | 3 +- package-lock.json | 73 +- package.json | 6 +- ui/src/App.vue | 25 +- ui/src/i18n/detector.js | 41 + ui/src/i18n/index.js | 66 ++ ui/src/layouts/Baseline.vue | 43 +- ui/src/main.mjs | 78 +- ui/src/store/index.mjs | 4 +- ui/src/store/modules/i18n.js | 52 ++ ui/src/store/ui.mjs | 10 +- ui/src/widgets/index.mjs | 7 +- ui/src/widgets/ui-button/UIButton.vue | 17 +- ui/src/widgets/ui-control/UIControl.vue | 41 +- ui/src/widgets/ui-dropdown/UIDropdown.vue | 14 +- .../UILanguageSelector.vue | 175 ++++ ui/src/widgets/ui-markdown/UIMarkdown.vue | 32 +- ui/src/widgets/ui-switch/UISwitch.vue | 2 +- ui/src/widgets/ui-text-input/UITextInput.vue | 4 +- ui/src/widgets/ui-text/UIText.vue | 8 +- 44 files changed, 2084 insertions(+), 66 deletions(-) create mode 100644 nodes/widgets/locales/en-US/ui_language_selector.html create mode 100644 nodes/widgets/ui_language_selector.html create mode 100644 nodes/widgets/ui_language_selector.js create mode 100644 ui/src/i18n/detector.js create mode 100644 ui/src/i18n/index.js create mode 100644 ui/src/store/modules/i18n.js create mode 100644 ui/src/widgets/ui-language-selector/UILanguageSelector.vue diff --git a/nodes/config/ui_base.html b/nodes/config/ui_base.html index f29bdeaff..d8a18d636 100644 --- a/nodes/config/ui_base.html +++ b/nodes/config/ui_base.html @@ -366,6 +366,88 @@ } } + + // Function to apply translations to widget properties + function applyTranslationsToWidgets() { + const enabledLanguages = []; + let baseNode = null; + + RED.nodes.eachConfig(function(n) { + if (n.type === 'ui-base') { + baseNode = n; + if (n.languages) { + n.languages.forEach(lang => { + if (lang.enabled && lang.code) { + enabledLanguages.push(lang.code); + } + }); + } + } + }); + + // Apply translations to all widgets + RED.nodes.eachNode(function(n) { + // Check if node has translations + const hasTranslations = n.translations && Object.keys(n.translations).length > 0; + + if (hasTranslations) { + // Widget types that support translations + const translatableProperties = { + 'ui-button': ['label', 'tooltip'], + 'ui-text': ['label'], + 'ui-dropdown': ['label'], + 'ui-text-input': ['label', 'tooltip'], + 'ui-number-input': ['label', 'tooltip'], + 'ui-switch': ['label'], + 'ui-slider': ['label'], + 'ui-chart': ['label'], + 'ui-gauge': ['label', 'title'], + 'ui-markdown': ['content'], + 'ui-page': ['name'], + 'ui-group': ['name'] + }; + + const propsToTranslate = translatableProperties[n.type]; + if (propsToTranslate) { + propsToTranslate.forEach(prop => { + const translations = {}; + let hasTranslations = false; + + // Build translation object for this property + enabledLanguages.forEach(lang => { + // Check node translations + const value = n.translations?.[lang]?.[prop]; + + if (value) { + translations[lang] = value; + hasTranslations = true; + } + }); + + // Include original value as 'en' if not already translated + if (n[prop] && !translations.en) { + const value = n[prop]; + + if (typeof value === 'object' && value.en) { + // If it's already a translation object, use the English value + translations.en = value.en; + } else if (typeof value === 'string') { + // If it's a plain string, use it as English + translations.en = value; + } + + if (translations.en) { + hasTranslations = true; + } + } + + // Don't modify widget properties - translations are stored in widget.translations + }); + } + } + }); + } + RED.nodes.registerType('ui-base', { category: 'config', defaults: { @@ -398,6 +480,17 @@ titleBarStyle: { value: 'default' }, + languages: { + value: [ + { code: 'en', name: 'English', enabled: true } + ] + }, + defaultLanguage: { + value: 'en' + }, + autoDetectLanguage: { + value: true + }, showReconnectNotification: { value: true }, @@ -464,6 +557,10 @@ $('#node-config-input-allowInstall').prop('checked', true) } }, + oneditsave: function () { + // Apply translations to all widgets before saving + applyTranslationsToWidgets(); + }, onpaletteadd: function () { // add the Dashboard 2.0 sidebar if (RED._db2debug) { console.log('dashboard 2: ui_base.html: onpaletteadd ()') } @@ -1206,6 +1303,7 @@ evt.stopPropagation() evt.preventDefault() }) + if (item.type === 'ui-page') { // add the "+ group" button @@ -2125,6 +2223,700 @@ }) } + + + /** + * Build the Languages Editor within the Dashboard 2.0 sidebar + * @param {Object} base - The base configuration node + * @param {Object} parent - The parent element to append the languages editor to + */ + function buildLanguagesEditor(base, parent) { + const html = `
+
+

+ Configure available languages for your dashboard. You can add, remove and set the default language. +

+
+
+ + +
+
+ + + Automatically detect user's browser language +
+
+
+
+ + +
+
+
+
+
` + + const container = $(html).appendTo(parent) + + // Initialize with current values + const languages = base.languages || [ + { code: 'en', name: 'English', enabled: true }, + { code: 'fr', name: 'Français', enabled: false } + ] + + // Function to render language list + function renderLanguagesList() { + const listContainer = $('#languages-list') + listContainer.empty() + + languages.forEach((lang, index) => { + const langRow = $(` +
+
+ + + + +
+ +
+ `).appendTo(listContainer) + + // Update language in array when inputs change + langRow.find('.lang-code').on('change', function() { + languages[index].code = $(this).val() + updateDefaultLanguageOptions() + }) + + langRow.find('.lang-name').on('change', function() { + languages[index].name = $(this).val() + updateDefaultLanguageOptions() + }) + + langRow.find('.lang-enabled').on('change', function() { + languages[index].enabled = $(this).prop('checked') + }) + + langRow.find('.remove-lang-btn').on('click', function() { + languages.splice(index, 1) + renderLanguagesList() + updateDefaultLanguageOptions() + }) + }) + } + + // Function to update default language options + function updateDefaultLanguageOptions() { + const select = $('#node-config-input-defaultLanguage') + const currentValue = select.val() + select.empty() + + languages.forEach(lang => { + if (lang.code && lang.name) { + select.append(``) + } + }) + + // Restore previous selection if it still exists + if (select.find(`option[value="${currentValue}"]`).length > 0) { + select.val(currentValue) + } + } + + // Add language button handler + $('#add-language-btn').on('click', function() { + languages.push({ code: '', name: '', enabled: false }) + renderLanguagesList() + }) + + // Initialize + renderLanguagesList() + updateDefaultLanguageOptions() + + // Make the language list sortable + $('#languages-list').sortable({ + handle: '.language-handle', + update: function(event, ui) { + // Get new order + const newOrder = [] + $('#languages-list .language-row').each(function() { + const index = parseInt($(this).data('index')) + newOrder.push(languages[index]) + }) + + // Update languages array with new order + languages.length = 0 + newOrder.forEach(lang => languages.push(lang)) + + // Re-render to update indices + renderLanguagesList() + saveLanguages() + + // Re-apply sortable after re-render + $('#languages-list').sortable({ + handle: '.language-handle', + update: function(event, ui) { + // This will be called again if needed + } + }) + } + }) + + // Set current values + $('#node-config-input-defaultLanguage').val(base.defaultLanguage || 'en') + $('#node-config-input-autoDetect').prop('checked', base.autoDetectLanguage !== false) + + // Update base configuration when values change + $('#node-config-input-defaultLanguage').on('change', function() { + base.defaultLanguage = $(this).val() + // Mark as dirty + RED.nodes.dirty(true) + }) + + $('#node-config-input-autoDetect').on('change', function() { + base.autoDetectLanguage = $(this).prop('checked') + // Mark as dirty + RED.nodes.dirty(true) + }) + + // Save languages configuration + function saveLanguages() { + base.languages = languages.filter(lang => lang.code && lang.name) + // Mark as dirty + RED.nodes.dirty(true) + } + + // Update languages whenever they change + container.on('change', '.lang-code, .lang-name, .lang-enabled', saveLanguages) + container.on('click', '.remove-lang-btn', function() { + setTimeout(saveLanguages, 100) + }) + container.on('click', '#add-language-btn', function() { + setTimeout(saveLanguages, 100) + }) + } + + /** + * Build the Translations Editor within the Dashboard 2.0 sidebar + * @param {Object} base - The base configuration node + * @param {Object} parent - The parent element to append the translations editor to + */ + function buildTranslationsEditor(base, parent) { + const html = `
+
+

+ Manage all translations for your dashboard widgets, groups, and pages in one place. +

+
+
+
+ + + +
+
+ + +
+
+
+
+ Loading translations... +
+
+
` + + const container = $(html).appendTo(parent) + + // Load and manage translations + function loadTranslations() { + const translationsList = $('#translations-list') + translationsList.html('
Loading translations...
') + + // Get languages from base configuration + const languages = base.languages || [ + { code: 'en', name: 'English', enabled: true } + ] + const enabledLanguages = languages.filter(lang => lang.enabled && lang.code && lang.name) + if (enabledLanguages.length === 0) { + translationsList.html('
No languages enabled. Enable at least one language above to manage translations.
') + return + } + + // Widget types configuration + const widgetTypes = { + 'ui-button': [ + { key: 'label', label: 'Button Text', type: 'text' }, + { key: 'tooltip', label: 'Tooltip', type: 'text' } + ], + 'ui-text': [ + { key: 'label', label: 'Label', type: 'text' } + ], + 'ui-dropdown': [ + { key: 'label', label: 'Label', type: 'text' } + ], + 'ui-text-input': [ + { key: 'label', label: 'Label', type: 'text' }, + { key: 'tooltip', label: 'Tooltip', type: 'text' } + ], + 'ui-number-input': [ + { key: 'label', label: 'Label', type: 'text' }, + { key: 'tooltip', label: 'Tooltip', type: 'text' } + ], + 'ui-switch': [ + { key: 'label', label: 'Label', type: 'text' } + ], + 'ui-slider': [ + { key: 'label', label: 'Label', type: 'text' } + ], + 'ui-chart': [ + { key: 'label', label: 'Label', type: 'text' } + ], + 'ui-gauge': [ + { key: 'label', label: 'Label', type: 'text' }, + { key: 'title', label: 'Title', type: 'text' } + ], + 'ui-notification': [ + { key: 'dismissText', label: 'Dismiss Text', type: 'text' }, + { key: 'confirmText', label: 'Confirm Text', type: 'text' } + ], + 'ui-markdown': [ + { key: 'content', label: 'Content', type: 'textarea' } + ], + 'ui-form': [ + { key: 'label', label: 'Label', type: 'text' }, + { key: 'submit', label: 'Submit Button', type: 'text' }, + { key: 'cancel', label: 'Cancel Button', type: 'text' } + ], + 'ui-button-group': [ + { key: 'label', label: 'Label', type: 'text' } + ], + 'ui-radio-group': [ + { key: 'label', label: 'Label', type: 'text' } + ], + 'ui-progress': [ + { key: 'label', label: 'Label', type: 'text' } + ], + 'ui-spacer': [ + { key: 'name', label: 'Name', type: 'text' }, + { key: 'tooltip', label: 'Tooltip', type: 'text' } + ] + } + + // Collect and organize data hierarchically + const pages = [] + + // Get all pages sorted by order + RED.nodes.eachConfig(function(n) { + if (n.type === 'ui-page') { + pages.push({ + id: n.id, + node: n, + name: n.name || 'Unnamed Page', + order: n.order || 0, + groups: [] + }) + } + }) + + // Sort pages by order + pages.sort((a, b) => a.order - b.order) + + // For each page, get its groups + pages.forEach(page => { + RED.nodes.eachConfig(function(n) { + if (n.type === 'ui-group' && n.page === page.id) { + const group = { + id: n.id, + node: n, + name: n.name || 'Unnamed Group', + order: n.order || 0, + widgets: [] + } + + // Get widgets for this group + RED.nodes.eachNode(function(w) { + if (widgetTypes[w.type] && w.group === group.id) { + group.widgets.push({ + id: w.id, + node: w, + type: w.type, + name: w.name || w.label || w.type, + order: w.order || 0, + properties: widgetTypes[w.type] + }) + } + }) + + // Sort widgets by order + group.widgets.sort((a, b) => a.order - b.order) + + page.groups.push(group) + } + }) + + // Sort groups by order + page.groups.sort((a, b) => a.order - b.order) + }) + + // Build the translations UI + translationsList.empty() + + if (pages.length === 0) { + translationsList.html('
No pages found in your dashboard.
') + return + } + + // Add CSS classes for the tree view + if (!$('#nrdb2-translations-styles').length) { + $(``).appendTo('head') + } + + // Create translation UI following the hierarchy + pages.forEach(page => { + const pageContainer = $('
').appendTo(translationsList) + + // Page header with collapsible chevron + const pageHeader = $('
').appendTo(pageContainer) + const pageChevron = $('').appendTo(pageHeader) + $('').appendTo(pageHeader) + $(`

${page.name}

`).appendTo(pageHeader) + $(`${page.groups.length} groups`).appendTo(pageHeader) + + const pageContent = $('
').appendTo(pageContainer) + + // Page name translation + const pageTranslation = createTranslationField(page.node, 'name', 'Page Name', 'text', enabledLanguages) + pageTranslation.css('margin-bottom', '15px').appendTo(pageContent) + + // Groups for this page + page.groups.forEach(group => { + const groupContainer = $('
').appendTo(pageContent) + + // Group header + const groupHeader = $('
').appendTo(groupContainer) + const groupChevron = $('').appendTo(groupHeader) + $('').appendTo(groupHeader) + $(`
${group.name}
`).appendTo(groupHeader) + $(`${group.widgets.length} widgets`).appendTo(groupHeader) + + const groupContent = $('
').appendTo(groupContainer) + + // Group name translation + const groupTranslation = createTranslationField(group.node, 'name', 'Group Name', 'text', enabledLanguages) + groupTranslation.css('margin-bottom', '10px').appendTo(groupContent) + + // Widgets for this group + group.widgets.forEach(widget => { + const widgetContainer = $('
').appendTo(groupContent) + widgetContainer.addClass('translation-item') + widgetContainer.attr('data-search-text', `${page.name} ${group.name} ${widget.name}`.toLowerCase()) + + // Widget header + $(`
${widget.name}
`).appendTo(widgetContainer) + + // Widget properties + widget.properties.forEach(prop => { + const propField = createTranslationField(widget.node, prop.key, prop.label, prop.type, enabledLanguages) + propField.appendTo(widgetContainer) + }) + }) + + // Toggle group visibility + groupHeader.on('click', function(e) { + e.stopPropagation() + groupContent.slideToggle(200) + groupChevron.toggleClass('collapsed') + }) + }) + + // Toggle page visibility + pageHeader.on('click', function(e) { + e.stopPropagation() + pageContent.slideToggle(200) + pageChevron.toggleClass('collapsed') + }) + }) + + // Search functionality + $('#translation-search').off('input').on('input', function() { + const searchTerm = $(this).val().toLowerCase() + if (searchTerm === '') { + // Show all and restore collapsed state + $('.translation-item').show() + translationsList.find('> div').show() + } else { + // Hide all containers first + translationsList.find('> div').hide() + + // Show only matching items and their parents + $('.translation-item').each(function() { + const text = $(this).attr('data-search-text') + if (text && text.includes(searchTerm)) { + $(this).show() + // Show all parent containers + $(this).parents().show() + } else { + $(this).hide() + } + }) + } + }) + + // Helper function to create translation fields + function createTranslationField(node, key, label, type, languages) { + const container = $('
') + $(`
${label}:
`).appendTo(container) + + // Original value + let originalValue = node[key] || '' + + // Check if the value is already a translation object + if (typeof originalValue === 'object' && !Array.isArray(originalValue)) { + // It's a translation object, try to get the default language or English value + originalValue = originalValue[base.defaultLanguage] || originalValue.en || originalValue[Object.keys(originalValue)[0]] || '' + } + + // Don't show JSON strings as original values + if (typeof originalValue === 'string' && originalValue.startsWith('{') && originalValue.endsWith('}')) { + try { + const parsed = JSON.parse(originalValue) + if (typeof parsed === 'object') { + originalValue = parsed[base.defaultLanguage] || parsed.en || parsed[Object.keys(parsed)[0]] || '' + } + } catch (e) { + // Not valid JSON, keep as is + } + } + + const originalDiv = $('
').appendTo(container) + $('').appendTo(originalDiv) + + // Show original value in readonly input/textarea + if (type === 'textarea') { + $('