From cb513bc7a8f46a35bc05ca684658b21e46cdf9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 30 Apr 2025 13:11:40 +0200 Subject: [PATCH] Set default locale according to browser locale (#11805) We didn't get much complaints on Localization so I guess we can expand it more and make it the default behavior to use the browser's locale when you signup --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../appearance/components/LocalePicker.tsx | 6 +++ .../src/utils/i18n/dynamicActivate.ts | 6 --- .../src/utils/i18n/initialI18nActivate.ts | 36 +++++++++---- packages/twenty-shared/src/utils/index.ts | 1 + .../__tests__/normalizeLocale.test.ts | 53 +++++++++++++++++++ .../src/utils/validation/index.ts | 3 +- .../src/utils/validation/normalizeLocale.ts | 53 +++++++++++++++++++ 7 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 packages/twenty-shared/src/utils/validation/__tests__/normalizeLocale.test.ts create mode 100644 packages/twenty-shared/src/utils/validation/normalizeLocale.ts diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx index 6660cb3a1..12fca1457 100644 --- a/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx @@ -59,6 +59,12 @@ export const LocalePicker = () => { await updateWorkspaceMember({ locale: value }); await dynamicActivate(value); + try { + localStorage.setItem('locale', value); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Failed to save locale to localStorage:', error); + } await refreshObjectMetadataItems(); }; diff --git a/packages/twenty-front/src/utils/i18n/dynamicActivate.ts b/packages/twenty-front/src/utils/i18n/dynamicActivate.ts index 7204af96b..9283ec031 100644 --- a/packages/twenty-front/src/utils/i18n/dynamicActivate.ts +++ b/packages/twenty-front/src/utils/i18n/dynamicActivate.ts @@ -10,10 +10,4 @@ export const dynamicActivate = async (locale: keyof typeof APP_LOCALES) => { const { messages } = await import(`../../locales/generated/${locale}.ts`); i18n.load(locale, messages); i18n.activate(locale); - try { - localStorage.setItem('locale', locale); - } catch (error) { - // eslint-disable-next-line no-console - console.log('Failed to save locale to localStorage:', error); - } }; diff --git a/packages/twenty-front/src/utils/i18n/initialI18nActivate.ts b/packages/twenty-front/src/utils/i18n/initialI18nActivate.ts index e5ce29abb..64b1b82ad 100644 --- a/packages/twenty-front/src/utils/i18n/initialI18nActivate.ts +++ b/packages/twenty-front/src/utils/i18n/initialI18nActivate.ts @@ -1,7 +1,7 @@ import { fromNavigator, fromStorage, fromUrl } from '@lingui/detect-locale'; +import { APP_LOCALES } from 'twenty-shared/translations'; +import { isDefined, isValidLocale, normalizeLocale } from 'twenty-shared/utils'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; -import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; -import { isDefined, isValidLocale } from 'twenty-shared/utils'; export const initialI18nActivate = () => { const urlLocale = fromUrl('locale'); @@ -10,20 +10,34 @@ export const initialI18nActivate = () => { let locale: keyof typeof APP_LOCALES = APP_LOCALES.en; - if (isDefined(urlLocale) && isValidLocale(urlLocale)) { - locale = urlLocale; + const normalizedUrlLocale = isDefined(urlLocale) + ? normalizeLocale(urlLocale) + : null; + const normalizedStorageLocale = isDefined(storageLocale) + ? normalizeLocale(storageLocale) + : null; + const normalizedNavigatorLocale = isDefined(navigatorLocale) + ? normalizeLocale(navigatorLocale) + : null; + + if (isDefined(normalizedUrlLocale) && isValidLocale(normalizedUrlLocale)) { + locale = normalizedUrlLocale; try { - localStorage.setItem('locale', urlLocale); + localStorage.setItem('locale', normalizedUrlLocale); } catch (error) { // eslint-disable-next-line no-console console.log('Failed to save locale to localStorage:', error); } - } else if (isDefined(storageLocale) && isValidLocale(storageLocale)) { - locale = storageLocale; - } else if (isDefined(navigatorLocale) && isValidLocale(navigatorLocale)) { - // TODO: remove when we're ready to launch - // locale = navigatorLocale; - locale = SOURCE_LOCALE; + } else if ( + isDefined(normalizedStorageLocale) && + isValidLocale(normalizedStorageLocale) + ) { + locale = normalizedStorageLocale; + } else if ( + isDefined(normalizedNavigatorLocale) && + isValidLocale(normalizedNavigatorLocale) + ) { + locale = normalizedNavigatorLocale; } dynamicActivate(locale); diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index a162430d5..9961e1bdc 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -23,3 +23,4 @@ export { isValidUrl } from './url/isValidUrl'; export { isDefined } from './validation/isDefined'; export { isValidLocale } from './validation/isValidLocale'; export { isValidUuid } from './validation/isValidUuid'; +export { normalizeLocale } from './validation/normalizeLocale'; diff --git a/packages/twenty-shared/src/utils/validation/__tests__/normalizeLocale.test.ts b/packages/twenty-shared/src/utils/validation/__tests__/normalizeLocale.test.ts new file mode 100644 index 000000000..9f1b27a97 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/__tests__/normalizeLocale.test.ts @@ -0,0 +1,53 @@ +import { SOURCE_LOCALE } from '@/translations'; +import { normalizeLocale } from '../normalizeLocale'; + +describe('normalizeLocale', () => { + it('should return SOURCE_LOCALE when the input is null', () => { + expect(normalizeLocale(null)).toBe(SOURCE_LOCALE); + }); + + it('should return the locale when there is a direct match in APP_LOCALES', () => { + // Test a few valid locales + expect(normalizeLocale('en')).toBe('en'); + expect(normalizeLocale('fr-FR')).toBe('fr-FR'); + expect(normalizeLocale('es-ES')).toBe('es-ES'); + }); + + it('should handle case-insensitive matches', () => { + // Test with lowercase variants of the locales + expect(normalizeLocale('fr-fr')).toBe('fr-FR'); + expect(normalizeLocale('es-es')).toBe('es-ES'); + expect(normalizeLocale('DE-de')).toBe('de-DE'); + }); + + it('should match just the language part if full locale not found', () => { + // Test with just the language code + expect(normalizeLocale('fr')).toBe('fr-FR'); + expect(normalizeLocale('es')).toBe('es-ES'); + expect(normalizeLocale('de')).toBe('de-DE'); + }); + + it('should handle language codes that might map to multiple locales', () => { + // Test for language codes that might have multiple possible mappings + // For example, 'pt' could map to either 'pt-PT' or 'pt-BR' + // The implementation should map consistently to one of them + expect(normalizeLocale('pt')).toBeTruthy(); + // Verify it's one of the expected values + expect(['pt-PT', 'pt-BR']).toContain(normalizeLocale('pt')); + }); + + it('should return SOURCE_LOCALE for unsupported or invalid locales', () => { + expect(normalizeLocale('invalid-locale')).toBe(SOURCE_LOCALE); + expect(normalizeLocale('xx-XX')).toBe(SOURCE_LOCALE); + expect(normalizeLocale('')).toBe(SOURCE_LOCALE); + }); + + it('should handle SOURCE_LOCALE and its variants correctly', () => { + expect(normalizeLocale(SOURCE_LOCALE)).toBe(SOURCE_LOCALE); + // If SOURCE_LOCALE is 'en', test 'en-US', 'en-GB', etc. + if (SOURCE_LOCALE === 'en') { + expect(normalizeLocale('en-US')).toBe(SOURCE_LOCALE); + expect(normalizeLocale('en-GB')).toBe(SOURCE_LOCALE); + } + }); +}); diff --git a/packages/twenty-shared/src/utils/validation/index.ts b/packages/twenty-shared/src/utils/validation/index.ts index 767a68751..cf2201eb5 100644 --- a/packages/twenty-shared/src/utils/validation/index.ts +++ b/packages/twenty-shared/src/utils/validation/index.ts @@ -1,3 +1,4 @@ -export * from './isValidUuid'; export * from './isDefined'; export * from './isValidLocale'; +export * from './isValidUuid'; +export * from './normalizeLocale'; diff --git a/packages/twenty-shared/src/utils/validation/normalizeLocale.ts b/packages/twenty-shared/src/utils/validation/normalizeLocale.ts new file mode 100644 index 000000000..3f16ab073 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/normalizeLocale.ts @@ -0,0 +1,53 @@ +import { APP_LOCALES, SOURCE_LOCALE } from '@/translations'; + +/** + * Maps language codes to full locale keys in APP_LOCALES + * Example: 'fr' -> 'fr-FR', 'en' -> 'en' + */ +const languageToLocaleMap = Object.keys(APP_LOCALES).reduce< + Record +>((map, locale) => { + // Extract the language code (part before the hyphen or the whole code if no hyphen) + const language = locale.split('-')[0].toLowerCase(); + + // Only add to the map if not already added or if the current locale is the source locale + // This ensures language codes map to their full locale version (e.g., 'es' -> 'es-ES') + // but preserves 'en' -> 'en' since it's the source locale + if (!map[language] || locale === SOURCE_LOCALE) { + map[language] = locale; + } + + return map; +}, {}); + +/** + * Normalizes a locale string to match our supported formats + */ +export const normalizeLocale = ( + value: string | null, +): keyof typeof APP_LOCALES => { + if (value === null) { + return SOURCE_LOCALE; + } + + // Direct match in our supported locales + if (value in APP_LOCALES) { + return value as keyof typeof APP_LOCALES; + } + + // Try case-insensitive match (e.g., 'fr-fr' -> 'fr-FR') + const caseInsensitiveMatch = Object.keys(APP_LOCALES).find( + (locale) => locale.toLowerCase() === value.toLowerCase(), + ); + if (caseInsensitiveMatch) { + return caseInsensitiveMatch as keyof typeof APP_LOCALES; + } + + // Try matching just the language part (e.g., 'fr' -> 'fr-FR') + const languageCode = value?.trim() ? value.split('-')[0].toLowerCase() : ''; + if (languageToLocaleMap[languageCode]) { + return languageToLocaleMap[languageCode] as keyof typeof APP_LOCALES; + } + + return SOURCE_LOCALE; +};