diff --git a/.gitignore b/.gitignore index 48296f0cb..03c7ae81b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ !.yarn/sdks !.yarn/versions .vercel +.swc **/**/logs/** diff --git a/.swc/plugins/v7_macos_aarch64_0.111.2/30d309907f6262cb1327613813ba35dde7f64d654b4afd8728719be6e057ff48 b/.swc/plugins/v7_macos_aarch64_0.111.2/30d309907f6262cb1327613813ba35dde7f64d654b4afd8728719be6e057ff48 new file mode 100644 index 000000000..f5947cc77 Binary files /dev/null and b/.swc/plugins/v7_macos_aarch64_0.111.2/30d309907f6262cb1327613813ba35dde7f64d654b4afd8728719be6e057ff48 differ diff --git a/package.json b/package.json index fd0b704b6..3293e6e8a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "@jsdevtools/rehype-toc": "^3.0.2", "@linaria/core": "^6.2.0", "@linaria/react": "^6.2.1", + "@lingui/core": "^5.1.2", + "@lingui/react": "^5.1.2", "@mdx-js/react": "^3.0.0", "@microsoft/microsoft-graph-client": "^3.0.7", "@nestjs/apollo": "^11.0.5", @@ -202,6 +204,9 @@ "@graphql-codegen/typescript": "^3.0.4", "@graphql-codegen/typescript-operations": "^3.0.4", "@graphql-codegen/typescript-react-apollo": "^3.3.7", + "@lingui/cli": "^5.1.2", + "@lingui/swc-plugin": "^5.0.2", + "@lingui/vite-plugin": "^5.1.2", "@microsoft/microsoft-graph-types": "^2.40.0", "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", diff --git a/packages/twenty-front/.swcrc b/packages/twenty-front/.swcrc index 6de32e423..d95dce28d 100644 --- a/packages/twenty-front/.swcrc +++ b/packages/twenty-front/.swcrc @@ -2,7 +2,7 @@ "jsc": { "target": "es2017", "parser": { - "syntax": "typescript", + "syntax": "typescript", "tsx": true, "decorators": false, "dynamicImport": false @@ -19,6 +19,19 @@ "hidden": { "jest": true } + }, + "experimental": { + "plugins": [ + [ + "@lingui/swc-plugin", + { + "runtimeModules": { + "i18n": ["@lingui/core", "i18n"], + "trans": ["@lingui/react", "Trans"] + } + } + ] + ] } }, "module": { diff --git a/packages/twenty-front/jest.config.ts b/packages/twenty-front/jest.config.ts index 9f99f5614..7ff96a178 100644 --- a/packages/twenty-front/jest.config.ts +++ b/packages/twenty-front/jest.config.ts @@ -12,7 +12,16 @@ const jestConfig: JestConfigWithTsJest = { testEnvironment: 'jsdom', transformIgnorePatterns: ['../../node_modules/'], transform: { - '^.+\\.(ts|js|tsx|jsx)$': '@swc/jest', + '^.+\\.(ts|js|tsx|jsx)$': [ + '@swc/jest', + { + jsc: { + experimental: { + plugins: [], // Disable Lingui plugin during tests + }, + }, + }, + ], }, moduleNameMapper: { '\\.(jpg|jpeg|png|gif|webp|svg|svg\\?react)$': diff --git a/packages/twenty-front/lingui.config.ts b/packages/twenty-front/lingui.config.ts new file mode 100644 index 000000000..64bf9f3a6 --- /dev/null +++ b/packages/twenty-front/lingui.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@lingui/cli'; + +export default defineConfig({ + sourceLocale: 'en', + locales: ['fr', 'en', 'pt', 'de', 'it', 'es', 'zh'], + catalogs: [ + { + path: '/src/locales/{locale}/messages', + include: ['src'], + }, + ], +}); diff --git a/packages/twenty-front/project.json b/packages/twenty-front/project.json index 19618f949..2f73d5f51 100644 --- a/packages/twenty-front/project.json +++ b/packages/twenty-front/project.json @@ -153,6 +153,20 @@ "configurations": { "ci": {} } + }, + "lingui:extract": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "command": "lingui extract" + } + }, + "lingui:compile": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "command": "lingui compile --typescript" + } } } } diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 6f8551222..e28fa927e 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -330,7 +330,8 @@ export enum FeatureFlagKey { IsPostgreSqlIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled', IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled', IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled', - IsWorkflowEnabled = 'IsWorkflowEnabled' + IsWorkflowEnabled = 'IsWorkflowEnabled', + IsLocalizationEnabled = 'IsLocalizationEnabled' } export type FieldConnection = { diff --git a/packages/twenty-front/src/locales/de/messages.ts b/packages/twenty-front/src/locales/de/messages.ts new file mode 100644 index 000000000..7263b31c3 --- /dev/null +++ b/packages/twenty-front/src/locales/de/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"vXIe7J\":[\"Language\"],\"AXTJAW\":[\"Select your preferred language\"]}")as Messages; \ No newline at end of file diff --git a/packages/twenty-front/src/locales/en/messages.po b/packages/twenty-front/src/locales/en/messages.po new file mode 100644 index 000000000..9e0c5889e --- /dev/null +++ b/packages/twenty-front/src/locales/en/messages.po @@ -0,0 +1,16 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2025-01-16 16:50+0100\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: en\n" + +#: src/pages/settings/profile/appearance/components/SettingsExperience.tsx:51 +msgid "Language" +msgstr "Language" + +#: src/pages/settings/profile/appearance/components/SettingsExperience.tsx:52 +msgid "Select your preferred language" +msgstr "Select your preferred language" diff --git a/packages/twenty-front/src/locales/en/messages.ts b/packages/twenty-front/src/locales/en/messages.ts new file mode 100644 index 000000000..7263b31c3 --- /dev/null +++ b/packages/twenty-front/src/locales/en/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"vXIe7J\":[\"Language\"],\"AXTJAW\":[\"Select your preferred language\"]}")as Messages; \ No newline at end of file diff --git a/packages/twenty-front/src/locales/es/messages.ts b/packages/twenty-front/src/locales/es/messages.ts new file mode 100644 index 000000000..7263b31c3 --- /dev/null +++ b/packages/twenty-front/src/locales/es/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"vXIe7J\":[\"Language\"],\"AXTJAW\":[\"Select your preferred language\"]}")as Messages; \ No newline at end of file diff --git a/packages/twenty-front/src/locales/fr/messages.po b/packages/twenty-front/src/locales/fr/messages.po new file mode 100644 index 000000000..b5e69827b --- /dev/null +++ b/packages/twenty-front/src/locales/fr/messages.po @@ -0,0 +1,16 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2025-01-16 16:50+0100\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: fr\n" + +#: src/pages/settings/profile/appearance/components/SettingsExperience.tsx:51 +msgid "Language" +msgstr "Langue" + +#: src/pages/settings/profile/appearance/components/SettingsExperience.tsx:52 +msgid "Select your preferred language" +msgstr "Sélectionnez votre langue préférée" diff --git a/packages/twenty-front/src/locales/fr/messages.ts b/packages/twenty-front/src/locales/fr/messages.ts new file mode 100644 index 000000000..8a62b60db --- /dev/null +++ b/packages/twenty-front/src/locales/fr/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"vXIe7J\":[\"Langue\"],\"AXTJAW\":[\"Sélectionnez votre langue préférée\"]}")as Messages; \ No newline at end of file diff --git a/packages/twenty-front/src/locales/it/messages.ts b/packages/twenty-front/src/locales/it/messages.ts new file mode 100644 index 000000000..7263b31c3 --- /dev/null +++ b/packages/twenty-front/src/locales/it/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"vXIe7J\":[\"Language\"],\"AXTJAW\":[\"Select your preferred language\"]}")as Messages; \ No newline at end of file diff --git a/packages/twenty-front/src/locales/pt/messages.ts b/packages/twenty-front/src/locales/pt/messages.ts new file mode 100644 index 000000000..7263b31c3 --- /dev/null +++ b/packages/twenty-front/src/locales/pt/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"vXIe7J\":[\"Language\"],\"AXTJAW\":[\"Select your preferred language\"]}")as Messages; \ No newline at end of file diff --git a/packages/twenty-front/src/locales/zh/messages.ts b/packages/twenty-front/src/locales/zh/messages.ts new file mode 100644 index 000000000..7263b31c3 --- /dev/null +++ b/packages/twenty-front/src/locales/zh/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"vXIe7J\":[\"Language\"],\"AXTJAW\":[\"Select your preferred language\"]}")as Messages; \ No newline at end of file diff --git a/packages/twenty-front/src/modules/app/components/App.tsx b/packages/twenty-front/src/modules/app/components/App.tsx index af4c1eec0..b20cd63a2 100644 --- a/packages/twenty-front/src/modules/app/components/App.tsx +++ b/packages/twenty-front/src/modules/app/components/App.tsx @@ -5,29 +5,41 @@ import { RecoilDebugObserverEffect } from '@/debug/components/RecoilDebugObserve import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary'; import { ExceptionHandlerProvider } from '@/error-handler/components/ExceptionHandlerProvider'; import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; import { HelmetProvider } from 'react-helmet-async'; import { RecoilRoot } from 'recoil'; import { RecoilURLSyncJSON } from 'recoil-sync'; import { IconsProvider } from 'twenty-ui'; +import { messages as enMessages } from '../../../locales/en/messages'; +import { messages as frMessages } from '../../../locales/fr/messages'; + +i18n.load({ + en: enMessages, + fr: frMessages, +}); +i18n.activate('fr'); export const App = () => { return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 0caaa75c5..ed9c32afb 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -123,11 +123,11 @@ const SettingsProfile = lazy(() => })), ); -const SettingsAppearance = lazy(() => +const SettingsExperience = lazy(() => import( - '~/pages/settings/profile/appearance/components/SettingsAppearance' + '~/pages/settings/profile/appearance/components/SettingsExperience' ).then((module) => ({ - default: module.SettingsAppearance, + default: module.SettingsExperience, })), ); @@ -278,7 +278,7 @@ export const SettingsRoutes = ({ }> } /> - } /> + } /> } /> } /> { @@ -279,6 +280,7 @@ export const useAuth = () => { ) : TimeFormat[detectTimeFormat()], }); + i18n.activate(workspaceMember.locale ?? 'en'); } const workspace = user.currentWorkspace ?? null; diff --git a/packages/twenty-front/src/modules/navigation/components/__stories__/AppNavigationDrawer.stories.tsx b/packages/twenty-front/src/modules/navigation/components/__stories__/AppNavigationDrawer.stories.tsx index b7db0bd26..cce977e6c 100644 --- a/packages/twenty-front/src/modules/navigation/components/__stories__/AppNavigationDrawer.stories.tsx +++ b/packages/twenty-front/src/modules/navigation/components/__stories__/AppNavigationDrawer.stories.tsx @@ -76,6 +76,6 @@ export const Main: Story = {}; export const Settings: Story = { args: { mobileNavigationDrawer: 'settings', - routePath: '/settings/appearance', + routePath: '/settings/experience', }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts index d6a7ce756..9d9c8e8ed 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts @@ -16,8 +16,8 @@ import { IconPrinter, IconSettings, } from 'twenty-ui'; -import { FeatureFlag, FieldMetadataType } from '~/generated-metadata/graphql'; -import { FeatureFlagKey } from '~/generated/graphql'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FeatureFlag, FeatureFlagKey } from '~/generated/graphql'; export const useRecordShowContainerTabs = ( loading: boolean, diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index ddc3dfdec..07354f6d8 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -106,7 +106,7 @@ export const SettingsNavigationDrawerItems = () => { /> diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index dcb0a276a..968bbf64c 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -1,6 +1,6 @@ export enum SettingsPath { ProfilePage = 'profile', - Appearance = 'appearance', + Experience = 'experience', Accounts = 'accounts', NewAccount = 'accounts/new', AccountsCalendars = 'accounts/calendars', diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx index d588e7ba5..48b5afd26 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx @@ -142,7 +142,7 @@ export const Settings: Story = { /> diff --git a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx index 89ac4e26d..41a867029 100644 --- a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx @@ -16,6 +16,7 @@ import { detectTimeZone } from '@/localization/utils/detectTimeZone'; import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDateFormatFromWorkspaceDateFormat'; import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; +import { i18n } from '@lingui/core'; import { WorkspaceMember } from '~/generated-metadata/graphql'; import { useGetCurrentUserQuery } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -91,6 +92,8 @@ export const UserProviderEffect = () => { ? getTimeFormatFromWorkspaceTimeFormat(workspaceMember.timeFormat) : TimeFormat[detectTimeFormat()], }); + + i18n.activate(workspaceMember.locale ?? 'en'); } if (isDefined(workspaceMembers)) { diff --git a/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx b/packages/twenty-front/src/pages/settings/__stories__/SettingsExperience.stories.tsx similarity index 86% rename from packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx rename to packages/twenty-front/src/pages/settings/__stories__/SettingsExperience.stories.tsx index 2e2880015..aed56b2d1 100644 --- a/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx +++ b/packages/twenty-front/src/pages/settings/__stories__/SettingsExperience.stories.tsx @@ -7,15 +7,15 @@ import { import { graphqlMocks } from '~/testing/graphqlMocks'; import { userEvent, within } from '@storybook/test'; -import { SettingsAppearance } from '../profile/appearance/components/SettingsAppearance'; +import { SettingsExperience } from '../profile/appearance/components/SettingsExperience'; Date.now = () => new Date('2022-06-13T12:33:37.000Z').getTime(); const meta: Meta = { - title: 'Pages/Settings/SettingsAppearance', - component: SettingsAppearance, + title: 'Pages/Settings/SettingsExperience', + component: SettingsExperience, decorators: [PageDecorator], - args: { routePath: '/settings/appearance' }, + args: { routePath: '/settings/experience' }, parameters: { msw: graphqlMocks, date: new Date(2021, 1, 1), @@ -24,13 +24,13 @@ const meta: Meta = { export default meta; -export type Story = StoryObj; +export type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText('Appearance', undefined, { + await canvas.findAllByText('Experience', undefined, { timeout: 3000, }); 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 new file mode 100644 index 000000000..8fa6966f6 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx @@ -0,0 +1,96 @@ +import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { Select } from '@/ui/input/components/Select'; + +import { i18n } from '@lingui/core'; +import { isDefined } from '~/utils/isDefined'; +import { logError } from '~/utils/logError'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; + +export const LocalePicker = () => { + const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState( + currentWorkspaceMemberState, + ); + + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + }); + + const updateWorkspaceMember = async (changedFields: any) => { + if (!currentWorkspaceMember?.id) { + throw new Error('User is not logged in'); + } + + try { + await updateOneRecord({ + idToUpdate: currentWorkspaceMember.id, + updateOneRecordInput: changedFields, + }); + } catch (error) { + logError(error); + } + }; + + if (!isDefined(currentWorkspaceMember)) return; + + const handleLocaleChange = (value: string) => { + setCurrentWorkspaceMember({ + ...currentWorkspaceMember, + ...{ locale: value }, + }); + updateWorkspaceMember({ locale: value }); + + i18n.activate(value); + }; + + return ( + +