From 5ab181c6de93ca5dd5deb2b9c4d935de3d11e00e Mon Sep 17 00:00:00 2001 From: rvveber Date: Mon, 18 Nov 2024 17:55:42 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B(backend)=20email=20invite=20in?= =?UTF-8?q?=20receivers=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E-mails sent for granting access are sent in the receiving users language. Falling back to system default language. --- CHANGELOG.md | 8 ++ src/backend/core/api/viewsets.py | 10 +-- .../documents/test_api_document_accesses.py | 9 +++ .../test_api_document_accesses_create.py | 76 ++++++++++++++++++- .../test_api_document_invitations.py | 53 ++----------- src/backend/core/tests/test_api_config.py | 6 +- src/backend/core/tests/test_api_users.py | 1 + src/backend/impress/settings.py | 6 +- .../e2e/__tests__/app-impress/config.spec.ts | 4 +- .../api/useCreateDocInvitation.tsx | 6 -- .../members-add/api/useCreateDocAccess.tsx | 6 -- .../members-add/components/AddMembers.tsx | 4 - .../impress/src/i18n/hooks/useLanguage.tsx | 15 ---- src/frontend/apps/impress/src/i18n/types.ts | 2 +- 14 files changed, 113 insertions(+), 93 deletions(-) delete mode 100644 src/frontend/apps/impress/src/i18n/hooks/useLanguage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e174b20..18da1b871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to ## [Unreleased] +## Added + +## Changed + +## Fixed + +- 🐛(backend) invitation e-mails in receivers language #401 + ## [1.8.2] - 2024-11-28 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 75c0f5c4c..753597386 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -741,10 +741,9 @@ class DocumentAccessViewSet( def perform_create(self, serializer): """Add a new access to the document and send an email to the new added user.""" access = serializer.save() - language = self.request.headers.get("Content-Language", "en-us") access.document.email_invitation( - language, + access.user.language, access.user.email, access.role, self.request.user, @@ -984,10 +983,11 @@ def perform_create(self, serializer): """Save invitation to a document then send an email to the invited user.""" invitation = serializer.save() - language = self.request.headers.get("Content-Language", "en-us") - invitation.document.email_invitation( - language, invitation.email, invitation.role, self.request.user + self.request.user.language, + invitation.email, + invitation.role, + self.request.user, ) diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 9d04d9240..7ac12578b 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -15,6 +15,9 @@ pytestmark = pytest.mark.django_db +# List + + def test_api_document_accesses_list_anonymous(): """Anonymous users should not be allowed to list document accesses.""" document = factories.DocumentFactory() @@ -128,6 +131,9 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_teams): ) +# Retrieve + + def test_api_document_accesses_retrieve_anonymous(): """ Anonymous users should not be allowed to retrieve a document access. @@ -216,6 +222,9 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_tea } +# Update + + def test_api_document_accesses_update_anonymous(): """Anonymous users should not be allowed to update a document access.""" access = factories.UserDocumentAccessFactory() diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index bd96d04dd..43f40299a 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -16,6 +16,9 @@ pytestmark = pytest.mark.django_db +# Create + + def test_api_document_accesses_create_anonymous(): """Anonymous users should not be allowed to create document accesses.""" document = factories.DocumentFactory() @@ -123,7 +126,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user document=document, team="lasuite", role="administrator" ) - other_user = factories.UserFactory() + other_user = factories.UserFactory(language="en-us") # It should not be allowed to create an owner access response = client.post( @@ -198,7 +201,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams): document=document, team="lasuite", role="owner" ) - other_user = factories.UserFactory() + other_user = factories.UserFactory(language="en-us") role = random.choice([role[0] for role in models.RoleChoices.choices]) @@ -233,3 +236,72 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams): in email_content ) assert "docs/" + str(document.id) + "/" in email_content + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams): + """ + The email sent to the accesses to notify them of the adding, should be in their language. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + assert len(mail.outbox) == 0 + + other_users = ( + factories.UserFactory(language="en-us"), + factories.UserFactory(language="fr-fr"), + ) + + for index, other_user in enumerate(other_users): + expected_language = other_user.language + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user_id": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.DocumentAccess.objects.filter(user=other_user).count() == 1 + new_document_access = models.DocumentAccess.objects.filter( + user=other_user + ).get() + other_user_data = serializers.UserSerializer(instance=other_user).data + assert response.json() == { + "id": str(new_document_access.id), + "user": other_user_data, + "team": "", + "role": role, + "abilities": new_document_access.get_abilities(user), + } + assert len(mail.outbox) == index + 1 + email = mail.outbox[index] + assert email.to == [other_user_data["email"]] + email_content = " ".join(email.body.split()) + if expected_language == "en-us": + assert ( + f"{user.full_name} shared a document with you: {document.title}" + in email_content + ) + elif expected_language == "fr-fr": + assert ( + f"{user.full_name} a partagé un document avec vous: {document.title}" + in email_content + ) + assert "docs/" + str(document.id) + "/" in email_content diff --git a/src/backend/core/tests/documents/test_api_document_invitations.py b/src/backend/core/tests/documents/test_api_document_invitations.py index 1b9e61688..db72167c9 100644 --- a/src/backend/core/tests/documents/test_api_document_invitations.py +++ b/src/backend/core/tests/documents/test_api_document_invitations.py @@ -368,7 +368,7 @@ def test_api_document_invitations_create_privileged_members( Only owners and administrators should be able to invite new users. Only owners can invite owners. """ - user = factories.UserFactory() + user = factories.UserFactory(language="en-us") document = factories.DocumentFactory() if via == USER: factories.UserDocumentAccessFactory(document=document, user=user, role=inviting) @@ -417,11 +417,11 @@ def test_api_document_invitations_create_privileged_members( } -def test_api_document_invitations_create_email_from_content_language(): +def test_api_document_invitations_create_email_from_senders_language(): """ - The email generated is from the language set in the Content-Language header + When inviting on a document a user who does not exist yet in our database, the invitation email should be sent in the language of the sending user. """ - user = factories.UserFactory() + user = factories.UserFactory(language="fr-fr") document = factories.DocumentFactory() factories.UserDocumentAccessFactory(document=document, user=user, role="owner") @@ -439,7 +439,6 @@ def test_api_document_invitations_create_email_from_content_language(): f"/api/v1.0/documents/{document.id!s}/invitations/", invitation_values, format="json", - headers={"Content-Language": "fr-fr"}, ) assert response.status_code == 201 @@ -458,53 +457,11 @@ def test_api_document_invitations_create_email_from_content_language(): ) -def test_api_document_invitations_create_email_from_content_language_not_supported(): - """ - If the language from the Content-Language is not supported - it will display the default language, English. - """ - user = factories.UserFactory() - document = factories.DocumentFactory() - factories.UserDocumentAccessFactory(document=document, user=user, role="owner") - - invitation_values = { - "email": "guest@example.com", - "role": "reader", - } - - assert len(mail.outbox) == 0 - - client = APIClient() - client.force_login(user) - - response = client.post( - f"/api/v1.0/documents/{document.id!s}/invitations/", - invitation_values, - format="json", - headers={"Content-Language": "not-supported"}, - ) - - assert response.status_code == 201 - assert response.json()["email"] == "guest@example.com" - assert models.Invitation.objects.count() == 1 - assert len(mail.outbox) == 1 - - email = mail.outbox[0] - - assert email.to == ["guest@example.com"] - - email_content = " ".join(email.body.split()) - assert ( - f"{user.full_name} shared a document with you: {document.title}" - in email_content - ) - - def test_api_document_invitations_create_email_full_name_empty(): """ If the full name of the user is empty, it will display the email address. """ - user = factories.UserFactory(full_name="") + user = factories.UserFactory(full_name="", language="en-us") document = factories.DocumentFactory() factories.UserDocumentAccessFactory(document=document, user=user, role="owner") diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 96888be54..aebd12425 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -38,7 +38,11 @@ def test_api_config(is_authenticated): "CRISP_WEBSITE_ID": "123", "ENVIRONMENT": "test", "FRONTEND_THEME": "test-theme", - "LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]], + "LANGUAGES": [ + ["en-us", "English"], + ["fr-fr", "Français"], + ["de-de", "Deutsch"], + ], "LANGUAGE_CODE": "en-us", "MEDIA_BASE_URL": "http://testserver/", "SENTRY_DSN": "https://sentry.test/123", diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index e739d4d1f..321d0c011 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -163,6 +163,7 @@ def test_api_users_retrieve_me_authenticated(): "id": str(user.id), "email": user.email, "full_name": user.full_name, + "language": user.language, "short_name": user.short_name, } diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 3bb1d830a..12be80f74 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -232,9 +232,9 @@ class Base(Configuration): # fallback/default languages throughout the app. LANGUAGES = values.SingleNestedTupleValue( ( - ("en-us", _("English")), - ("fr-fr", _("French")), - ("de-de", _("German")), + ("en-us", "English"), + ("fr-fr", "Français"), + ("de-de", "Deutsch"), ) ) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index e005ca3a5..82a6d3f5a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -12,8 +12,8 @@ const config = { MEDIA_BASE_URL: 'http://localhost:8083', LANGUAGES: [ ['en-us', 'English'], - ['fr-fr', 'French'], - ['de-de', 'German'], + ['fr-fr', 'Français'], + ['de-de', 'Deutsch'], ], LANGUAGE_CODE: 'en-us', SENTRY_DSN: null, 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 a2230b3de..ca3537a1b 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 @@ -10,7 +10,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 { useBroadcastStore } from '@/stores'; import { OptionType } from '../types'; @@ -21,20 +20,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/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'; From 6a562b458a81888e49fa6a3a7bdf972e7878e68b Mon Sep 17 00:00:00 2001 From: rvveber Date: Mon, 25 Nov 2024 15:36:07 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8(frontend)=20sync=20user=20and=20f?= =?UTF-8?q?rontend=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Language change in the frontend, the user language is updated via API. If user language is available, it will be preferred and set in the frontend. --- CHANGELOG.md | 2 + src/backend/core/api/serializers.py | 2 +- src/backend/demo/defaults.py | 17 +-- .../demo/management/commands/create_demo.py | 3 +- .../__tests__/app-impress/doc-editor.spec.ts | 35 ------ .../app-impress/doc-member-create.spec.ts | 46 -------- .../__tests__/app-impress/language.spec.ts | 90 +++++++++++++-- .../apps/impress/src/core/AppProvider.tsx | 1 - .../apps/impress/src/core/auth/api/types.ts | 2 + .../src/core/config/ConfigProvider.tsx | 21 ++++ .../doc-editor/components/BlockNoteEditor.tsx | 2 +- .../src/features/language/LanguagePicker.tsx | 103 ++++++++++++------ .../language/api/useChangeUserLanguage.tsx | 45 ++++++++ src/frontend/apps/impress/src/i18n/conf.ts | 7 -- .../apps/impress/src/i18n/initI18n.ts | 11 +- src/frontend/apps/impress/src/i18n/types.ts | 2 - 16 files changed, 239 insertions(+), 150 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx delete mode 100644 src/frontend/apps/impress/src/i18n/conf.ts delete mode 100644 src/frontend/apps/impress/src/i18n/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 18da1b871..ded44e650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to ## Changed +- ✨(frontend) sync user and frontend language #401 + ## Fixed - 🐛(backend) invitation e-mails in receivers language #401 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 9a81fc47e..e26afdec8 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -18,7 +18,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["id", "email", "full_name", "short_name"] + fields = ["id", "email", "full_name", "short_name", "language"] read_only_fields = ["id", "email", "full_name", "short_name"] diff --git a/src/backend/demo/defaults.py b/src/backend/demo/defaults.py index 4f6fb5a2f..4b082e39c 100644 --- a/src/backend/demo/defaults.py +++ b/src/backend/demo/defaults.py @@ -7,17 +7,12 @@ } DEV_USERS = [ + {"username": "impress", "email": "impress@impress.world", "language": "en-us"}, + {"username": "user-e2e-webkit", "email": "user@webkit.e2e", "language": "en-us"}, + {"username": "user-e2e-firefox", "email": "user@firefox.e2e", "language": "en-us"}, { - "username": "impress", - "email": "impress@impress.world", + "username": "user-e2e-chromium", + "email": "user@chromium.e2e", + "language": "en-us", }, - { - "username": "user-e2e-webkit", - "email": "user@webkit.e2e", - }, - { - "username": "user-e2e-firefox", - "email": "user@firefox.e2e", - }, - {"username": "user-e2e-chromium", "email": "user@chromium.e2e"}, ] diff --git a/src/backend/demo/management/commands/create_demo.py b/src/backend/demo/management/commands/create_demo.py index 4ac9efc71..d7ce3266a 100644 --- a/src/backend/demo/management/commands/create_demo.py +++ b/src/backend/demo/management/commands/create_demo.py @@ -174,7 +174,8 @@ def create_demo(stdout): is_superuser=False, is_active=True, is_staff=False, - language=random.choice(settings.LANGUAGES)[0], + language=dev_user["language"] + or random.choice(settings.LANGUAGES)[0], ) ) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index d41660a5d..3771ebcb0 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -9,41 +9,6 @@ test.beforeEach(async ({ page }) => { }); test.describe('Doc Editor', () => { - test('it check translations of the slash menu when changing language', async ({ - page, - browserName, - }) => { - await createDoc(page, 'doc-toolbar', browserName, 1); - - const header = page.locator('header').first(); - const editor = page.locator('.ProseMirror'); - // Trigger slash menu to show english menu - await editor.click(); - await editor.fill('/'); - await expect(page.getByText('Headings', { exact: true })).toBeVisible(); - await header.click(); - await expect(page.getByText('Headings', { exact: true })).toBeHidden(); - - // Reset menu - await editor.click(); - await editor.fill(''); - - // Change language to French - await header.click(); - await header.getByRole('combobox').getByText('English').click(); - await header.getByRole('option', { name: 'Français' }).click(); - await expect( - header.getByRole('combobox').getByText('Français'), - ).toBeVisible(); - - // Trigger slash menu to show french menu - await editor.click(); - await editor.fill('/'); - await expect(page.getByText('Titres', { exact: true })).toBeVisible(); - await header.click(); - await expect(page.getByText('Titres', { exact: true })).toBeHidden(); - }); - test('it checks default toolbar buttons are displayed', async ({ page, browserName, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts index 8450b6f33..58a4ec079 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts @@ -111,9 +111,6 @@ test.describe('Document create member', () => { await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible(); const responseCreateInvitation = await responsePromiseCreateInvitation; expect(responseCreateInvitation.ok()).toBeTruthy(); - expect( - responseCreateInvitation.request().headers()['content-language'], - ).toBe('en-us'); // Check user added await expect( @@ -121,9 +118,6 @@ test.describe('Document create member', () => { ).toBeVisible(); const responseAddUser = await responsePromiseAddUser; expect(responseAddUser.ok()).toBeTruthy(); - expect(responseAddUser.request().headers()['content-language']).toBe( - 'en-us', - ); const listInvitation = page.getByLabel('List invitation card'); await expect(listInvitation.locator('li').getByText(email)).toBeVisible(); @@ -225,46 +219,6 @@ test.describe('Document create member', () => { expect(responseCreateInvitationFail.ok()).toBeFalsy(); }); - test('The invitation endpoint get the language of the website', async ({ - page, - browserName, - }) => { - await createDoc(page, 'user-invitation', browserName, 1); - - const header = page.locator('header').first(); - await header.getByRole('combobox').getByText('EN').click(); - await header.getByRole('option', { name: 'FR' }).click(); - - await page.getByRole('button', { name: 'Partager' }).click(); - - const inputSearch = page.getByLabel( - /Trouver un membre à ajouter au document/, - ); - - const email = randomName('test@test.fr', browserName, 1)[0]; - await inputSearch.fill(email); - await page.getByRole('option', { name: email }).click(); - - // Choose a role - await page.getByRole('combobox', { name: /Choisissez un rôle/ }).click(); - await page.getByRole('option', { name: 'Administrateur' }).click(); - - const responsePromiseCreateInvitation = page.waitForResponse( - (response) => - response.url().includes('/invitations/') && response.status() === 201, - ); - - await page.getByRole('button', { name: 'Valider' }).click(); - - // Check invitation sent - await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible(); - const responseCreateInvitation = await responsePromiseCreateInvitation; - expect(responseCreateInvitation.ok()).toBeTruthy(); - expect( - responseCreateInvitation.request().headers()['content-language'], - ).toBe('fr-fr'); - }); - test('it manages invitation', async ({ page, browserName }) => { await createDoc(page, 'user-invitation', browserName, 1); 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 9d7e3f3dc..94402d6e7 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,25 @@ -import { expect, test } from '@playwright/test'; +import { Page, expect, test } from '@playwright/test'; + +import { createDoc } from './common'; 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, TestLanguage.French); + await expect( header.getByRole('combobox').getByText('Français'), ).toBeVisible(); @@ -63,12 +68,79 @@ 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, TestLanguage.French); // Check for French 404 response await check404Response('Pas trouvé.'); }); + + test('it check translations of the slash menu when changing language', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-toolbar', browserName, 1); + + const header = page.locator('header').first(); + const editor = page.locator('.ProseMirror'); + // Trigger slash menu to show english menu + await editor.click(); + await editor.fill('/'); + await expect(page.getByText('Headings', { exact: true })).toBeVisible(); + await header.click(); + await expect(page.getByText('Headings', { exact: true })).toBeHidden(); + + // Reset menu + await editor.click(); + await editor.fill(''); + + // Change language to French + await waitForLanguageSwitch(page, TestLanguage.French); + + // Trigger slash menu to show french menu + await editor.click(); + await editor.fill('/'); + await expect(page.getByText('Titres', { exact: true })).toBeVisible(); + await header.click(); + await expect(page.getByText('Titres', { exact: true })).toBeHidden(); + }); }); + +test.afterEach(async ({ page }) => { + // Switch back to English - important for other tests to run as expected + await waitForLanguageSwitch(page, TestLanguage.English); +}); + +// language helper +export const TestLanguage = { + English: { + label: 'English', + expectedLocale: ['en-us'], + }, + French: { + label: 'Français', + expectedLocale: ['fr-fr'], + }, +} as const; + +type TestLanguageKey = keyof typeof TestLanguage; +type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey]; + +export async function waitForLanguageSwitch( + page: Page, + lang: TestLanguageValue, +) { + 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.label }).click(), + ]); + + const updatedUserResponse = await response.json(); + + expect(lang.expectedLocale).toContain(updatedUserResponse.language); +} diff --git a/src/frontend/apps/impress/src/core/AppProvider.tsx b/src/frontend/apps/impress/src/core/AppProvider.tsx index 39a2f9629..0c2f68848 100644 --- a/src/frontend/apps/impress/src/core/AppProvider.tsx +++ b/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -3,7 +3,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useEffect } from 'react'; import { useCunninghamTheme } from '@/cunningham'; -import '@/i18n/initI18n'; import { useResponsiveStore } from '@/stores/'; import { Auth } from './auth/'; 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..07097f550 100644 --- a/src/frontend/apps/impress/src/core/auth/api/types.ts +++ b/src/frontend/apps/impress/src/core/auth/api/types.ts @@ -4,10 +4,12 @@ * @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: string; } diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx index 570331107..705c805c6 100644 --- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -3,12 +3,16 @@ import { PropsWithChildren, useEffect } from 'react'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; +import i18n from '@/i18n/initI18n'; import { configureCrispSession } from '@/services'; import { useSentryStore } from '@/stores/useSentryStore'; +import { useAuthStore } from '../auth'; + import { useConfig } from './api/useConfig'; export const ConfigProvider = ({ children }: PropsWithChildren) => { + const { userData } = useAuthStore(); const { data: conf } = useConfig(); const { setSentry } = useSentryStore(); const { setTheme } = useCunninghamTheme(); @@ -37,6 +41,23 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { configureCrispSession(conf.CRISP_WEBSITE_ID); }, [conf?.CRISP_WEBSITE_ID]); + useEffect(() => { + if (!userData?.language || !conf?.LANGUAGES) { + return; + } + + conf.LANGUAGES.some(([available_lang]) => { + if ( + userData.language === available_lang && // language is expected by user + i18n.language !== available_lang // language not set as expected + ) { + void i18n.changeLanguage(available_lang); // change language to expected + return true; + } + return false; + }); + }, [conf?.LANGUAGES, userData?.language]); + if (!conf) { return ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 8c04127b8..b03559fae 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -81,7 +81,7 @@ export const BlockNoteEditor = ({ useSaveDoc(doc.id, provider.document, !readOnly); const { setHeadings, resetHeadings } = useHeadingStore(); const { i18n } = useTranslation(); - const lang = i18n.language; + const lang = i18n.resolvedLanguage; const { uploadFile, errorAttachment } = useUploadFile(doc.id); diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx index 0953fae5b..47f5bf927 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx @@ -1,10 +1,15 @@ -import { Select } from '@openfun/cunningham-react'; -import { useMemo } from 'react'; +import { + Select, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; import { Box, Text } from '@/components/'; -import { LANGUAGES_ALLOWED } from '@/i18n/conf'; +import { useAuthStore, useConfig } from '@/core'; + +import { useChangeUserLanguage } from './api/useChangeUserLanguage'; const SelectStyled = styled(Select)<{ $isSmall?: boolean }>` flex-shrink: 0; @@ -33,29 +38,69 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>` export const LanguagePicker = () => { const { t, i18n } = useTranslation(); - const { preload: languages } = i18n.options; + const { toast } = useToastProvider(); + const { mutateAsync: changeUserLanguage } = useChangeUserLanguage(); + const { userData } = useAuthStore(); + const { data: conf } = useConfig(); + + // Early return if LANGUAGES is not available or empty + if (!conf?.LANGUAGES || conf.LANGUAGES.length === 0) { + return null; + } + + // Create options for the select component + const optionsPicker = conf.LANGUAGES.map(([locale, label]) => ({ + value: locale, + label: label, + render: () => ( + + + translate + + + {label} + + + ), + })); + + // Soft match locale + const getMatchingLocale = (): string => { + const availableLocales = conf.LANGUAGES.map(([locale]) => locale); + return ( + availableLocales.find( + (availableLocale) => + availableLocale === i18n.language || + availableLocale.startsWith(i18n.language.split('-')[0]), + ) || availableLocales[0] + ); + }; + + // Switch i18n.language and user.language via API + const switchLanguage = (targetLocale: string): void => { + const actions: Promise[] = [i18n.changeLanguage(targetLocale)]; + + if (userData?.id) { + actions.push( + changeUserLanguage({ + userId: userData.id, + language: targetLocale, + }), + ); + } - const optionsPicker = useMemo(() => { - return (languages || []).map((lang) => ({ - value: lang, - label: lang, - render: () => ( - - - translate - - - {LANGUAGES_ALLOWED[lang]} - - - ), - })); - }, [languages]); + void Promise.all(actions).catch((err) => { + console.error('Error changing language', err); + toast(t('Failed to change the language'), VariantType.ERROR, { + duration: 3000, + }); + }); + }; return ( { showLabelWhenSelected={false} clearable={false} hideLabel - defaultValue={i18n.language} + value={getMatchingLocale()} className="c_select__no_bg" options={optionsPicker} - onChange={(e) => { - i18n.changeLanguage(e.target.value as string).catch((err) => { - console.error('Error changing language', err); - }); - }} + onChange={(e) => switchLanguage(e.target.value as string)} /> ); }; 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..0b1e960a7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { User } from '@/core'; + +interface ChangeUserLanguageParams { + userId: User['id']; + language: User['language']; +} + +export const changeUserLanguage = async ({ + userId, + language, +}: ChangeUserLanguageParams): Promise => { + const response = await fetchAPI(`users/${userId}/`, { + 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 useChangeUserLanguage() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: changeUserLanguage, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ['change-user-language'], + }); + }, + }); +} diff --git a/src/frontend/apps/impress/src/i18n/conf.ts b/src/frontend/apps/impress/src/i18n/conf.ts deleted file mode 100644 index dd42827c4..000000000 --- a/src/frontend/apps/impress/src/i18n/conf.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const LANGUAGES_ALLOWED: { [key: string]: string } = { - en: 'English', - fr: 'Français', - de: 'Deutsch', -}; -export const LANGUAGE_COOKIE_NAME = 'docs_language'; -export const BASE_LANGUAGE = 'en'; diff --git a/src/frontend/apps/impress/src/i18n/initI18n.ts b/src/frontend/apps/impress/src/i18n/initI18n.ts index cc98586b2..fe2d573c7 100644 --- a/src/frontend/apps/impress/src/i18n/initI18n.ts +++ b/src/frontend/apps/impress/src/i18n/initI18n.ts @@ -2,7 +2,6 @@ import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; -import { BASE_LANGUAGE, LANGUAGES_ALLOWED, LANGUAGE_COOKIE_NAME } from './conf'; import resources from './translations.json'; i18n @@ -10,18 +9,20 @@ i18n .use(initReactI18next) .init({ resources, - fallbackLng: BASE_LANGUAGE, - supportedLngs: Object.keys(LANGUAGES_ALLOWED), detection: { order: ['cookie', 'navigator'], // detection order caches: ['cookie'], // Use cookies to store the language preference - lookupCookie: LANGUAGE_COOKIE_NAME, + lookupCookie: 'docs_language', cookieMinutes: 525600, // Expires after one year + cookieOptions: { + sameSite: 'lax', + }, }, interpolation: { escapeValue: false, }, - preload: Object.keys(LANGUAGES_ALLOWED), + preload: Object.keys(resources), + lowerCaseLng: true, nsSeparator: false, keySeparator: false, }) diff --git a/src/frontend/apps/impress/src/i18n/types.ts b/src/frontend/apps/impress/src/i18n/types.ts deleted file mode 100644 index ccedc350c..000000000 --- a/src/frontend/apps/impress/src/i18n/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -// See: https://github.com/numerique-gouv/impress/blob/ac58341984c99c10ebfac7f8bbe1e8756c48e4d4/src/backend/impress/settings.py#L156-L161 -export type UserLanguage = 'en-us' | 'fr-fr';