mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
fix(editor): Fix i18n package locale files hot reloading (no-changelog) (#19385)
Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
@@ -1,6 +1,32 @@
|
||||
import { I18nClass } from './index';
|
||||
/* eslint-disable id-denylist */
|
||||
import { I18nClass, loadLanguage, i18nInstance } from './index';
|
||||
|
||||
// Store original state for cleanup
|
||||
let originalLocale: string;
|
||||
let originalHtmlLang: string;
|
||||
const testLocales = new Set<string>();
|
||||
|
||||
describe(I18nClass, () => {
|
||||
beforeAll(() => {
|
||||
// Set up basic i18n messages for displayTimer tests
|
||||
i18nInstance.global.setLocaleMessage('en', {
|
||||
genericHelpers: {
|
||||
millis: 'ms',
|
||||
secShort: 's',
|
||||
minShort: 'm',
|
||||
hrsShort: 'h',
|
||||
},
|
||||
});
|
||||
originalLocale = i18nInstance.global.locale.value;
|
||||
originalHtmlLang = document.querySelector('html')?.getAttribute('lang') ?? 'en';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original locale and html lang
|
||||
i18nInstance.global.locale.value = originalLocale as 'en';
|
||||
document.querySelector('html')?.setAttribute('lang', originalHtmlLang);
|
||||
});
|
||||
|
||||
describe('displayTimer', () => {
|
||||
it('should format duration with hours, minutes and seconds', () => {
|
||||
expect(new I18nClass().displayTimer(-1)).toBe('-1s');
|
||||
@@ -33,3 +59,325 @@ describe(I18nClass, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLanguage', () => {
|
||||
beforeEach(() => {
|
||||
// Reset to English before each test
|
||||
i18nInstance.global.locale.value = 'en';
|
||||
document.querySelector('html')?.setAttribute('lang', 'en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test locales (we can't easily remove them, but we can reset to English)
|
||||
i18nInstance.global.locale.value = 'en';
|
||||
document.querySelector('html')?.setAttribute('lang', 'en');
|
||||
});
|
||||
|
||||
it('should load a new language and set it as current locale', () => {
|
||||
const locale = 'de';
|
||||
const messages = {
|
||||
hello: 'Hallo',
|
||||
world: 'Welt',
|
||||
};
|
||||
|
||||
const result = loadLanguage(locale, messages);
|
||||
|
||||
expect(result).toBe(locale);
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('hello')).toBe('Hallo');
|
||||
expect(i18nInstance.global.t('world')).toBe('Welt');
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should set the HTML lang attribute when loading a language', () => {
|
||||
const locale = 'fr';
|
||||
const messages = { greeting: 'Bonjour' };
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(document.querySelector('html')?.getAttribute('lang')).toBe(locale);
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should handle number formats when provided in messages', () => {
|
||||
const locale = 'de-num';
|
||||
const messages = {
|
||||
hello: 'Hallo',
|
||||
numberFormats: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('hello')).toBe('Hallo');
|
||||
// Verify that numberFormats were set
|
||||
expect(i18nInstance.global.getNumberFormat(locale)).toBeDefined();
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should not reload a language if it has already been loaded', () => {
|
||||
const locale = 'es';
|
||||
const originalMessages = { hello: 'Hola' };
|
||||
const newMessages = { hello: 'Buenos días' };
|
||||
|
||||
// Load the language for the first time
|
||||
loadLanguage(locale, originalMessages);
|
||||
expect(i18nInstance.global.t('hello')).toBe('Hola');
|
||||
|
||||
// Switch to English temporarily
|
||||
i18nInstance.global.locale.value = 'en';
|
||||
|
||||
// Try to load the same language again with different messages
|
||||
const result = loadLanguage(locale, newMessages);
|
||||
|
||||
// Should return the locale but not update the messages
|
||||
expect(result).toBe(locale);
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('hello')).toBe('Hola'); // Should still be the original message
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should handle empty messages object', () => {
|
||||
const locale = 'it';
|
||||
const messages = {};
|
||||
|
||||
const result = loadLanguage(locale, messages);
|
||||
|
||||
expect(result).toBe(locale);
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should handle messages with nested objects', () => {
|
||||
const locale = 'pt';
|
||||
const messages = {
|
||||
nested: {
|
||||
deep: {
|
||||
message: 'Mensagem aninhada',
|
||||
},
|
||||
},
|
||||
simple: 'Simples',
|
||||
};
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('nested.deep.message')).toBe('Mensagem aninhada');
|
||||
expect(i18nInstance.global.t('simple')).toBe('Simples');
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should properly separate numberFormats from other messages', () => {
|
||||
const locale = 'ja';
|
||||
const messages = {
|
||||
greeting: 'こんにちは',
|
||||
farewell: 'さようなら',
|
||||
numberFormats: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'JPY',
|
||||
},
|
||||
decimal: {
|
||||
style: 'decimal',
|
||||
minimumFractionDigits: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('greeting')).toBe('こんにちは');
|
||||
expect(i18nInstance.global.t('farewell')).toBe('さようなら');
|
||||
// numberFormats should not be accessible as regular translation messages
|
||||
expect(i18nInstance.global.te('numberFormats')).toBe(false);
|
||||
expect(i18nInstance.global.te('numberFormats.currency')).toBe(false);
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should switch between already loaded languages', () => {
|
||||
const locale1 = 'fr-switch';
|
||||
const locale2 = 'de-switch';
|
||||
|
||||
// Load first language
|
||||
loadLanguage(locale1, { hello: 'Bonjour' });
|
||||
expect(i18nInstance.global.locale.value).toBe(locale1);
|
||||
expect(i18nInstance.global.t('hello')).toBe('Bonjour');
|
||||
|
||||
// Load second language
|
||||
loadLanguage(locale2, { hello: 'Hallo' });
|
||||
expect(i18nInstance.global.locale.value).toBe(locale2);
|
||||
expect(i18nInstance.global.t('hello')).toBe('Hallo');
|
||||
|
||||
// Switch back to first language (should not reload messages)
|
||||
loadLanguage(locale1, { hello: 'Salut' }); // Different message
|
||||
expect(i18nInstance.global.locale.value).toBe(locale1);
|
||||
expect(i18nInstance.global.t('hello')).toBe('Bonjour'); // Should be original message
|
||||
testLocales.add(locale1);
|
||||
testLocales.add(locale2);
|
||||
});
|
||||
|
||||
it('should return the locale that was set', () => {
|
||||
const locale = 'nl';
|
||||
const messages = { test: 'test' };
|
||||
|
||||
const result = loadLanguage(locale, messages);
|
||||
|
||||
expect(result).toBe(locale);
|
||||
expect(typeof result).toBe('string');
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should handle messages with special characters', () => {
|
||||
const locale = 'zh';
|
||||
const messages = {
|
||||
special: '特殊字符测试',
|
||||
emoji: '🚀 测试 🎉',
|
||||
mixed: 'Mixed 混合 content',
|
||||
};
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('special')).toBe('特殊字符测试');
|
||||
expect(i18nInstance.global.t('emoji')).toBe('🚀 测试 🎉');
|
||||
expect(i18nInstance.global.t('mixed')).toBe('Mixed 混合 content');
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should handle messages with undefined and null values', () => {
|
||||
const locale = 'test-null';
|
||||
const messages = {
|
||||
defined: 'Valid message',
|
||||
undefined,
|
||||
null: null,
|
||||
};
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('defined')).toBe('Valid message');
|
||||
// Undefined and null values should fallback to the key
|
||||
expect(i18nInstance.global.t('undefined')).toBe('undefined');
|
||||
expect(i18nInstance.global.t('null')).toBe('null');
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should handle messages with array values', () => {
|
||||
const locale = 'test-array';
|
||||
const messages = {
|
||||
items: ['item1', 'item2', 'item3'],
|
||||
nested: {
|
||||
list: ['a', 'b', 'c'],
|
||||
},
|
||||
};
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
// Vue i18n handles arrays, but accessing them directly might not work as expected
|
||||
// This tests that the function doesn't crash with array values
|
||||
expect(() => i18nInstance.global.t('items')).not.toThrow();
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should handle loading the same locale as current locale', () => {
|
||||
const currentLocale = 'en';
|
||||
const messages = {
|
||||
newMessage: 'This is a new message',
|
||||
};
|
||||
|
||||
// Ensure we're starting with English
|
||||
i18nInstance.global.locale.value = currentLocale;
|
||||
|
||||
const result = loadLanguage(currentLocale, messages);
|
||||
|
||||
expect(result).toBe(currentLocale);
|
||||
expect(i18nInstance.global.locale.value).toBe(currentLocale);
|
||||
expect(i18nInstance.global.t('newMessage')).toBe('This is a new message');
|
||||
});
|
||||
|
||||
it('should handle numberFormats with complex structure', () => {
|
||||
const locale = 'complex-number';
|
||||
const messages = {
|
||||
greeting: 'Hello',
|
||||
numberFormats: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
currencyDisplay: 'symbol',
|
||||
},
|
||||
percent: {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
},
|
||||
decimal: {
|
||||
style: 'decimal',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('greeting')).toBe('Hello');
|
||||
|
||||
const numberFormats = i18nInstance.global.getNumberFormat(
|
||||
locale,
|
||||
) as typeof messages.numberFormats;
|
||||
expect(numberFormats).toBeDefined();
|
||||
expect(numberFormats.currency).toBeDefined();
|
||||
expect(numberFormats.percent).toBeDefined();
|
||||
expect(numberFormats.decimal).toBeDefined();
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should handle messages with boolean and numeric values', () => {
|
||||
const locale = 'test-types';
|
||||
const messages = {
|
||||
boolTrue: true,
|
||||
boolFalse: false,
|
||||
number: 42,
|
||||
zero: 0,
|
||||
string: 'actual string',
|
||||
};
|
||||
|
||||
loadLanguage(locale, messages);
|
||||
|
||||
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||
expect(i18nInstance.global.t('string')).toBe('actual string');
|
||||
// Boolean and numeric values that are not strings will fallback to the key name
|
||||
// This is the expected behavior of vue-i18n when values are not strings
|
||||
expect(i18nInstance.global.t('boolTrue')).toBe('boolTrue');
|
||||
expect(i18nInstance.global.t('boolFalse')).toBe('boolFalse');
|
||||
expect(i18nInstance.global.t('number')).toBe('number');
|
||||
expect(i18nInstance.global.t('zero')).toBe('zero');
|
||||
testLocales.add(locale);
|
||||
});
|
||||
|
||||
it('should preserve HTML document language attribute correctly', () => {
|
||||
const html = document.querySelector('html');
|
||||
const originalLang = html?.getAttribute('lang') ?? 'en';
|
||||
|
||||
const locale1 = 'preserve-1';
|
||||
const locale2 = 'preserve-2';
|
||||
|
||||
loadLanguage(locale1, { test: 'test1' });
|
||||
expect(html?.getAttribute('lang')).toBe(locale1);
|
||||
|
||||
loadLanguage(locale2, { test: 'test2' });
|
||||
expect(html?.getAttribute('lang')).toBe(locale2);
|
||||
|
||||
// Restore original
|
||||
html?.setAttribute('lang', originalLang);
|
||||
testLocales.add(locale1);
|
||||
testLocales.add(locale2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-this-alias */
|
||||
import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow';
|
||||
import { ref } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import englishBaseText from './locales/en.json';
|
||||
import type { BaseTextKey, INodeTranslationHeaders } from './types';
|
||||
import {
|
||||
deriveMiddleKey,
|
||||
@@ -17,10 +17,13 @@ export const i18nInstance = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: { en: englishBaseText },
|
||||
messages: { en: {} },
|
||||
warnHtmlMessage: false,
|
||||
});
|
||||
|
||||
// Reactive version to signal i18n message updates to Vue computations
|
||||
export const i18nVersion = ref(0);
|
||||
|
||||
type BaseTextOptions = {
|
||||
adjustToNumber?: number;
|
||||
interpolate?: Record<string, string | number>;
|
||||
@@ -57,8 +60,12 @@ export class I18nClass {
|
||||
* Render a string of base text, i.e. a string with a fixed path to the localized value. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces.
|
||||
*/
|
||||
baseText(key: BaseTextKey, options?: BaseTextOptions): string {
|
||||
// Create a unique cache key
|
||||
const cacheKey = `${key}-${JSON.stringify(options)}`;
|
||||
// Track reactive version so computed properties re-evaluate when messages change
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
i18nVersion.value;
|
||||
|
||||
// Create a unique cache key, scoped by version
|
||||
const cacheKey = `${i18nVersion.value}|${key}-${JSON.stringify(options)}`;
|
||||
|
||||
// Check if the result is already cached
|
||||
if (this.baseTextCache.has(cacheKey)) {
|
||||
@@ -79,6 +86,14 @@ export class I18nClass {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached baseText results. Useful when locale messages are updated at runtime (e.g. HMR) or locale changes.
|
||||
*/
|
||||
clearCache() {
|
||||
this.baseTextCache.clear();
|
||||
i18nVersion.value++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a string of dynamic text, i.e. a string with a constructed path to the localized value.
|
||||
*/
|
||||
@@ -375,35 +390,34 @@ export class I18nClass {
|
||||
};
|
||||
}
|
||||
|
||||
const loadedLanguages = ['en'];
|
||||
const loadedLanguages: string[] = [];
|
||||
|
||||
async function setLanguage(language: string) {
|
||||
i18nInstance.global.locale.value = language as 'en';
|
||||
document.querySelector('html')!.setAttribute('lang', language);
|
||||
export function setLanguage(locale: string) {
|
||||
i18nInstance.global.locale.value = locale as 'en';
|
||||
document.querySelector('html')!.setAttribute('lang', locale);
|
||||
|
||||
return language;
|
||||
// Invalidate cached baseText results on locale change
|
||||
i18n.clearCache();
|
||||
|
||||
return locale;
|
||||
}
|
||||
|
||||
export async function loadLanguage(language: string) {
|
||||
if (i18nInstance.global.locale.value === language) {
|
||||
return await setLanguage(language);
|
||||
export function loadLanguage(locale: string, messages: Record<string, unknown>) {
|
||||
if (loadedLanguages.includes(locale)) {
|
||||
return setLanguage(locale);
|
||||
}
|
||||
|
||||
if (loadedLanguages.includes(language)) {
|
||||
return await setLanguage(language);
|
||||
}
|
||||
const { numberFormats, ...rest } = messages;
|
||||
|
||||
const { numberFormats, ...rest } = (await import(`@n8n/i18n/locales/${language}.json`)).default;
|
||||
|
||||
i18nInstance.global.setLocaleMessage(language, rest);
|
||||
i18nInstance.global.setLocaleMessage(locale, rest);
|
||||
|
||||
if (numberFormats) {
|
||||
i18nInstance.global.setNumberFormat(language, numberFormats);
|
||||
i18nInstance.global.setNumberFormat(locale, numberFormats);
|
||||
}
|
||||
|
||||
loadedLanguages.push(language);
|
||||
loadedLanguages.push(locale);
|
||||
|
||||
return await setLanguage(language);
|
||||
return setLanguage(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,6 +436,22 @@ export function addNodeTranslation(
|
||||
i18nInstance.global.mergeLocaleMessage(language, newMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev/runtime helper to replace messages for a locale without import side-effects.
|
||||
* Used by editor UI HMR to apply updated translation JSON.
|
||||
*/
|
||||
export function updateLocaleMessages(locale: string, messages: Record<string, unknown>) {
|
||||
const { numberFormats, ...rest } = messages as Record<string, unknown> & {
|
||||
numberFormats?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
i18nInstance.global.setLocaleMessage(locale, rest);
|
||||
if (numberFormats) i18nInstance.global.setNumberFormat(locale, numberFormats);
|
||||
|
||||
// Ensure subsequent reads recompute
|
||||
i18n.clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a credential translation to the i18n instance's `messages` object.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ import type englishBaseText from './locales/en.json';
|
||||
export type GetBaseTextKey<T> = T extends `_${string}` ? never : T;
|
||||
|
||||
export type BaseTextKey = GetBaseTextKey<keyof typeof englishBaseText>;
|
||||
export type LocaleMessages = typeof englishBaseText;
|
||||
|
||||
export interface INodeTranslationHeaders {
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user