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>
This commit is contained in:
Félix Malfait
2025-04-30 13:11:40 +02:00
committed by GitHub
parent e55ff5ac4a
commit cb513bc7a8
7 changed files with 140 additions and 18 deletions

View File

@ -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();
};

View File

@ -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);
}
};

View File

@ -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);

View File

@ -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';

View File

@ -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);
}
});
});

View File

@ -1,3 +1,4 @@
export * from './isValidUuid';
export * from './isDefined';
export * from './isValidLocale';
export * from './isValidUuid';
export * from './normalizeLocale';

View File

@ -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<string, string>
>((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;
};