Compare commits

...

18 Commits

Author SHA1 Message Date
Anthony LC 5bb7ad643a 🔖(minor) release 2.4.0
Added:
- (frontend) synchronize language-choice

Changed:
- Use sentry tags instead of extra scope

Fixed:
- 🐛(frontend) fix collaboration error
2025-03-06 15:59:34 +01:00
Anthony LC 57b8881fc6 📌(frontend) pin blocknote globally
Blocknote does not pinned the version.
We get bumped version instead of the version we want.
We pin the version of blocknote globally to
avoid this issue.
2025-03-06 15:09:17 +01:00
Anthony LC 89ad610ba6 🐛(frontend) fix collaboration error
We upgrade blocknote to 0.23.2-hotfix.0,
it includes a fix with the collaboration.
2025-03-06 15:09:17 +01:00
rvveber 251787b835 🚨(tests) add language related tests; fix getByRole not working in tests
- adds tests and test-utility for solid language switching in tests
- fixes where ...getByRole(menuitem... would not return a valid object
2025-03-05 14:29:24 +01:00
rvveber f95173e096 🐛(frontend) allow left panel to update on language change
- fixes a bug where after language-sync the left panel would remain
  in the same language as before.
2025-03-05 14:29:24 +01:00
rvveber a7944cce80 (app) get language from backend; set browser-detected language if null
- adds useLanguageSynchronizer hook to update the:
  1. frontend-language to the user-preference - if there is one.
  2. user-preference to the (browser-detected) frontend-language - otherwise.
2025-03-05 14:29:24 +01:00
rvveber 7941fc91d5 🐛(frontend) set toolbar-popup to current language
- ensure editor is translated to i18n.resolvedLanguage => en
  as i18n.language could hold more accurate locale => en-GB etc..
2025-03-05 14:29:24 +01:00
rvveber 7fc83a4fcd (frontend) the LanguagePicker now uses config as options
- config endpoint languages are used as available options for LanguagePicker
- updating the language from it, triggers an update on the user via API
2025-03-05 14:29:24 +01:00
rvveber 2bf47b7705 (backend) the LanguagePicker now uses config as options
- config endpoint languages are used as available options for LanguagePicker
- updating the language from it, triggers an update on the user via API
2025-03-05 14:29:24 +01:00
rvveber 23b0214a2a (frontend) add language utility for "locale"
- Adds a helper for working with locales
- More details in their annotations
- Unnecessary, if in the future, the backend uses
  the same locales as the keys in the translations (ISO 639-1)
2025-03-05 14:29:24 +01:00
rvveber f244509de3 (frontend) add API access for 'language' attribute on User model
- allow the language attribute on the user to be updated via API
- add frontend function to update the user language via API
- extend defaults on the test users, to have fixed language in E2E tests
- extend types and variables using the types with the new field
2025-03-05 14:29:24 +01:00
rvveber fda5f8f008 (backend) add API access for 'language' attribute on User model
- allow the language attribute on the user to be updated via API
- add frontend function to update the user language via API
- extend defaults on the test users, to have fixed language in E2E tests
- extend types and variables using the types with the new field
2025-03-05 14:29:24 +01:00
rvveber 9a79b09b07 🔨(backend) make the 'language' attribute on the User model nullable
- allow the language on the user to be unset
- set the default language to be unset
- helps us determine that the user has yet to set a language preference
2025-03-05 14:29:24 +01:00
rvveber b24acd14e2 🔨(frontend) email invitation in invited user's language
- language for invitation emails => language saved on the invited user
- if invited user does not exist yet => language of the sending user
- if for some reason no sending user => system default language
2025-03-05 14:29:24 +01:00
rvveber 1531846115 🔨(backend) email invitation in invited user's language
- language for invitation emails => language saved on the invited user
- if invited user does not exist yet => language of the sending user
- if for some reason no sending user => system default language
2025-03-05 14:29:24 +01:00
Manuel Raynaud ebf6d46e37 ♻️(front) use sentry tags instead of extra scope
To ease filtering issues on sentry, we want to use tags instead of extra
scope. Tags are indexed and searchable, it's not the case with extra
scope. Moreover using setEtra to add additional data is deprecated.
2025-03-05 10:26:23 +01:00
Manuel Raynaud b9b5f86cf4 ️(back) restrict documents to restore using only the queryset
To determine the descendant to restore or not, we were looking building
a complex exclude clause. This can be simplify focusing only on data we
already have without making an extra query to fetch the list of
descendant to exclude.
2025-03-04 18:03:18 +01:00
renovate[bot] 56412b0be5 ⬆️(dependencies) update python dependencies 2025-03-04 14:24:58 +01:00
55 changed files with 773 additions and 423 deletions
+16
View File
@@ -8,6 +8,20 @@ and this project adheres to
## [Unreleased]
## [2.4.0] - 2025-03-06
## Added
- ✨(frontend) synchronize language-choice #401
## Changed
- Use sentry tags instead of extra scope
## Fixed
- 🐛(frontend) fix collaboration error #684
## [2.3.0] - 2025-03-03
## Added
@@ -16,6 +30,7 @@ and this project adheres to
- 🔒️ Manage unsafe attachments #663
- ✨(frontend) Custom block quote with export #646
- ✨(frontend) add open source section homepage #666
- ✨(frontend) synchronize language-choice #401
## Changed
@@ -28,6 +43,7 @@ and this project adheres to
- 🐛(backend) allow any type of extensions for media download #671
- ♻️(frontend) improve table pdf rendering
- 🐛(email) invitation emails in receivers language
## [2.2.0] - 2025-02-10
+1 -1
View File
@@ -23,7 +23,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"]
+7 -5
View File
@@ -1167,13 +1167,14 @@ 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.send_invitation_email(
access.user.email,
access.role,
self.request.user,
language,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
)
def perform_update(self, serializer):
@@ -1399,10 +1400,11 @@ class InvitationViewset(
"""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.send_invitation_email(
invitation.email, invitation.role, self.request.user, language
invitation.email,
invitation.role,
self.request.user,
self.request.user.language or settings.LANGUAGE_CODE,
)
@@ -0,0 +1,36 @@
# Generated by Django 5.1.5 on 2025-03-04 12:23
from django.db import migrations, models
import core.models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_update_blank_title"),
]
operations = [
migrations.AlterModelManagers(
name="user",
managers=[
("objects", core.models.UserManager()),
],
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
],
default=None,
help_text="The language in which the user wants to see the interface.",
max_length=10,
null=True,
verbose_name="language",
),
),
]
+11 -17
View File
@@ -194,9 +194,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
default=None,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
null=True,
blank=True,
)
timezone = TimeZoneField(
choices_display="WITH_GMT_OFFSET",
@@ -792,6 +794,9 @@ class Document(MP_Node, BaseModel):
}
)
# save the current deleted_at value to exclude it from the descendants update
current_deleted_at = self.deleted_at
# Restore the current document
self.deleted_at = None
@@ -804,23 +809,12 @@ class Document(MP_Node, BaseModel):
.first()
)
self.ancestors_deleted_at = ancestors_deleted_at
self.save()
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
# Update descendants excluding those who were deleted prior to the deletion of the
# current document (the ancestor_deleted_at date for those should already by good)
# The number of deleted descendants should not be too big so we can handcraft a union
# clause for them:
deleted_descendants_paths = (
self.get_descendants()
.filter(deleted_at__isnull=False)
.values_list("path", flat=True)
)
exclude_condition = models.Q(
*(models.Q(path__startswith=path) for path in deleted_descendants_paths)
)
self.get_descendants().exclude(exclude_condition).update(
ancestors_deleted_at=self.ancestors_deleted_at
)
self.get_descendants().exclude(
models.Q(deleted_at__isnull=False)
| models.Q(ancestors_deleted_at__lt=current_deleted_at)
).update(ancestors_deleted_at=self.ancestors_deleted_at)
class LinkTrace(BaseModel):
@@ -16,6 +16,9 @@ from core.tests.conftest import TEAM, USER, VIA
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(
@@ -199,7 +202,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])
@@ -235,3 +238,73 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
f"on the following document: {document.title}"
) 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())
email_subject = " ".join(email.subject.split())
if expected_language == "en-us":
assert (
f"{user.full_name} shared a document with you: {document.title}".lower()
in email_subject.lower()
)
elif expected_language == "fr-fr":
assert (
f"{user.full_name} a partagé un document avec vous: {document.title}".lower()
in email_subject.lower()
)
assert "docs/" + str(document.id) + "/" in email_content.lower()
@@ -370,7 +370,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)
@@ -422,11 +422,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")
@@ -444,7 +444,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
@@ -464,50 +463,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!" 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")
+5 -1
View File
@@ -39,7 +39,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/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
+1
View File
@@ -158,6 +158,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,
}
@@ -796,3 +796,122 @@ def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
new_nb_accesses = document.nb_accesses
assert new_nb_accesses == 0
assert cache.get(key) == 0 # Cache should now contain the new value
def test_models_documents_restore(django_assert_num_queries):
"""The restore method should restore a soft-deleted document."""
document = factories.DocumentFactory()
document.soft_delete()
document.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
with django_assert_num_queries(6):
document.restore()
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at == document.deleted_at
def test_models_documents_restore_complex(django_assert_num_queries):
"""The restore method should restore a soft-deleted document and its ancestors."""
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child1 = factories.DocumentFactory(parent=document)
child2 = factories.DocumentFactory(parent=document)
# Soft delete first the document
document.soft_delete()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Soft delete the grand parent
grand_parent.soft_delete()
grand_parent.refresh_from_db()
parent.refresh_from_db()
assert grand_parent.deleted_at is not None
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
assert parent.ancestors_deleted_at == grand_parent.deleted_at
# item, child1 and child2 should not be affected
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Restore the item
with django_assert_num_queries(8):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
grand_parent.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at == grand_parent.deleted_at
# child 1 and child 2 should now have the same ancestors_deleted_at as the grand parent
assert child1.ancestors_deleted_at == grand_parent.deleted_at
assert child2.ancestors_deleted_at == grand_parent.deleted_at
def test_models_documents_restore_complex_bis(django_assert_num_queries):
"""The restore method should restore a soft-deleted item and its ancestors."""
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child1 = factories.DocumentFactory(parent=document)
child2 = factories.DocumentFactory(parent=document)
# Soft delete first the document
document.soft_delete()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Soft delete the grand parent
grand_parent.soft_delete()
grand_parent.refresh_from_db()
parent.refresh_from_db()
assert grand_parent.deleted_at is not None
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
assert parent.ancestors_deleted_at == grand_parent.deleted_at
# item, child1 and child2 should not be affected
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Restoring the grand parent should not restore the document
# as it was deleted before the grand parent
with django_assert_num_queries(7):
grand_parent.restore()
grand_parent.refresh_from_db()
parent.refresh_from_db()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert grand_parent.deleted_at is None
assert grand_parent.ancestors_deleted_at is None
assert parent.deleted_at is None
assert parent.ancestors_deleted_at is None
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
+6 -11
View File
@@ -7,17 +7,12 @@ NB_OBJECTS = {
}
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"},
]
@@ -179,7 +179,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],
)
)
+11 -6
View File
@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -220,7 +221,9 @@ class Base(Configuration):
# Languages
LANGUAGE_CODE = values.Value("en-us")
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
# cookie & language is set from frontend
LANGUAGE_COOKIE_NAME = "docs_language"
LANGUAGE_COOKIE_PATH = "/"
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -232,9 +235,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"),
)
)
@@ -646,8 +649,10 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
sentry_sdk.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
+17 -17
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.3.0"
version = "2.4.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,37 +25,37 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"boto3==1.36.7",
"boto3==1.37.5",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-filter==24.3",
"django-filter==25.1",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-storages[s3]==1.14.5",
"django-timezone-field>=5.1",
"django==5.1.5",
"django==5.1.6",
"django-treebeard==4.7.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.60.2",
"psycopg[binary]==3.2.4",
"openai==1.65.2",
"psycopg[binary]==3.2.5",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.20.0",
"sentry-sdk==2.22.0",
"url-normalize==1.4.3",
"whitenoise==6.8.2",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
]
@@ -69,21 +69,21 @@ dependencies = [
dev = [
"django-extensions==3.2.3",
"django-test-migrations==1.4.0",
"drf-spectacular-sidecar==2024.12.1",
"drf-spectacular-sidecar==2025.3.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"ipython==9.0.1",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
"ruff==0.9.9",
"types-requests==2.32.0.20250301",
]
[tool.setuptools]
@@ -97,7 +97,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByLabel(role).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -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',
POSTHOG_KEY: {},
@@ -15,41 +15,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('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
header.getByRole('button').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,
@@ -128,11 +93,7 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Connected',
})
.click();
await page.getByLabel('Connected').click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
@@ -88,11 +88,7 @@ test.describe('Doc Header', () => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
name: 'Delete document',
})
.click();
await page.getByLabel('Delete document').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -150,9 +146,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -174,11 +168,7 @@ test.describe('Doc Header', () => {
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
name: 'delete',
}),
).toBeEnabled();
await expect(page.getByLabel('Delete')).toBeEnabled();
await invitationCard.click();
const memberCard = shareModal.getByLabel('List members card');
@@ -192,11 +182,7 @@ test.describe('Doc Header', () => {
).toBeVisible();
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
name: 'delete',
}),
).toBeEnabled();
await expect(page.getByLabel('Delete')).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -230,9 +216,7 @@ test.describe('Doc Header', () => {
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -292,9 +276,7 @@ test.describe('Doc Header', () => {
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -350,7 +332,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await page.getByLabel('Copy as Markdown').click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -385,7 +367,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await page.getByLabel('Copy as HTML').click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -458,7 +440,7 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByLabel('Share').click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
@@ -492,7 +474,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByLabel('Share').click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -65,15 +65,13 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeVisible();
await expect(page.getByLabel('Reader')).toBeVisible();
await expect(page.getByLabel('Editor')).toBeVisible();
await expect(page.getByLabel('Owner')).toBeVisible();
await expect(page.getByLabel('Administrator')).toBeVisible();
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -121,7 +119,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await page.getByLabel('Owner').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -139,7 +137,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await page.getByLabel('Owner').click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -155,47 +153,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('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Saisie de recherche rapide',
});
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation sent
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);
@@ -212,7 +169,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -232,14 +189,14 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByLabel('Reader').click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete').click();
await expect(userInvitation).toBeHidden();
});
@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
await list.click();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByLabel('Reader').click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -215,11 +215,11 @@ test.describe('Document list members', () => {
await expect(mySelfMoreActions).toBeVisible();
await userReaderMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete').click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete').click();
await expect(
page.getByText('You do not have permission to view this document.'),
).toBeVisible();
@@ -18,11 +18,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, randomDoc);
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
name: 'Version history',
})
.click();
await page.getByLabel('Version history').click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
@@ -58,11 +54,7 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
name: 'Version history',
})
.click();
await page.getByLabel('Version history').click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -90,9 +82,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
await expect(page.getByLabel('Version history')).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -119,11 +109,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
name: 'Version history',
})
.click();
await page.getByLabel('Version history').click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
@@ -49,21 +49,13 @@ test.describe('Doc Visibility', () => {
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Connected',
})
.click();
await page.getByLabel('Connected').click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await page.getByLabel('Public', { exact: true }).click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
});
@@ -162,7 +154,7 @@ test.describe('Doc Visibility: Restricted', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -1,19 +1,41 @@
import { expect, test } from '@playwright/test';
import { Page, expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
import { createDoc } from './common';
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(page.getByLabel('Logout')).toBeVisible();
test.describe.serial('Language', () => {
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.afterAll(async () => {
await page.close();
});
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForLanguageSwitch(page, TestLanguage.English);
});
test.afterEach(async ({ page }) => {
// Switch back to English - important for other tests to run as expected
await waitForLanguageSwitch(page, TestLanguage.English);
});
test('checks language switching', async ({ page }) => {
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
// initial language should be english
await expect(
page.getByRole('button', {
name: 'New doc',
}),
).toBeVisible();
// switch to french
await waitForLanguageSwitch(page, TestLanguage.French);
await expect(
header.getByRole('button').getByText('Français'),
).toBeVisible();
@@ -21,7 +43,7 @@ test.describe('Language', () => {
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('button').getByText('Français').click();
await page.getByRole('menuitem', { name: 'Deutsch' }).click();
await page.getByLabel('Deutsch').click();
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
@@ -52,15 +74,76 @@ 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('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { 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();
});
});
// language helper
export const TestLanguage = {
English: {
label: 'English',
expectedLocale: ['en-us'],
},
French: {
label: 'Français',
expectedLocale: ['fr-fr'],
},
German: {
label: 'Deutsch',
expectedLocale: ['de-de'],
},
} 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('button', { name: 'arrow_drop_down' }).click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/user') && resp.request().method() === 'PATCH',
);
await page.getByLabel(lang.label).click();
const resolvedResponsePromise = await responsePromise;
const responseData = await resolvedResponsePromise.json();
expect(lang.expectedLocale).toContain(responseData.language);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "2.3.0",
"version": "2.4.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "2.3.0",
"version": "2.4.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,11 +16,11 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.1",
"@blocknote/core": "0.23.2",
"@blocknote/mantine": "0.23.2",
"@blocknote/react": "0.23.2",
"@blocknote/xl-docx-exporter": "0.23.2",
"@blocknote/xl-pdf-exporter": "0.23.2",
"@blocknote/core": "*",
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@blocknote/xl-docx-exporter": "*",
"@blocknote/xl-pdf-exporter": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "2.9.4",
@@ -4,7 +4,6 @@ import { useEffect } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Auth } from '@/features/auth';
import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { ConfigProvider } from './config/';
@@ -3,6 +3,7 @@ import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useLanguageSynchronizer } from '@/features/language/hooks/useLanguageSynchronizer';
import { CrispProvider, PostHogProvider } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
@@ -12,6 +13,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
const { synchronizeLanguage } = useLanguageSynchronizer();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
@@ -29,6 +31,10 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
void synchronizeLanguage();
}, [synchronizeLanguage]);
if (!conf) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
@@ -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. e.g. 'en-us', 'fr-fr', 'de-de'.
*/
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
language: string;
}
@@ -51,7 +51,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const readOnly = !doc.abilities.partial_update;
useSaveDoc(doc.id, provider.document, !readOnly);
const { i18n } = useTranslation();
const lang = i18n.language;
const lang = i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
@@ -10,7 +10,6 @@ import {
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/doc-share';
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<Access> => {
const response = await fetchAPI(`documents/${docId}/accesses/`, {
method: 'POST',
headers: {
'Content-Language': contentLanguage,
},
body: JSON.stringify({
user_id: memberId,
role,
@@ -4,7 +4,6 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/features/auth';
import { Doc, Role } from '@/features/docs/doc-management';
import { Invitation, OptionType } from '@/features/docs/doc-share/types';
import { ContentLanguage } from '@/i18n/types';
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
@@ -12,20 +11,15 @@ interface CreateDocInvitationParams {
email: User['email'];
role: Role;
docId: Doc['id'];
contentLanguage: ContentLanguage;
}
export const createDocInvitation = async ({
email,
role,
docId,
contentLanguage,
}: CreateDocInvitationParams): Promise<Invitation> => {
const response = await fetchAPI(`documents/${docId}/invitations/`, {
method: 'POST',
headers: {
'Content-Language': contentLanguage,
},
body: JSON.stringify({
email,
role,
@@ -12,7 +12,6 @@ import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { User } from '@/features/auth';
import { Doc, Role } from '@/features/docs';
import { useLanguage } from '@/i18n/hooks/useLanguage';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
import { OptionType } from '../types';
@@ -42,7 +41,6 @@ export const DocShareAddMemberList = ({
const { toast } = useToastProvider();
const [isLoading, setIsLoading] = useState(false);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { contentLanguage } = useLanguage();
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
const canShare = doc.abilities.accesses_manage;
const spacing = spacingsTokens();
@@ -90,7 +88,6 @@ export const DocShareAddMemberList = ({
const payload = {
role: invitationRole,
docId: doc.id,
contentLanguage,
};
return isInvitationMode
@@ -30,6 +30,7 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
full_name: invitation.email,
email: invitation.email,
short_name: invitation.email,
language: 'en-us',
};
const { toast } = useToastProvider();
@@ -132,6 +132,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
full_name: '',
email: userQuery,
short_name: '',
language: '',
};
const hasEmailInUsers = users.some((user) => user.email === userQuery);
@@ -6,7 +6,6 @@ import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Footer } from '@/features/footer';
import { LeftPanel } from '@/features/left-panel';
import { useLanguage } from '@/i18n/hooks/useLanguage';
import { useResponsiveStore } from '@/stores';
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
@@ -28,11 +27,10 @@ import { HomeHeader, getHeaderHeight } from './HomeHeader';
import { HomeSection } from './HomeSection';
export function HomeContent() {
const { t } = useTranslation();
const { i18n, t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile, isSmallMobile, isTablet } = useResponsiveStore();
const lang = useLanguage();
const isFrLanguage = lang.language === 'fr';
const isFrLanguage = i18n.resolvedLanguage === 'fr';
return (
<Box as="main">
@@ -4,25 +4,45 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
import { useConfig } from '@/core';
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
import { getMatchingLocales } from './utils/locale';
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { preload: languages } = i18n.options;
const language = i18n.language;
const { data: conf } = useConfig();
const { synchronizeLanguage } = useLanguageSynchronizer();
const language = i18n.languages[0];
Settings.defaultLocale = language;
// Compute options for dropdown
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
label: LANGUAGES_ALLOWED[lang],
isSelected: language === lang,
callback: () => {
i18n.changeLanguage(lang).catch((err) => {
console.error('Error changing language', err);
});
},
}));
}, [i18n, language, languages]);
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
return backendOptions.map(([backendLocale, label]) => {
// Determine if the option is selected
const isSelected =
getMatchingLocales([backendLocale], [language]).length > 0;
// Define callback for updating both frontend and backend languages
const callback = () => {
i18n
.changeLanguage(backendLocale)
.then(() => {
void synchronizeLanguage('toBackend');
})
.catch((err) => {
console.error('Error changing language', err);
});
};
return { label, isSelected, callback };
});
}, [conf, i18n, language, synchronizeLanguage]);
// Extract current language label for display
const currentLanguageLabel =
conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
)?.[1] || language;
return (
<DropdownMenu
@@ -54,7 +74,7 @@ export const LanguagePicker = () => {
<Text $isMaterialIcon $color="inherit" $size="xl">
translate
</Text>
{LANGUAGES_ALLOWED[language]}
{currentLanguageLabel}
</Text>
</DropdownMenu>
);
@@ -0,0 +1,45 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/features/auth/api/types';
export interface ChangeUserLanguageParams {
userId: User['id'];
language: User['language'];
}
export const changeUserLanguage = async ({
userId,
language,
}: ChangeUserLanguageParams): Promise<User> => {
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<User>;
};
export function useChangeUserLanguage() {
const queryClient = useQueryClient();
return useMutation<User, APIError, ChangeUserLanguageParams>({
mutationFn: changeUserLanguage,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['change-user-language'],
});
},
});
}
@@ -0,0 +1,82 @@
import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core';
import { useAuthQuery } from '@/features/auth/api';
import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage';
import { getMatchingLocales } from '@/features/language/utils/locale';
import { availableFrontendLanguages } from '@/i18n/initI18n';
export const useLanguageSynchronizer = () => {
const { data: conf, isSuccess: confInitialized } = useConfig();
const { data: user, isSuccess: userInitialized } = useAuthQuery();
const { i18n } = useTranslation();
const { mutateAsync: changeUserLanguage } = useChangeUserLanguage();
const languageSynchronizing = useRef(false);
const availableBackendLanguages = useMemo(() => {
return conf?.LANGUAGES.map(([locale]) => locale);
}, [conf]);
const synchronizeLanguage = useCallback(
async (direction?: 'toBackend' | 'toFrontend') => {
if (
languageSynchronizing.current ||
!userInitialized ||
!confInitialized ||
!availableBackendLanguages ||
!availableFrontendLanguages
) {
return;
}
languageSynchronizing.current = true;
try {
const userPreferredLanguages = user.language ? [user.language] : [];
const setOrDetectedLanguages = i18n.languages;
// Default direction depends on whether a user already has a language preference
direction =
direction ??
(userPreferredLanguages.length ? 'toFrontend' : 'toBackend');
if (direction === 'toBackend') {
// Update user's preference from frontends's language
const closestBackendLanguage =
getMatchingLocales(
availableBackendLanguages,
setOrDetectedLanguages,
)[0] || availableBackendLanguages[0];
await changeUserLanguage({
userId: user.id,
language: closestBackendLanguage,
});
} else {
// Update frontends's language from user's preference
const closestFrontendLanguage =
getMatchingLocales(
availableFrontendLanguages,
userPreferredLanguages,
)[0] || availableFrontendLanguages[0];
if (i18n.resolvedLanguage !== closestFrontendLanguage) {
await i18n.changeLanguage(closestFrontendLanguage);
}
}
} catch (error) {
console.error('Error synchronizing language', error);
} finally {
languageSynchronizing.current = false;
}
},
[
i18n,
user,
userInitialized,
confInitialized,
availableBackendLanguages,
changeUserLanguage,
],
);
return { synchronizeLanguage };
};
@@ -0,0 +1,49 @@
/**
* @param {string} locale - The locale string, which can be in formats like de-DE, de-CH, de_DE, de-de, de_de, or de.
* @returns {string} The regionless ISO 639-1 code. => de
*/
export function convertLocaleToISO639_1(locale: string): string {
// Split the locale string by either a hyphen (-) or an underscore (_)
return locale.split(/[-_]/)[0].toLowerCase();
}
/**
* Filters the available locales to find those that match the search criteria.
*
* @param {readonly string[]} localesAvailable - The list of available locale strings to match against.
* @param {readonly string[]} localesToSearch - The list of locale strings to search for.
* @returns {string[]} Filtered list of localesAvailable that match localesToSearch, ordered by closest match.
*/
export function getMatchingLocales(
localesAvailable: readonly string[],
localesToSearch: readonly string[],
): string[] {
const matchingLocales: string[] = [];
for (const localeToSearch of localesToSearch) {
// Exact match (case-insensitive)
const exactMatches = localesAvailable.filter(
(localeAvailable) =>
localeAvailable.toLowerCase() === localeToSearch.toLowerCase(),
);
if (exactMatches.length > 0) {
for (const matchingLocale of exactMatches) {
if (!matchingLocales.includes(matchingLocale)) {
matchingLocales.push(matchingLocale);
}
}
continue;
}
// Soft match (ISO639-1 code)
const softMatches = localesAvailable.filter(
(localeAvailable) =>
convertLocaleToISO639_1(localeAvailable) ===
convertLocaleToISO639_1(localeToSearch),
);
for (const matchingLocale of softMatches) {
if (!matchingLocales.includes(matchingLocale)) {
matchingLocales.push(matchingLocale);
}
}
}
return matchingLocales;
}
@@ -1,5 +1,4 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -10,6 +9,7 @@ import { useLeftPanelStore } from '@/features/left-panel';
export const LeftPanelTargetFilters = () => {
const { t } = useTranslation();
const pathname = usePathname();
const { togglePanel } = useLeftPanelStore();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
@@ -23,25 +23,23 @@ export const LeftPanelTargetFilters = () => {
const router = useRouter();
const defaultQueries = useMemo(() => {
return [
{
icon: 'apps',
label: t('All docs'),
targetQuery: DocDefaultFilter.ALL_DOCS,
},
{
icon: 'lock',
label: t('My docs'),
targetQuery: DocDefaultFilter.MY_DOCS,
},
{
icon: 'group',
label: t('Shared with me'),
targetQuery: DocDefaultFilter.SHARED_WITH_ME,
},
];
}, [t]);
const defaultQueries = [
{
icon: 'apps',
label: t('All docs'),
targetQuery: DocDefaultFilter.ALL_DOCS,
},
{
icon: 'lock',
label: t('My docs'),
targetQuery: DocDefaultFilter.MY_DOCS,
},
{
icon: 'group',
label: t('Shared with me'),
targetQuery: DocDefaultFilter.SHARED_WITH_ME,
},
];
const onSelectQuery = (query: DocDefaultFilter) => {
const params = new URLSearchParams(searchParams);
@@ -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';
@@ -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',
};
};
+15 -8
View File
@@ -1,27 +1,34 @@
import i18n from 'i18next';
import i18next 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
export const availableFrontendLanguages: readonly string[] =
Object.keys(resources);
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: BASE_LANGUAGE,
supportedLngs: Object.keys(LANGUAGES_ALLOWED),
fallbackLng: 'en',
debug: false,
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: {
path: '/',
sameSite: 'lax',
},
},
interpolation: {
escapeValue: false,
},
preload: Object.keys(LANGUAGES_ALLOWED),
preload: availableFrontendLanguages,
lowerCaseLng: true,
nsSeparator: false,
keySeparator: false,
})
@@ -29,4 +36,4 @@ i18n
throw new Error('i18n initialization failed');
});
export default i18n;
export default i18next;
@@ -1,2 +0,0 @@
// See: https://github.com/numerique-gouv/impress/blob/ac58341984c99c10ebfac7f8bbe1e8756c48e4d4/src/backend/impress/settings.py#L156-L161
export type ContentLanguage = 'en-us' | 'fr-fr';
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { AppProvider } from '@/core/';
import { useSWRegister } from '@/features/service-worker/';
import '@/i18n/initI18n';
import { NextPageWithLayout } from '@/types/next';
import './globals.css';
@@ -12,21 +12,21 @@ interface SentryState {
export const useSentryStore = create<SentryState>((set, get) => ({
sentry: undefined,
setSentry: (dsn, environment) => {
const sentry = get().sentry;
if (sentry) {
if (get().sentry) {
return;
}
set({
sentry: Sentry.init({
dsn,
environment,
integrations: [Sentry.replayIntegration()],
release: packageJson.version,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
tracesSampleRate: 0.1,
}),
const sentry = Sentry.init({
dsn,
environment,
integrations: [Sentry.replayIntegration()],
release: packageJson.version,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
tracesSampleRate: 0.1,
});
Sentry.setTag('application', 'frontend');
set({ sentry });
},
}));
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "2.3.0",
"version": "2.4.0",
"private": true,
"workspaces": {
"packages": [
@@ -28,6 +28,11 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@blocknote/core": "0.23.2-hotfix.0",
"@blocknote/mantine": "0.23.2-hotfix.0",
"@blocknote/react": "0.23.2-hotfix.0",
"@blocknote/xl-docx-exporter": "0.23.2-hotfix.0",
"@blocknote/xl-pdf-exporter": "0.23.2-hotfix.0",
"@types/node": "22.13.1",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.23.0",
@@ -1,6 +1,6 @@
{
"name": "eslint-config-impress",
"version": "2.3.0",
"version": "2.4.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "2.3.0",
"version": "2.4.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "2.3.0",
"version": "2.4.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",
@@ -9,3 +9,4 @@ Sentry.init({
tracesSampleRate: 0.1,
profilesSampleRate: 1.0,
});
Sentry.setTag('application', 'y-provider');
+34 -27
View File
@@ -1004,10 +1004,10 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@blocknote/core@0.23.2", "@blocknote/core@^0.23.2":
version "0.23.2"
resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.23.2.tgz#8383ec6c71b054412f968e77d65e0641a944a164"
integrity sha512-rPKiWJq/Tg479PFk5DPY4xMqX5puPqSidD5cYrBHYdt9dXvuqAnMNbdDhYpRM9bA7XmLXkYG46kSzUjI58ZIBA==
"@blocknote/core@*", "@blocknote/core@0.23.2-hotfix.0", "@blocknote/core@^0.23.2", "@blocknote/core@^0.23.2-hotfix.0":
version "0.23.2-hotfix.0"
resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.23.2-hotfix.0.tgz#f95e64dfa6a481baac27414242b08b593bf6383d"
integrity sha512-y+fAV7YBwRQYonITjcSYiydTqZMA2VePBkcurLGDGU/OV3V/ihSsS8TjhABVf3l+w5bX3bp1i1ZuyhiGopyFBg==
dependencies:
"@emoji-mart/data" "^1.2.1"
"@tiptap/core" "^2.7.1"
@@ -1049,28 +1049,28 @@
shiki "^1.22.0"
unified "^10.1.2"
uuid "^8.3.2"
y-prosemirror "1.2.13"
y-prosemirror "1.2.17"
y-protocols "^1.0.6"
yjs "^13.6.15"
"@blocknote/mantine@0.23.2":
version "0.23.2"
resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.23.2.tgz#438be000b74b17dab4621d0acb141f40058fd272"
integrity sha512-rg6nD+XiS9NuMoZP1QMIT//SyDR0tukPkfJZ/P88mL9n1j0Cw3RNzKwfZgQR3WYwMMIPum2Zx++iZtAo8mONPA==
"@blocknote/mantine@*", "@blocknote/mantine@0.23.2-hotfix.0":
version "0.23.2-hotfix.0"
resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.23.2-hotfix.0.tgz#695a6b44b92fab91ee0dcaa93c4224bc573374f4"
integrity sha512-8SNBzgdOiYz+of/BfZY15nG71fmB0jk2eKjVliU4fWbhyKCM5ZJ86I2/5Ap8yBNP6ZtrV7QEEKyUqelg7/U/PA==
dependencies:
"@blocknote/core" "^0.23.2"
"@blocknote/react" "^0.23.2"
"@blocknote/core" "^0.23.2-hotfix.0"
"@blocknote/react" "^0.23.2-hotfix.0"
"@mantine/core" "^7.10.1"
"@mantine/hooks" "^7.10.1"
"@mantine/utils" "^6.0.21"
react-icons "^5.2.1"
"@blocknote/react@0.23.2", "@blocknote/react@^0.23.2":
version "0.23.2"
resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.23.2.tgz#1a6e5463b768c481f5d662d37353c5765454b16b"
integrity sha512-/6eqWmA4gRDskCX/2HUAf/i4OzxeObg/59wpHu3rjQLGHCiGrgQx9pLyO2tf/sy0b+WstXpKlSj2V13YJ45E+Q==
"@blocknote/react@*", "@blocknote/react@0.23.2-hotfix.0", "@blocknote/react@^0.23.2", "@blocknote/react@^0.23.2-hotfix.0":
version "0.23.2-hotfix.0"
resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.23.2-hotfix.0.tgz#4fc52986b6b22598ee89a5ab1cafbe3d2d04ae0b"
integrity sha512-POcDQHc4VsNrGxDH6DPcm2SeAaSnqQgWgGKCGljR1+4HaaqdK56WbHYeFw4W70jJcj5W6zY0keoev+r3lNH86Q==
dependencies:
"@blocknote/core" "^0.23.2"
"@blocknote/core" "^0.23.2-hotfix.0"
"@floating-ui/react" "^0.26.4"
"@tiptap/core" "^2.7.1"
"@tiptap/react" "^2.7.1"
@@ -1091,23 +1091,23 @@
y-protocols "^1.0.6"
yjs "^13.6.15"
"@blocknote/xl-docx-exporter@0.23.2":
version "0.23.2"
resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.23.2.tgz#e55c94aaac3d24cffcd9f918a2c9875875c5c477"
integrity sha512-A8TFP+heo6qt6Afm/vE19PfcJQ9ktnVNWMdodkziYmjy0RSQeqmLGHs8RfKfHMaQMMcXTtdmdMQGyJj2HDfRmQ==
"@blocknote/xl-docx-exporter@*", "@blocknote/xl-docx-exporter@0.23.2-hotfix.0":
version "0.23.2-hotfix.0"
resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.23.2-hotfix.0.tgz#4be683d38da35d7b056f759e033a59335746c88b"
integrity sha512-LXodW2toxtjx2WE7ZOUFdrqnuahjpt5+12EkA1n3BeMIoApv7BKsPKmwkvMLmjg3S+8VX29tiOxFqRUOUL75lQ==
dependencies:
"@blocknote/core" "^0.23.2"
"@blocknote/core" "^0.23.2-hotfix.0"
buffer "^6.0.3"
docx "^9.0.2"
sharp "^0.33.5"
"@blocknote/xl-pdf-exporter@0.23.2":
version "0.23.2"
resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.23.2.tgz#a7782e6af1dd21723df6e02d47f57fd5b0080de0"
integrity sha512-NsRMmDIBjmSXRp4XTbFHrAFUaSRKeIA6GYTqEFk/XyQU78h/uRLzWyzrDnkJSHPA81O3TX1C4ih4lbuYisFpzg==
"@blocknote/xl-pdf-exporter@*", "@blocknote/xl-pdf-exporter@0.23.2-hotfix.0":
version "0.23.2-hotfix.0"
resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.23.2-hotfix.0.tgz#a9a64e66e0d3f87a28efc9de689c3a14ce573025"
integrity sha512-1QPOSv+2OeUAEGY2yDxRsZopv/rdpZZ2U6amvipwGXwH9KmXqenFOuM8ZrrHf/UAens9vXGRx4ezKaAkdm/Nmw==
dependencies:
"@blocknote/core" "^0.23.2"
"@blocknote/react" "^0.23.2"
"@blocknote/core" "^0.23.2-hotfix.0"
"@blocknote/react" "^0.23.2-hotfix.0"
"@react-pdf/renderer" "^4.0.0"
buffer "^6.0.3"
docx "^9.0.2"
@@ -14598,6 +14598,13 @@ y-prosemirror@1.2.13:
dependencies:
lib0 "^0.2.42"
y-prosemirror@1.2.17:
version "1.2.17"
resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.2.17.tgz#6c50cd87fd21f7cdb6617cee281d43a034215a56"
integrity sha512-9bmO5Vl6E3AH5JRQhJPWOmX4x0dwDZvfM86qd+w38G0j23VSV+ZvrKYBbKc1l44Tnyd7j7TWEuqBpzfOfbc4fw==
dependencies:
lib0 "^0.2.42"
y-protocols@1.0.6, y-protocols@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.6.tgz#66dad8a95752623443e8e28c0e923682d2c0d495"
+1 -1
View File
@@ -93,4 +93,4 @@ releases:
environments:
dev:
values:
- version: 2.3.0
- version: 2.4.0
+1 -1
View File
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
version: 2.3.0
version: 2.4.0
appVersion: latest
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "2.3.0",
"version": "2.4.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {