diff --git a/.gitignore b/.gitignore index 074d320c0..a1fa2baa0 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,13 @@ db.sqlite3 .vscode/ *.iml .devcontainer + +# Devenv +.devenv* +devenv.local.nix +devenv.* +devenv.lock + +# Direnv +.direnv +.envrc \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e673caf..cd7648777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to ## [Unreleased] +## Changed + +- ♻️(frontend) sync user- and frontend language #401 + +## Fixed + +- 🐛(i18n) invitation e-mails in receivers language #401 + ## [1.7.0] - 2024-10-24 ## Added diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts index 3ed6317af..425723fa6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -1,20 +1,23 @@ -import { expect, test } from '@playwright/test'; +import { Page, expect, test } from '@playwright/test'; test.beforeEach(async ({ page }) => { await page.goto('/'); }); test.describe('Language', () => { - test('checks the language picker', async ({ page }) => { + test('checks language switching', async ({ page }) => { + const header = page.locator('header').first(); + + // initial language should be english await expect( page.getByRole('button', { name: 'Create a new document', }), ).toBeVisible(); - const header = page.locator('header').first(); - await header.getByRole('combobox').getByText('English').click(); - await header.getByRole('option', { name: 'Français' }).click(); + // switch to french + await waitForLanguageSwitch(page, Languages.French); + await expect( header.getByRole('combobox').getByText('Français'), ).toBeVisible(); @@ -51,12 +54,41 @@ test.describe('Language', () => { // Check for English 404 response await check404Response('Not found.'); - // Switch language to French - const header = page.locator('header').first(); - await header.getByRole('combobox').getByText('English').click(); - await header.getByRole('option', { name: 'Français' }).click(); + await waitForLanguageSwitch(page, Languages.French); // Check for French 404 response await check404Response('Pas trouvé.'); }); }); + +test.afterEach(async ({ page }) => { + // Switch back to English - important for other tests to run, as this updates the language in the user entity + // and in turn updates the language of the frontend. + // Therefore continuing tests, messages that are expected to be english would be french + await waitForLanguageSwitch(page, Languages.English); +}); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +enum Languages { + English = 'English', + French = 'Français', +} +const serverSideLanguageValues: Record = { + [Languages.English]: 'en-us', + [Languages.French]: 'fr-fr', +}; +async function waitForLanguageSwitch(page: Page, lang: Languages) { + const header = page.locator('header').first(); + await header.getByRole('combobox').click(); + + const [response] = await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes('/user') && resp.request().method() === 'PATCH', + ), + header.getByRole('option', { name: lang }).click(), + ]); + + const updatedUserResponse = await response.json(); + expect(updatedUserResponse.language).toBe(serverSideLanguageValues[lang]); +} diff --git a/src/frontend/apps/impress/src/core/AppProvider.tsx b/src/frontend/apps/impress/src/core/AppProvider.tsx index 9c6df0802..f76f95a03 100644 --- a/src/frontend/apps/impress/src/core/AppProvider.tsx +++ b/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -3,10 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useEffect } from 'react'; import { useCunninghamTheme } from '@/cunningham'; -import '@/i18n/initI18n'; +import { LANGUAGES_ALLOWED } from '@/i18n/conf'; +import i18n from '@/i18n/initI18n'; import { useResponsiveStore } from '@/stores/'; -import { Auth } from './auth/'; +import { Auth, useAuthStore } from './auth/'; /** * QueryClient: @@ -26,6 +27,7 @@ const queryClient = new QueryClient({ export function AppProvider({ children }: { children: React.ReactNode }) { const { theme } = useCunninghamTheme(); + const { userData } = useAuthStore(); const initializeResizeListener = useResponsiveStore( (state) => state.initializeResizeListener, @@ -36,6 +38,16 @@ export function AppProvider({ children }: { children: React.ReactNode }) { return cleanupResizeListener; }, [initializeResizeListener]); + useEffect(() => { + if (userData?.language) { + Object.keys(LANGUAGES_ALLOWED).forEach((key) => { + if (userData.language.includes(key) && i18n.language !== key) { + void i18n.changeLanguage(key); + } + }); + } + }, [userData?.language]); + return ( diff --git a/src/frontend/apps/impress/src/core/auth/api/types.ts b/src/frontend/apps/impress/src/core/auth/api/types.ts index ef1893606..b50095a78 100644 --- a/src/frontend/apps/impress/src/core/auth/api/types.ts +++ b/src/frontend/apps/impress/src/core/auth/api/types.ts @@ -1,13 +1,17 @@ +import { UserLanguage } from '@/i18n/types'; + /** * Represents user retrieved from the API. * @interface User * @property {string} id - The id of the user. * @property {string} email - The email of the user. * @property {string} name - The name of the user. + * @property {string} language - The language of the user. */ export interface User { id: string; email: string; full_name: string; short_name: string; + language: UserLanguage; } diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useCreateDocInvitation.tsx b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useCreateDocInvitation.tsx index 85aa80d88..6c63e32fb 100644 --- a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useCreateDocInvitation.tsx +++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useCreateDocInvitation.tsx @@ -4,7 +4,6 @@ import { APIError, errorCauses, fetchAPI } from '@/api'; import { User } from '@/core/auth'; import { Doc, Role } from '@/features/docs/doc-management'; import { OptionType } from '@/features/docs/members/members-add/types'; -import { ContentLanguage } from '@/i18n/types'; import { Invitation } from '../types'; @@ -14,20 +13,15 @@ interface CreateDocInvitationParams { email: User['email']; role: Role; docId: Doc['id']; - contentLanguage: ContentLanguage; } export const createDocInvitation = async ({ email, role, docId, - contentLanguage, }: CreateDocInvitationParams): Promise => { const response = await fetchAPI(`documents/${docId}/invitations/`, { method: 'POST', - headers: { - 'Content-Language': contentLanguage, - }, body: JSON.stringify({ email, role, diff --git a/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx b/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx index 516d6bb33..8c3d2f5b6 100644 --- a/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx +++ b/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx @@ -9,7 +9,6 @@ import { Role, } from '@/features/docs/doc-management'; import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list'; -import { ContentLanguage } from '@/i18n/types'; import { OptionType } from '../types'; @@ -19,20 +18,15 @@ interface CreateDocAccessParams { role: Role; docId: Doc['id']; memberId: User['id']; - contentLanguage: ContentLanguage; } export const createDocAccess = async ({ memberId, role, docId, - contentLanguage, }: CreateDocAccessParams): Promise => { const response = await fetchAPI(`documents/${docId}/accesses/`, { method: 'POST', - headers: { - 'Content-Language': contentLanguage, - }, body: JSON.stringify({ user_id: memberId, role, diff --git a/src/frontend/apps/impress/src/features/docs/members/members-add/components/AddMembers.tsx b/src/frontend/apps/impress/src/features/docs/members/members-add/components/AddMembers.tsx index 3b8fddb30..970d8ff31 100644 --- a/src/frontend/apps/impress/src/features/docs/members/members-add/components/AddMembers.tsx +++ b/src/frontend/apps/impress/src/features/docs/members/members-add/components/AddMembers.tsx @@ -10,7 +10,6 @@ import { APIError } from '@/api'; import { Box, Card, IconBG } from '@/components'; import { Doc, Role } from '@/features/docs/doc-management'; import { useCreateDocInvitation } from '@/features/docs/members/invitation-list/'; -import { useLanguage } from '@/i18n/hooks/useLanguage'; import { useResponsiveStore } from '@/stores'; import { useCreateDocAccess } from '../api'; @@ -36,7 +35,6 @@ interface ModalAddMembersProps { } export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => { - const { contentLanguage } = useLanguage(); const { t } = useTranslation(); const { isSmallMobile } = useResponsiveStore(); const [selectedUsers, setSelectedUsers] = useState([]); @@ -56,7 +54,6 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => { email: selectedUser.value.email, role: selectedRole, docId: doc.id, - contentLanguage, }); break; @@ -65,7 +62,6 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => { role: selectedRole, docId: doc.id, memberId: selectedUser.value.id, - contentLanguage, }); break; } diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx index 0953fae5b..de84bf2ca 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx @@ -6,6 +6,8 @@ import styled from 'styled-components'; import { Box, Text } from '@/components/'; import { LANGUAGES_ALLOWED } from '@/i18n/conf'; +import { useSetUserLanguage } from './api/useChangeUserLanguage'; + const SelectStyled = styled(Select)<{ $isSmall?: boolean }>` flex-shrink: 0; width: auto; @@ -33,6 +35,7 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>` export const LanguagePicker = () => { const { t, i18n } = useTranslation(); + const { mutateAsync: setUserLanguage } = useSetUserLanguage(); const { preload: languages } = i18n.options; const optionsPicker = useMemo(() => { @@ -63,13 +66,22 @@ export const LanguagePicker = () => { showLabelWhenSelected={false} clearable={false} hideLabel - defaultValue={i18n.language} + value={i18n.language} className="c_select__no_bg" options={optionsPicker} onChange={(e) => { - i18n.changeLanguage(e.target.value as string).catch((err) => { - console.error('Error changing language', err); - }); + void i18n + .changeLanguage(e.target.value as string) + .catch((err) => { + console.error('Error changing language', err); + }) + .then(() => { + setUserLanguage({ + language: i18n.language === 'fr' ? 'fr-fr' : 'en-us', + }).catch((err) => { + console.error('Error changing users language', err); + }); + }); }} /> ); diff --git a/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx b/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx new file mode 100644 index 000000000..d544ddf09 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { User, useAuthStore } from '@/core'; + +interface SetUserLanguageParams { + language: User['language']; +} + +export const setUserLanguage = async ({ + language, +}: SetUserLanguageParams): Promise => { + const { userData } = useAuthStore.getState(); + + if (!userData?.id) { + console.warn('Id of user is needed for this request.'); + return {} as Promise; + } + + const response = await fetchAPI(`users/${userData.id}/`, { + method: 'PATCH', + body: JSON.stringify({ + language, + }), + }); + + if (!response.ok) { + throw new APIError( + `Failed to change the user language to ${language}`, + await errorCauses(response, { + value: language, + type: 'language', + }), + ); + } + + return response.json() as Promise; +}; + +export function useSetUserLanguage() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: setUserLanguage, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ['set-user-language'], + }); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts index 5e13d7430..98fef92be 100644 --- a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts @@ -216,6 +216,7 @@ export class ApiPlugin implements WorkboxPlugin { email: 'dummy-email', full_name: 'dummy-full-name', short_name: 'dummy-short-name', + language: 'en-us', }, abilities: { destroy: false, diff --git a/src/frontend/apps/impress/src/i18n/hooks/useLanguage.tsx b/src/frontend/apps/impress/src/i18n/hooks/useLanguage.tsx deleted file mode 100644 index 4879bda8e..000000000 --- a/src/frontend/apps/impress/src/i18n/hooks/useLanguage.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -import { ContentLanguage } from '../types'; - -export const useLanguage = (): { - language: string; - contentLanguage: ContentLanguage; -} => { - const { i18n } = useTranslation(); - - return { - language: i18n.language, - contentLanguage: i18n.language === 'fr' ? 'fr-fr' : 'en-us', - }; -}; diff --git a/src/frontend/apps/impress/src/i18n/types.ts b/src/frontend/apps/impress/src/i18n/types.ts index d9b0064aa..ccedc350c 100644 --- a/src/frontend/apps/impress/src/i18n/types.ts +++ b/src/frontend/apps/impress/src/i18n/types.ts @@ -1,2 +1,2 @@ // See: https://github.com/numerique-gouv/impress/blob/ac58341984c99c10ebfac7f8bbe1e8756c48e4d4/src/backend/impress/settings.py#L156-L161 -export type ContentLanguage = 'en-us' | 'fr-fr'; +export type UserLanguage = 'en-us' | 'fr-fr';