Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bb7ad643a | |||
| 57b8881fc6 | |||
| 89ad610ba6 | |||
| 251787b835 | |||
| f95173e096 | |||
| a7944cce80 | |||
| 7941fc91d5 | |||
| 7fc83a4fcd | |||
| 2bf47b7705 | |||
| 23b0214a2a | |||
| f244509de3 | |||
| fda5f8f008 | |||
| 9a79b09b07 | |||
| b24acd14e2 | |||
| 1531846115 | |||
| ebf6d46e37 | |||
| b9b5f86cf4 | |||
| 56412b0be5 |
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
-3
@@ -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
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
+18
-20
@@ -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',
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"extract-translation": "yarn extract-translation:impress",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -93,4 +93,4 @@ releases:
|
||||
environments:
|
||||
dev:
|
||||
values:
|
||||
- version: 2.3.0
|
||||
- version: 2.4.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 2.3.0
|
||||
version: 2.4.0
|
||||
appVersion: latest
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user