mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
41 Commits
media-secu
...
v2.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bb7ad643a | ||
|
|
57b8881fc6 | ||
|
|
89ad610ba6 | ||
|
|
251787b835 | ||
|
|
f95173e096 | ||
|
|
a7944cce80 | ||
|
|
7941fc91d5 | ||
|
|
7fc83a4fcd | ||
|
|
2bf47b7705 | ||
|
|
23b0214a2a | ||
|
|
f244509de3 | ||
|
|
fda5f8f008 | ||
|
|
9a79b09b07 | ||
|
|
b24acd14e2 | ||
|
|
1531846115 | ||
|
|
ebf6d46e37 | ||
|
|
b9b5f86cf4 | ||
|
|
56412b0be5 | ||
|
|
af052cd06b | ||
|
|
8927635c5f | ||
|
|
76bce4313b | ||
|
|
5ac71bfac1 | ||
|
|
cb4e148afc | ||
|
|
2d24825be0 | ||
|
|
7b1ddc0e05 | ||
|
|
22a665e535 | ||
|
|
a22bf95bce | ||
|
|
3ce1826355 | ||
|
|
d099d58f77 | ||
|
|
ebd49f05a8 | ||
|
|
315c2c2c43 | ||
|
|
e442908c50 | ||
|
|
6672292d93 | ||
|
|
7dda74421f | ||
|
|
9c25b684e3 | ||
|
|
cd5ee3fb7c | ||
|
|
942c0f059c | ||
|
|
3acee1e6fa | ||
|
|
26ea32bd0b | ||
|
|
7f6ffa0123 | ||
|
|
ef2127585c |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -8,9 +8,29 @@ 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
|
||||
|
||||
- 💄(frontend) add error pages #643
|
||||
- 🔒️ Manage unsafe attachments #663
|
||||
- ✨(frontend) Custom block quote with export #646
|
||||
- ✨(frontend) add open source section homepage #666
|
||||
- ✨(frontend) synchronize language-choice #401
|
||||
|
||||
## Changed
|
||||
|
||||
@@ -21,7 +41,10 @@ and this project adheres to
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(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
|
||||
|
||||
@@ -409,7 +432,8 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.3.0...main
|
||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@ ATTACHMENTS_FOLDER = "attachments"
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
|
||||
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
|
||||
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
|
||||
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
|
||||
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
|
||||
)
|
||||
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
|
||||
|
||||
@@ -915,15 +915,18 @@ class DocumentViewSet(
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
extension = serializer.validated_data["expected_extension"]
|
||||
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
|
||||
|
||||
# Prepare metadata for storage
|
||||
extra_args = {
|
||||
"Metadata": {"owner": str(request.user.id)},
|
||||
"ContentType": serializer.validated_data["content_type"],
|
||||
}
|
||||
file_unsafe = ""
|
||||
if serializer.validated_data["is_unsafe"]:
|
||||
extra_args["Metadata"]["is_unsafe"] = "true"
|
||||
file_unsafe = "-unsafe"
|
||||
|
||||
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}{file_unsafe}.{extension:s}"
|
||||
|
||||
file_name = serializer.validated_data["file_name"]
|
||||
if (
|
||||
@@ -1164,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):
|
||||
@@ -1396,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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
@@ -697,6 +699,7 @@ class Document(MP_Node, BaseModel):
|
||||
"document": self,
|
||||
"domain": domain,
|
||||
"link": f"{domain}/docs/{self.id}/",
|
||||
"document_title": self.title or str(_("Untitled Document")),
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
}
|
||||
)
|
||||
@@ -738,8 +741,12 @@ class Document(MP_Node, BaseModel):
|
||||
'{name} invited you with the role "{role}" on the following document:'
|
||||
).format(name=sender_name_email, role=role.lower()),
|
||||
}
|
||||
subject = _("{name} shared a document with you: {title}").format(
|
||||
name=sender_name, title=self.title
|
||||
subject = (
|
||||
context["title"]
|
||||
if not self.title
|
||||
else _("{name} shared a document with you: {title}").format(
|
||||
name=sender_name, title=self.title
|
||||
)
|
||||
)
|
||||
|
||||
self.send_email(subject, [email], context, language)
|
||||
@@ -787,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
|
||||
|
||||
@@ -794,26 +804,17 @@ class Document(MP_Node, BaseModel):
|
||||
ancestors_deleted_at = (
|
||||
self.get_ancestors()
|
||||
.filter(deleted_at__isnull=False)
|
||||
.order_by("deleted_at")
|
||||
.values_list("deleted_at", flat=True)
|
||||
.first()
|
||||
)
|
||||
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
|
||||
self.save()
|
||||
self.ancestors_deleted_at = ancestors_deleted_at
|
||||
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")
|
||||
|
||||
|
||||
@@ -293,7 +293,9 @@ def test_api_documents_attachment_upload_fix_extension(
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
assert "-unsafe" in file_id
|
||||
# Validate that file_id is a valid UUID
|
||||
file_id = file_id.replace("-unsafe", "")
|
||||
uuid.UUID(file_id)
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
@@ -343,7 +345,9 @@ def test_api_documents_attachment_upload_unsafe():
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
assert "-unsafe" in file_id
|
||||
# Validate that file_id is a valid UUID
|
||||
file_id = file_id.replace("-unsafe", "")
|
||||
uuid.UUID(file_id)
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
|
||||
@@ -64,6 +64,30 @@ def test_api_documents_media_auth_anonymous_public():
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_documents_media_auth_extensions():
|
||||
"""Files with extensions of any format should work."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
extensions = [
|
||||
"c",
|
||||
"go",
|
||||
"gif",
|
||||
"mp4",
|
||||
"woff2",
|
||||
"appimage",
|
||||
]
|
||||
for ext in extensions:
|
||||
filename = f"{uuid.uuid4()!s}.{ext:s}"
|
||||
key = f"{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
|
||||
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -636,6 +636,37 @@ def test_models_documents__email_invitation__success():
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_empty_title():
|
||||
"""
|
||||
The email invitation is sent successfully.
|
||||
"""
|
||||
document = factories.DocumentFactory(title=None)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
|
||||
document.send_invitation_email(
|
||||
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert "Test sender shared a document with you!" in email.subject
|
||||
assert (
|
||||
"Test Sender (sender@example.com) invited you with the role "editor" "
|
||||
"on the following document: Untitled Document" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_fr():
|
||||
"""
|
||||
The email invitation is sent successfully in french.
|
||||
@@ -765,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__)))
|
||||
@@ -210,7 +211,6 @@ class Base(Configuration):
|
||||
"application/x-ms-regedit",
|
||||
"application/x-msdownload",
|
||||
"application/xml",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
# Document versions
|
||||
@@ -221,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
|
||||
@@ -233,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"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -647,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
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
|
||||
"PO-Revision-Date: 2025-03-03 12:22\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -54,15 +54,15 @@ msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sie sind Besitzer eines neuen Dokuments:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
|
||||
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -230,8 +230,8 @@ msgstr "Benutzer"
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
|
||||
#: core/models.py:925
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
|
||||
#: core/models.py:942
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
@@ -251,143 +251,143 @@ msgstr "Dokumente"
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:734 core/models.py:734
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
#: build/lib/core/models.py:738 core/models.py:738
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:741 core/models.py:741
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: build/lib/core/models.py:777 core/models.py:777
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
msgid "This document was permanently deleted and cannot be restored."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:820 core/models.py:820
|
||||
#: build/lib/core/models.py:837 core/models.py:837
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:838 core/models.py:838
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:844 core/models.py:844
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
|
||||
|
||||
#: build/lib/core/models.py:879 core/models.py:879
|
||||
#: build/lib/core/models.py:896 core/models.py:896
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:897 core/models.py:897
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:903 core/models.py:903
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:909 core/models.py:909
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
|
||||
#: core/models.py:898 core/models.py:1012
|
||||
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
|
||||
#: core/models.py:915 core/models.py:1029
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: build/lib/core/models.py:926 core/models.py:926
|
||||
#: build/lib/core/models.py:943 core/models.py:943
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:944 core/models.py:944
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:945 core/models.py:945
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:949 core/models.py:949
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: build/lib/core/models.py:938 core/models.py:938
|
||||
#: build/lib/core/models.py:955 core/models.py:955
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1011 core/models.py:1011
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1017 core/models.py:1017
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1023 core/models.py:1023
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1065 core/models.py:1065
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1066 core/models.py:1066
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1086 core/models.py:1086
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:235 impress/settings.py:235
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
|
||||
"PO-Revision-Date: 2025-03-03 12:22\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -54,15 +54,15 @@ msgstr ""
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
|
||||
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -230,8 +230,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
|
||||
#: core/models.py:925
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
|
||||
#: core/models.py:942
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -251,143 +251,143 @@ msgstr ""
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:734 core/models.py:734
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
#: build/lib/core/models.py:738 core/models.py:738
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:741 core/models.py:741
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: build/lib/core/models.py:777 core/models.py:777
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
msgid "This document was permanently deleted and cannot be restored."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:820 core/models.py:820
|
||||
#: build/lib/core/models.py:837 core/models.py:837
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:838 core/models.py:838
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:844 core/models.py:844
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:879 core/models.py:879
|
||||
#: build/lib/core/models.py:896 core/models.py:896
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:897 core/models.py:897
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:903 core/models.py:903
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:909 core/models.py:909
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
|
||||
#: core/models.py:898 core/models.py:1012
|
||||
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
|
||||
#: core/models.py:915 core/models.py:1029
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:926 core/models.py:926
|
||||
#: build/lib/core/models.py:943 core/models.py:943
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:944 core/models.py:944
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:945 core/models.py:945
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:949 core/models.py:949
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:938 core/models.py:938
|
||||
#: build/lib/core/models.py:955 core/models.py:955
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1011 core/models.py:1011
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1017 core/models.py:1017
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1023 core/models.py:1023
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1065 core/models.py:1065
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1066 core/models.py:1066
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1086 core/models.py:1086
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:235 impress/settings.py:235
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
|
||||
"PO-Revision-Date: 2025-03-04 08:20\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -54,15 +54,15 @@ msgstr "Un nouveau document a été créé pour vous !"
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
|
||||
|
||||
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
|
||||
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -230,8 +230,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
|
||||
#: core/models.py:925
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
|
||||
#: core/models.py:942
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -249,145 +249,145 @@ msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:418 core/models.py:418
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:734 core/models.py:734
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
#: build/lib/core/models.py:738 core/models.py:738
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:741 core/models.py:741
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous: {title}"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: build/lib/core/models.py:777 core/models.py:777
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
msgid "This document was permanently deleted and cannot be restored."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:820 core/models.py:820
|
||||
#: build/lib/core/models.py:837 core/models.py:837
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:838 core/models.py:838
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:844 core/models.py:844
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:879 core/models.py:879
|
||||
#: build/lib/core/models.py:896 core/models.py:896
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:897 core/models.py:897
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:903 core/models.py:903
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:909 core/models.py:909
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
|
||||
#: core/models.py:898 core/models.py:1012
|
||||
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
|
||||
#: core/models.py:915 core/models.py:1029
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:926 core/models.py:926
|
||||
#: build/lib/core/models.py:943 core/models.py:943
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:944 core/models.py:944
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:945 core/models.py:945
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:949 core/models.py:949
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:938 core/models.py:938
|
||||
#: build/lib/core/models.py:955 core/models.py:955
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1011 core/models.py:1011
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1017 core/models.py:1017
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1023 core/models.py:1023
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1065 core/models.py:1065
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1066 core/models.py:1066
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1086 core/models.py:1086
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:235 impress/settings.py:235
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
|
||||
"PO-Revision-Date: 2025-03-03 12:22\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -54,15 +54,15 @@ msgstr ""
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
|
||||
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -230,8 +230,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
|
||||
#: core/models.py:925
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
|
||||
#: core/models.py:942
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -251,143 +251,143 @@ msgstr ""
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:734 core/models.py:734
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
#: build/lib/core/models.py:738 core/models.py:738
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:741 core/models.py:741
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: build/lib/core/models.py:777 core/models.py:777
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
msgid "This document was permanently deleted and cannot be restored."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:820 core/models.py:820
|
||||
#: build/lib/core/models.py:837 core/models.py:837
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:838 core/models.py:838
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:844 core/models.py:844
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:879 core/models.py:879
|
||||
#: build/lib/core/models.py:896 core/models.py:896
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:897 core/models.py:897
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:903 core/models.py:903
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:909 core/models.py:909
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
|
||||
#: core/models.py:898 core/models.py:1012
|
||||
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
|
||||
#: core/models.py:915 core/models.py:1029
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:926 core/models.py:926
|
||||
#: build/lib/core/models.py:943 core/models.py:943
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:944 core/models.py:944
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:945 core/models.py:945
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:949 core/models.py:949
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:938 core/models.py:938
|
||||
#: build/lib/core/models.py:955 core/models.py:955
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1011 core/models.py:1011
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1017 core/models.py:1017
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1023 core/models.py:1023
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1065 core/models.py:1065
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1066 core/models.py:1066
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1086 core/models.py:1086
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:235 impress/settings.py:235
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "2.2.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]
|
||||
|
||||
22
src/frontend/apps/e2e/__tests__/app-impress/assets/test.html
Normal file
22
src/frontend/apps/e2e/__tests__/app-impress/assets/test.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Test unsafe file</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello svg</h1>
|
||||
<img src="test.jpg" alt="test" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle cx="50" cy="30" r="20" fill="#3498db" />
|
||||
<polygon
|
||||
points="50,10 55,20 65,20 58,30 60,40 50,35 40,40 42,30 35,20 45,20"
|
||||
fill="#f1c40f"
|
||||
/>
|
||||
<text x="50" y="70" text-anchor="middle" fill="white">Hello svg</text>
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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: {},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
|
||||
import {
|
||||
createDoc,
|
||||
@@ -16,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,
|
||||
@@ -129,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;
|
||||
@@ -415,6 +375,8 @@ test.describe('Doc Editor', () => {
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
if (!ai_transform && !ai_translate) {
|
||||
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
|
||||
return;
|
||||
@@ -441,6 +403,45 @@ test.describe('Doc Editor', () => {
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeHidden();
|
||||
}
|
||||
/* eslint-enable playwright/no-conditional-expect */
|
||||
/* eslint-enable playwright/no-conditional-in-test */
|
||||
});
|
||||
});
|
||||
|
||||
test('it downloads unsafe files', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`html`);
|
||||
});
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Embedded file').click();
|
||||
await page.getByText('Upload file').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.html'));
|
||||
|
||||
await page.locator('.bn-block-content[data-name="test.html"]').click();
|
||||
await page.getByRole('button', { name: 'Download file' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('This file is flagged as unsafe.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toContain(`-unsafe.html`);
|
||||
|
||||
const svgBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
expect(svgBuffer.toString()).toContain('Hello svg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,4 +197,49 @@ test.describe('Doc Export', () => {
|
||||
|
||||
expect(pdfText).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with quotes', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
// Trigger slash menu to show menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await page.getByText('Add a quote block').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="quote"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await editor.fill('Hello World');
|
||||
|
||||
await expect(editor.getByText('Hello World')).toHaveCSS(
|
||||
'font-style',
|
||||
'italic',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Download',
|
||||
})
|
||||
.click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,20 +88,14 @@ 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' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete the document "${randomDoc}"?`,
|
||||
),
|
||||
page.getByText(`Are you sure you want to delete this document ?`),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -152,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 } });
|
||||
@@ -176,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');
|
||||
@@ -194,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 }) => {
|
||||
@@ -232,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 } });
|
||||
@@ -294,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 } });
|
||||
@@ -352,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
|
||||
@@ -387,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
|
||||
@@ -460,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
|
||||
@@ -494,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();
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ test.describe('Home page', () => {
|
||||
|
||||
// Check the titles
|
||||
const h2 = page.locator('h2');
|
||||
await expect(h2.getByText('Govs ❤️ Open Source.')).toBeVisible();
|
||||
await expect(
|
||||
h2.getByText('Collaborative writing, Simplified.'),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -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.2.0",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -357,6 +357,15 @@ const config = {
|
||||
components: {
|
||||
alert: {
|
||||
'border-radius': '0',
|
||||
error: {
|
||||
'background-color': 'var(--c--theme--colors--danger-100)',
|
||||
'border-left-color': 'var(--c--theme--colors--danger-400)',
|
||||
close: {
|
||||
color: 'white',
|
||||
'background-color': 'var(--c--theme--colors--danger-400)',
|
||||
'background-color-hover': 'var(--c--theme--colors--danger-600)',
|
||||
},
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
'width-small': '342px',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "2.2.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",
|
||||
|
||||
@@ -35,6 +35,7 @@ export const TextErrors = ({
|
||||
<Text
|
||||
key={`causes-${i}`}
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
@@ -43,7 +44,12 @@ export const TextErrors = ({
|
||||
))}
|
||||
|
||||
{!causes && (
|
||||
<Text $theme="danger" $textAlign="center" {...textProps}>
|
||||
<Text
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{defaultMessage || t('Something bad happens, please retry.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -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,7 +3,8 @@ import { PropsWithChildren, useEffect } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { PostHogProvider, configureCrispSession } from '@/services';
|
||||
import { useLanguageSynchronizer } from '@/features/language/hooks/useLanguageSynchronizer';
|
||||
import { CrispProvider, PostHogProvider } from '@/services';
|
||||
import { useSentryStore } from '@/stores/useSentryStore';
|
||||
|
||||
import { useConfig } from './api/useConfig';
|
||||
@@ -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) {
|
||||
@@ -30,12 +32,8 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [conf?.FRONTEND_THEME, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.CRISP_WEBSITE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
configureCrispSession(conf.CRISP_WEBSITE_ID);
|
||||
}, [conf?.CRISP_WEBSITE_ID]);
|
||||
void synchronizeLanguage();
|
||||
}, [synchronizeLanguage]);
|
||||
|
||||
if (!conf) {
|
||||
return (
|
||||
@@ -45,5 +43,11 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <PostHogProvider conf={conf.POSTHOG_KEY}>{children}</PostHogProvider>;
|
||||
return (
|
||||
<PostHogProvider conf={conf.POSTHOG_KEY}>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
</CrispProvider>
|
||||
</PostHogProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -310,7 +310,7 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
/**
|
||||
* Others
|
||||
* Checkbox
|
||||
*/
|
||||
.c__checkbox:focus-within {
|
||||
border-color: transparent;
|
||||
@@ -365,7 +365,8 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.c__button--medium {
|
||||
padding: 0.9rem var(--c--theme--spacings--s);
|
||||
height: auto;
|
||||
min-height: var(--c--components--button--medium-height);
|
||||
}
|
||||
|
||||
.c__button--small {
|
||||
@@ -551,6 +552,8 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.c__modal__close .c__button {
|
||||
padding: 0 !important;
|
||||
top: -0.65rem;
|
||||
right: -0.65rem;
|
||||
}
|
||||
|
||||
.c__modal--full .c__modal__content {
|
||||
@@ -609,3 +612,22 @@ input:-webkit-autofill:focus {
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert
|
||||
*/
|
||||
.c__alert--error {
|
||||
background-color: var(--c--components--alert--error--background-color);
|
||||
border-left-color: var(--c--components--alert--error--border-left-color);
|
||||
}
|
||||
|
||||
.c__alert--error .c__button--tertiary {
|
||||
background-color: var(--c--components--alert--error--close--background-color);
|
||||
color: var(--c--components--alert--error--close--color);
|
||||
}
|
||||
|
||||
.c__alert.c__alert--error .c__button--tertiary:hover {
|
||||
background-color: var(
|
||||
--c--components--alert--error--close--background-color-hover
|
||||
);
|
||||
}
|
||||
|
||||
@@ -484,6 +484,19 @@
|
||||
--c--theme--logo--widthFooter: 220px;
|
||||
--c--theme--logo--alt: gouvernement logo;
|
||||
--c--components--alert--border-radius: 0;
|
||||
--c--components--alert--error--background-color: var(
|
||||
--c--theme--colors--danger-100
|
||||
);
|
||||
--c--components--alert--error--border-left-color: var(
|
||||
--c--theme--colors--danger-400
|
||||
);
|
||||
--c--components--alert--error--close--color: white;
|
||||
--c--components--alert--error--close--background-color: var(
|
||||
--c--theme--colors--danger-400
|
||||
);
|
||||
--c--components--alert--error--close--background-color-hover: var(
|
||||
--c--theme--colors--danger-600
|
||||
);
|
||||
--c--components--modal--width-small: 342px;
|
||||
--c--components--button--medium-height: 40px;
|
||||
--c--components--button--medium-text-height: 40px;
|
||||
|
||||
@@ -483,7 +483,18 @@ export const tokens = {
|
||||
},
|
||||
},
|
||||
components: {
|
||||
alert: { 'border-radius': '0' },
|
||||
alert: {
|
||||
'border-radius': '0',
|
||||
error: {
|
||||
'background-color': 'var(--c--theme--colors--danger-100)',
|
||||
'border-left-color': 'var(--c--theme--colors--danger-400)',
|
||||
close: {
|
||||
color: 'white',
|
||||
'background-color': 'var(--c--theme--colors--danger-400)',
|
||||
'background-color-hover': 'var(--c--theme--colors--danger-600)',
|
||||
},
|
||||
},
|
||||
},
|
||||
modal: { 'width-small': '342px' },
|
||||
button: {
|
||||
'medium-height': '40px',
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, fetchAPI } from '@/api';
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { User } from './types';
|
||||
|
||||
@@ -17,7 +17,10 @@ import { User } from './types';
|
||||
export const getMe = async (): Promise<User> => {
|
||||
const response = await fetchAPI(`users/me/`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
|
||||
throw new APIError(
|
||||
`Couldn't fetch user data: ${response.statusText}`,
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<User>;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PropsWithChildren } from 'react';
|
||||
import { Box } from '@/components';
|
||||
|
||||
import { useAuth } from '../hooks';
|
||||
import { getAuthUrl } from '../utils';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
|
||||
@@ -19,6 +20,22 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is authenticated and wanted initially to access a document,
|
||||
* we redirect to the document page.
|
||||
*/
|
||||
if (authenticated) {
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
void replace(authUrl);
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
|
||||
*/
|
||||
|
||||
@@ -2,13 +2,12 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useAuthQuery } from '../api';
|
||||
import { getAuthUrl } from '../utils';
|
||||
|
||||
const regexpUrlsAuth = [/\/docs\/$/g, /\/docs$/g, /^\/$/g];
|
||||
|
||||
export const useAuth = () => {
|
||||
const { data: user, ...authStates } = useAuthQuery();
|
||||
const { pathname, replace } = useRouter();
|
||||
const { pathname } = useRouter();
|
||||
|
||||
const [pathAllowed, setPathAllowed] = useState<boolean>(
|
||||
!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)),
|
||||
@@ -18,17 +17,10 @@ export const useAuth = () => {
|
||||
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)));
|
||||
}, [pathname]);
|
||||
|
||||
// Redirect to the path before login
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
void replace(authUrl);
|
||||
}
|
||||
}, [user, replace]);
|
||||
|
||||
return { user, authenticated: !!user, pathAllowed, ...authStates };
|
||||
return {
|
||||
user,
|
||||
authenticated: !!user && authStates.isSuccess,
|
||||
pathAllowed,
|
||||
...authStates,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './api/types';
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './hooks';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
Dictionary,
|
||||
defaultBlockSpecs,
|
||||
locales,
|
||||
withPageBreak,
|
||||
} from '@blocknote/core';
|
||||
@@ -25,9 +26,17 @@ import { cssEditor } from '../styles';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolbar';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { QuoteBlock } from './custom-blocks';
|
||||
|
||||
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
|
||||
export const blockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
quote: QuoteBlock,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
@@ -42,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);
|
||||
|
||||
@@ -125,7 +134,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
$css={cssEditor(readOnly)}
|
||||
>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big' }}>
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
causes={errorAttachment.cause}
|
||||
canClose
|
||||
@@ -141,8 +150,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
editable={!readOnly}
|
||||
theme="light"
|
||||
>
|
||||
<BlockNoteToolbar />
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -5,13 +5,19 @@ import {
|
||||
getDefaultReactSlashMenuItems,
|
||||
getPageBreakReactSlashMenuItems,
|
||||
useBlockNoteEditor,
|
||||
useDictionary,
|
||||
} from '@blocknote/react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
import { DocsBlockSchema } from '../types';
|
||||
|
||||
import { getQuoteReactSlashMenuItems } from './custom-blocks';
|
||||
|
||||
export const BlockNoteSuggestionMenu = () => {
|
||||
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
|
||||
const editor = useBlockNoteEditor<DocsBlockSchema>();
|
||||
const { t } = useTranslation();
|
||||
const basicBlocksName = useDictionary().slash_menu.page_break.group;
|
||||
|
||||
const getSlashMenuItems = useMemo(() => {
|
||||
return async (query: string) =>
|
||||
@@ -20,11 +26,12 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
combineByGroup(
|
||||
getDefaultReactSlashMenuItems(editor),
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getQuoteReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
),
|
||||
query,
|
||||
),
|
||||
);
|
||||
}, [editor]);
|
||||
}, [basicBlocksName, editor, t]);
|
||||
|
||||
return (
|
||||
<SuggestionMenuController
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
AITransformActions,
|
||||
useDocAITransform,
|
||||
useDocAITranslate,
|
||||
} from '../api/';
|
||||
} from '../../api';
|
||||
|
||||
type LanguageTranslate = {
|
||||
value: string;
|
||||
@@ -0,0 +1,75 @@
|
||||
import '@blocknote/mantine/style.css';
|
||||
import {
|
||||
FormattingToolbar,
|
||||
FormattingToolbarController,
|
||||
blockTypeSelectItems,
|
||||
getFormattingToolbarItems,
|
||||
useDictionary,
|
||||
} from '@blocknote/react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getQuoteFormattingToolbarItems } from '../custom-blocks';
|
||||
|
||||
import { AIGroupButton } from './AIButton';
|
||||
import { FileDownloadButton } from './FileDownloadButton';
|
||||
import { MarkdownButton } from './MarkdownButton';
|
||||
import { ModalConfirmDownloadUnsafe } from './ModalConfirmDownloadUnsafe';
|
||||
|
||||
export const BlockNoteToolbar = () => {
|
||||
const dict = useDictionary();
|
||||
const [confirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toolbarItems = useMemo(() => {
|
||||
const toolbarItems = getFormattingToolbarItems([
|
||||
...blockTypeSelectItems(dict),
|
||||
getQuoteFormattingToolbarItems(t),
|
||||
]);
|
||||
const fileDownloadButtonIndex = toolbarItems.findIndex(
|
||||
(item) => item.key === 'fileDownloadButton',
|
||||
);
|
||||
if (fileDownloadButtonIndex !== -1) {
|
||||
toolbarItems.splice(
|
||||
fileDownloadButtonIndex,
|
||||
1,
|
||||
<FileDownloadButton
|
||||
key="fileDownloadButton"
|
||||
open={(onConfirm) => {
|
||||
setIsConfirmOpen(true);
|
||||
setOnConfirm(() => onConfirm);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return toolbarItems;
|
||||
}, [dict, t]);
|
||||
|
||||
const formattingToolbar = useCallback(() => {
|
||||
return (
|
||||
<FormattingToolbar>
|
||||
{toolbarItems}
|
||||
|
||||
{/* Extra button to do some AI powered actions */}
|
||||
<AIGroupButton key="AIButton" />
|
||||
|
||||
{/* Extra button to convert from markdown to json */}
|
||||
<MarkdownButton key="customButton" />
|
||||
</FormattingToolbar>
|
||||
);
|
||||
}, [toolbarItems]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormattingToolbarController formattingToolbar={formattingToolbar} />
|
||||
{confirmOpen && (
|
||||
<ModalConfirmDownloadUnsafe
|
||||
onClose={() => setIsConfirmOpen(false)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
checkBlockIsFileBlock,
|
||||
checkBlockIsFileBlockWithPlaceholder,
|
||||
} from '@blocknote/core';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useDictionary,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { RiDownload2Fill } from 'react-icons/ri';
|
||||
|
||||
import { downloadFile, exportResolveFileUrl } from '@/features/docs/doc-export';
|
||||
|
||||
export const FileDownloadButton = ({
|
||||
open,
|
||||
}: {
|
||||
open: (onConfirm: () => Promise<void> | void) => void;
|
||||
}) => {
|
||||
const dict = useDictionary();
|
||||
const Components = useComponentsContext();
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>();
|
||||
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
|
||||
const fileBlock = useMemo(() => {
|
||||
// Checks if only one block is selected.
|
||||
if (selectedBlocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const block = selectedBlocks[0];
|
||||
|
||||
if (checkBlockIsFileBlock(block, editor)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [editor, selectedBlocks]);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
if (fileBlock && fileBlock.props.url) {
|
||||
editor.focus();
|
||||
|
||||
const url = fileBlock.props.url as string;
|
||||
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
* we do what Blocknote was doing initially.
|
||||
*/
|
||||
if (!url.includes(window.location.hostname)) {
|
||||
if (!editor.resolveFileUrl) {
|
||||
window.open(url);
|
||||
} else {
|
||||
void editor
|
||||
.resolveFileUrl(url)
|
||||
.then((downloadUrl) => window.open(downloadUrl));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url, undefined)) as Blob;
|
||||
downloadFile(blob, url.split('/').pop() || 'file');
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url, undefined)) as Blob;
|
||||
downloadFile(blob, url.split('/').pop() || 'file (unsafe)');
|
||||
};
|
||||
|
||||
open(onConfirm);
|
||||
}
|
||||
}
|
||||
}, [editor, fileBlock, open]);
|
||||
|
||||
if (
|
||||
!fileBlock ||
|
||||
checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
|
||||
!Components
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button"
|
||||
label={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
}
|
||||
mainTooltip={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
}
|
||||
icon={<RiDownload2Fill />}
|
||||
onClick={() => void onClick()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
|
||||
interface ModalConfirmDownloadUnsafeProps {
|
||||
onClose: () => void;
|
||||
onConfirm?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const ModalConfirmDownloadUnsafe = ({
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ModalConfirmDownloadUnsafeProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
console.log('onClick');
|
||||
if (onConfirm) {
|
||||
void onConfirm();
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('Download anyway')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.SMALL}
|
||||
title={
|
||||
<Text
|
||||
$gap="0.7rem"
|
||||
$size="h6"
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
$direction="row"
|
||||
>
|
||||
<Text $isMaterialIcon $theme="warning">
|
||||
warning
|
||||
</Text>
|
||||
{t('Warning')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box aria-label={t('Modal confirmation to download the attachment')}>
|
||||
<Box>
|
||||
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
|
||||
<Text $variation="700">{t('This file is flagged as unsafe.')}</Text>
|
||||
<Text $variation="600">
|
||||
{t('Please download it only if it comes from a trusted source.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import '@blocknote/mantine/style.css';
|
||||
import {
|
||||
FormattingToolbar,
|
||||
FormattingToolbarController,
|
||||
FormattingToolbarProps,
|
||||
getFormattingToolbarItems,
|
||||
} from '@blocknote/react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { AIGroupButton } from './AIButton';
|
||||
import { MarkdownButton } from './MarkdownButton';
|
||||
|
||||
export const BlockNoteToolbar = () => {
|
||||
const formattingToolbar = useCallback(
|
||||
({ blockTypeSelectItems }: FormattingToolbarProps) => (
|
||||
<FormattingToolbar>
|
||||
{getFormattingToolbarItems(blockTypeSelectItems)}
|
||||
|
||||
{/* Extra button to do some AI powered actions */}
|
||||
<AIGroupButton key="AIButton" />
|
||||
|
||||
{/* Extra button to convert from markdown to json */}
|
||||
<MarkdownButton key="customButton" />
|
||||
</FormattingToolbar>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return <FormattingToolbarController formattingToolbar={formattingToolbar} />;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
|
||||
import { Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
export const QuoteBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'quote',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
textColor: defaultProps.textColor,
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Text
|
||||
className="inline-content"
|
||||
$margin="0 0 1rem 0"
|
||||
$padding="0.5rem 1rem"
|
||||
$variation="600"
|
||||
style={{
|
||||
borderLeft: `4px solid ${colorsTokens()['greyscale-300']}`,
|
||||
fontStyle: 'italic',
|
||||
flexGrow: 1,
|
||||
}}
|
||||
ref={props.contentRef}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getQuoteReactSlashMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
title: t('Quote'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'quote',
|
||||
});
|
||||
},
|
||||
aliases: ['quote', 'blockquote', 'citation'],
|
||||
group,
|
||||
icon: (
|
||||
<Text $isMaterialIcon $size="18px">
|
||||
format_quote
|
||||
</Text>
|
||||
),
|
||||
subtext: t('Add a quote block'),
|
||||
},
|
||||
];
|
||||
|
||||
export const getQuoteFormattingToolbarItems = (
|
||||
t: TFunction<'translation', undefined>,
|
||||
): BlockTypeSelectItem => ({
|
||||
name: t('Quote'),
|
||||
type: 'quote',
|
||||
icon: () => (
|
||||
<Text $isMaterialIcon $size="16px">
|
||||
format_quote
|
||||
</Text>
|
||||
),
|
||||
isSelected: (block) => block.type === 'quote',
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './QuoteBlock';
|
||||
@@ -1 +1,2 @@
|
||||
export * from './DocEditor';
|
||||
export * from './custom-blocks/';
|
||||
|
||||
@@ -6,6 +6,14 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
& .ProseMirror {
|
||||
height: 100%;
|
||||
|
||||
img.bn-visual-media[src*='-unsafe'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bn-side-menu[data-block-type='quote'] {
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.collaboration-cursor-custom__base {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,12 @@ export type HeadingBlock = {
|
||||
};
|
||||
};
|
||||
|
||||
export type DocsBlockSchema = typeof blockNoteSchema.blockSchema;
|
||||
export type DocsInlineContentSchema =
|
||||
typeof blockNoteSchema.inlineContentSchema;
|
||||
export type DocsStyleSchema = typeof blockNoteSchema.styleSchema;
|
||||
export type DocsBlockNoteEditor = BlockNoteEditor<
|
||||
typeof blockNoteSchema.blockSchema,
|
||||
typeof blockNoteSchema.inlineContentSchema,
|
||||
typeof blockNoteSchema.styleSchema
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Text } from '@react-pdf/renderer';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']['heading'] =
|
||||
(block, exporter) => {
|
||||
const PIXELS_PER_POINT = 0.75;
|
||||
const MERGE_RATIO = 7.5;
|
||||
const FONT_SIZE = 16;
|
||||
const fontSizeEM =
|
||||
block.props.level === 1 ? 2 : block.props.level === 2 ? 1.5 : 1.17;
|
||||
return (
|
||||
<Text
|
||||
key={block.id}
|
||||
style={{
|
||||
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
|
||||
fontWeight: 700,
|
||||
marginTop: `${fontSizeEM * MERGE_RATIO}px`,
|
||||
marginBottom: `${fontSizeEM * MERGE_RATIO}px`,
|
||||
}}
|
||||
>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './headingPDF';
|
||||
export * from './paragraphPDF';
|
||||
export * from './quoteDocx';
|
||||
export * from './quotePDF';
|
||||
export * from './tablePDF';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Text } from '@react-pdf/renderer';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping']['paragraph'] =
|
||||
(block, exporter) => {
|
||||
/**
|
||||
* Breakline in the editor are not rendered in the PDF
|
||||
* By adding a space if the block is empty we ensure that the block is rendered
|
||||
*/
|
||||
if (Array.isArray(block.content)) {
|
||||
block.content.forEach((content) => {
|
||||
if (content.type === 'text' && !content.text) {
|
||||
content.text = ' ';
|
||||
}
|
||||
});
|
||||
|
||||
if (!block.content.length) {
|
||||
block.content.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Text key={block.id}>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Paragraph } from 'docx';
|
||||
|
||||
import { DocsExporterDocx } from '../types';
|
||||
import { docxBlockPropsToStyles } from '../utils';
|
||||
|
||||
export const blockMappingQuoteDocx: DocsExporterDocx['mappings']['blockMapping']['quote'] =
|
||||
(block, exporter) => {
|
||||
if (Array.isArray(block.content)) {
|
||||
block.content.forEach((content) => {
|
||||
if (content.type === 'text') {
|
||||
content.styles = {
|
||||
...content.styles,
|
||||
italic: true,
|
||||
textColor: 'gray',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Paragraph({
|
||||
...docxBlockPropsToStyles(block.props, exporter.options.colors),
|
||||
spacing: { before: 10, after: 10 },
|
||||
border: {
|
||||
left: {
|
||||
color: '#cecece',
|
||||
space: 4,
|
||||
style: 'thick',
|
||||
},
|
||||
},
|
||||
style: 'Normal',
|
||||
children: exporter.transformInlineContent(block.content),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Text } from '@react-pdf/renderer';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
export const blockMappingQuotePDF: DocsExporterPDF['mappings']['blockMapping']['quote'] =
|
||||
(block, exporter) => {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
fontStyle: 'italic',
|
||||
marginVertical: 10,
|
||||
paddingVertical: 5,
|
||||
paddingLeft: 10,
|
||||
borderLeft: '4px solid #cecece',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TD, TH, TR, Table } from '@ag-media/react-pdf-table';
|
||||
import { View } from '@react-pdf/renderer';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['table'] =
|
||||
(block, exporter) => {
|
||||
return (
|
||||
<Table>
|
||||
{block.content.rows.map((row, index) => {
|
||||
if (index === 0) {
|
||||
return (
|
||||
<TH key={index}>
|
||||
{row.cells.map((cell, index) => {
|
||||
// Make empty cells are rendered.
|
||||
if (cell.length === 0) {
|
||||
cell.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<TD key={index}>{exporter.transformInlineContent(cell)}</TD>
|
||||
);
|
||||
})}
|
||||
</TH>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TR key={index}>
|
||||
{row.cells.map((cell, index) => {
|
||||
// Make empty cells are rendered.
|
||||
if (cell.length === 0) {
|
||||
cell.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<TD key={index}>
|
||||
<View>{exporter.transformInlineContent(cell)}</View>
|
||||
</TD>
|
||||
);
|
||||
})}
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
DOCXExporter,
|
||||
docxDefaultSchemaMappings,
|
||||
} from '@blocknote/xl-docx-exporter';
|
||||
import {
|
||||
PDFExporter,
|
||||
pdfDefaultSchemaMappings,
|
||||
} from '@blocknote/xl-pdf-exporter';
|
||||
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
|
||||
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
|
||||
import {
|
||||
Button,
|
||||
Loader,
|
||||
@@ -15,20 +9,20 @@ import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { Text as PDFText, pdf } from '@react-pdf/renderer';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useEditorStore } from '@/features/docs/doc-editor';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { Doc, useTrans } from '@/features/docs/doc-management';
|
||||
|
||||
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||
import { docxDocsSchemaMappings } from '../mappingDocx';
|
||||
import { pdfDocsSchemaMappings } from '../mappingPDF';
|
||||
import { downloadFile, exportResolveFileUrl } from '../utils';
|
||||
|
||||
import { Table } from './blocks/Table';
|
||||
|
||||
enum DocDownloadFormat {
|
||||
PDF = 'pdf',
|
||||
DOCX = 'docx',
|
||||
@@ -51,6 +45,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
const [format, setFormat] = useState<DocDownloadFormat>(
|
||||
DocDownloadFormat.PDF,
|
||||
);
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
const templateOptions = useMemo(() => {
|
||||
const templateOptions = (templates?.pages || [])
|
||||
@@ -78,7 +73,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
const title = doc.title
|
||||
const title = (doc.title || untitledDocument)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
@@ -95,91 +90,25 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
if (format === DocDownloadFormat.PDF) {
|
||||
const defaultExporter = new PDFExporter(
|
||||
editor.schema,
|
||||
pdfDefaultSchemaMappings,
|
||||
pdfDocsSchemaMappings,
|
||||
);
|
||||
|
||||
const exporter = new PDFExporter(
|
||||
editor.schema,
|
||||
{
|
||||
...pdfDefaultSchemaMappings,
|
||||
blockMapping: {
|
||||
...pdfDefaultSchemaMappings.blockMapping,
|
||||
heading: (block, exporter) => {
|
||||
const PIXELS_PER_POINT = 0.75;
|
||||
const MERGE_RATIO = 7.5;
|
||||
const FONT_SIZE = 16;
|
||||
const fontSizeEM =
|
||||
block.props.level === 1
|
||||
? 2
|
||||
: block.props.level === 2
|
||||
? 1.5
|
||||
: 1.17;
|
||||
return (
|
||||
<PDFText
|
||||
key={block.id}
|
||||
style={{
|
||||
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
|
||||
fontWeight: 700,
|
||||
marginTop: `${fontSizeEM * MERGE_RATIO}px`,
|
||||
marginBottom: `${fontSizeEM * MERGE_RATIO}px`,
|
||||
}}
|
||||
>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</PDFText>
|
||||
);
|
||||
},
|
||||
paragraph: (block, exporter) => {
|
||||
/**
|
||||
* Breakline in the editor are not rendered in the PDF
|
||||
* By adding a space if the block is empty we ensure that the block is rendered
|
||||
*/
|
||||
if (Array.isArray(block.content)) {
|
||||
block.content.forEach((content) => {
|
||||
if (content.type === 'text' && !content.text) {
|
||||
content.text = ' ';
|
||||
}
|
||||
});
|
||||
|
||||
if (!block.content.length) {
|
||||
block.content.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<PDFText key={block.id}>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</PDFText>
|
||||
);
|
||||
},
|
||||
table: (block, transformer) => {
|
||||
return <Table data={block.content} transformer={transformer} />;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
resolveFileUrl: async (url) =>
|
||||
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
|
||||
},
|
||||
);
|
||||
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
|
||||
resolveFileUrl: async (url) =>
|
||||
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
|
||||
});
|
||||
const pdfDocument = await exporter.toReactPDFDocument(exportDocument);
|
||||
blobExport = await pdf(pdfDocument).toBlob();
|
||||
} else {
|
||||
const defaultExporter = new DOCXExporter(
|
||||
editor.schema,
|
||||
docxDefaultSchemaMappings,
|
||||
docxDocsSchemaMappings,
|
||||
);
|
||||
|
||||
const exporter = new DOCXExporter(
|
||||
editor.schema,
|
||||
docxDefaultSchemaMappings,
|
||||
{
|
||||
resolveFileUrl: async (url) =>
|
||||
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
|
||||
},
|
||||
);
|
||||
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
|
||||
resolveFileUrl: async (url) =>
|
||||
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
|
||||
});
|
||||
|
||||
blobExport = await exporter.toBlob(exportDocument);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ModalExport';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
|
||||
|
||||
import { blockMappingQuoteDocx } from './blocks-mapping/';
|
||||
import { DocsExporterDocx } from './types';
|
||||
|
||||
export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
|
||||
...docxDefaultSchemaMappings,
|
||||
blockMapping: {
|
||||
...docxDefaultSchemaMappings.blockMapping,
|
||||
quote: blockMappingQuoteDocx,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
|
||||
|
||||
import {
|
||||
blockMappingHeadingPDF,
|
||||
blockMappingParagraphPDF,
|
||||
blockMappingQuotePDF,
|
||||
blockMappingTablePDF,
|
||||
} from './blocks-mapping';
|
||||
import { DocsExporterPDF } from './types';
|
||||
|
||||
export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
|
||||
...pdfDefaultSchemaMappings,
|
||||
blockMapping: {
|
||||
...pdfDefaultSchemaMappings.blockMapping,
|
||||
heading: blockMappingHeadingPDF,
|
||||
paragraph: blockMappingParagraphPDF,
|
||||
quote: blockMappingQuotePDF,
|
||||
table: blockMappingTablePDF,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Exporter } from '@blocknote/core';
|
||||
import { Link, Text, TextProps } from '@react-pdf/renderer';
|
||||
import {
|
||||
IRunPropertiesOptions,
|
||||
Paragraph,
|
||||
ParagraphChild,
|
||||
Table,
|
||||
TextRun,
|
||||
} from 'docx';
|
||||
|
||||
import {
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema,
|
||||
} from '../doc-editor';
|
||||
import { Access } from '../doc-management';
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
abilities: {
|
||||
destroy: boolean;
|
||||
generate_document: boolean;
|
||||
accesses_manage: boolean;
|
||||
retrieve: boolean;
|
||||
update: boolean;
|
||||
partial_update: boolean;
|
||||
};
|
||||
accesses: Access[];
|
||||
title: string;
|
||||
is_public: boolean;
|
||||
css: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export type DocsExporterPDF = Exporter<
|
||||
NoInfer<DocsBlockSchema>,
|
||||
NoInfer<DocsInlineContentSchema>,
|
||||
NoInfer<DocsStyleSchema>,
|
||||
React.ReactElement<Text>,
|
||||
React.ReactElement<Link> | React.ReactElement<Text>,
|
||||
TextProps['style'],
|
||||
React.ReactElement<Text>
|
||||
>;
|
||||
|
||||
export type DocsExporterDocx = Exporter<
|
||||
NoInfer<DocsBlockSchema>,
|
||||
NoInfer<DocsInlineContentSchema>,
|
||||
NoInfer<DocsStyleSchema>,
|
||||
Promise<Paragraph[] | Paragraph | Table> | Paragraph[] | Paragraph | Table,
|
||||
ParagraphChild,
|
||||
IRunPropertiesOptions,
|
||||
TextRun
|
||||
>;
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
COLORS_DEFAULT,
|
||||
DefaultProps,
|
||||
UnreachableCaseError,
|
||||
} from '@blocknote/core';
|
||||
import { IParagraphOptions, ShadingType } from 'docx';
|
||||
|
||||
export function downloadFile(blob: Blob, filename: string) {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const exportResolveFileUrl = async (
|
||||
url: string,
|
||||
resolveFileUrl: ((url: string) => Promise<string | Blob>) | undefined,
|
||||
) => {
|
||||
if (!url.includes(window.location.hostname) && resolveFileUrl) {
|
||||
return resolveFileUrl(url);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
return response.blob();
|
||||
} catch {
|
||||
console.error(`Failed to fetch image: ${url}`);
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export function docxBlockPropsToStyles(
|
||||
props: Partial<DefaultProps>,
|
||||
colors: typeof COLORS_DEFAULT,
|
||||
): IParagraphOptions {
|
||||
return {
|
||||
shading:
|
||||
props.backgroundColor === 'default' || !props.backgroundColor
|
||||
? undefined
|
||||
: {
|
||||
type: ShadingType.SOLID,
|
||||
color:
|
||||
colors[
|
||||
props.backgroundColor as keyof typeof colors
|
||||
].background.slice(1),
|
||||
},
|
||||
run:
|
||||
props.textColor === 'default' || !props.textColor
|
||||
? undefined
|
||||
: {
|
||||
color: colors[props.textColor as keyof typeof colors].text.slice(1),
|
||||
},
|
||||
alignment:
|
||||
!props.textAlignment || props.textAlignment === 'left'
|
||||
? undefined
|
||||
: props.textAlignment === 'center'
|
||||
? 'center'
|
||||
: props.textAlignment === 'right'
|
||||
? 'right'
|
||||
: props.textAlignment === 'justify'
|
||||
? 'distribute'
|
||||
: (() => {
|
||||
throw new UnreachableCaseError(props.textAlignment);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
@@ -33,11 +33,13 @@ export const DocTitle = ({ doc }: DocTitleProps) => {
|
||||
};
|
||||
|
||||
interface DocTitleTextProps {
|
||||
title: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const DocTitleText = ({ title }: DocTitleTextProps) => {
|
||||
const { isMobile } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
return (
|
||||
<Text
|
||||
as="h2"
|
||||
@@ -45,7 +47,7 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
|
||||
$size={isMobile ? 'h4' : 'h2'}
|
||||
$variation="1000"
|
||||
>
|
||||
{title}
|
||||
{title || untitledDocument}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -114,6 +116,8 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
$color={colorsTokens()['greyscale-1000']}
|
||||
$minHeight="40px"
|
||||
$padding={{ right: 'big' }}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
content: '${untitledDocument}';
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useEditorStore } from '@/features/docs/doc-editor/';
|
||||
import { ModalExport } from '@/features/docs/doc-export/';
|
||||
import {
|
||||
Doc,
|
||||
ModalRemoveDoc,
|
||||
@@ -30,8 +31,6 @@ import {
|
||||
} from '@/features/docs/doc-versioning';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { ModalExport } from './ModalExport';
|
||||
|
||||
interface DocToolBoxProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCunninghamTheme } from '@/cunningham';
|
||||
import { DocTitleText } from './DocTitle';
|
||||
|
||||
interface DocVersionHeaderProps {
|
||||
title: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { TD, TH, TR, Table as TablePDF } from '@ag-media/react-pdf-table';
|
||||
import {
|
||||
DefaultBlockSchema,
|
||||
Exporter,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
TableContent,
|
||||
} from '@blocknote/core';
|
||||
import { View } from '@react-pdf/renderer';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export const Table = (props: {
|
||||
data: TableContent<InlineContentSchema>;
|
||||
transformer: Exporter<
|
||||
DefaultBlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown
|
||||
>;
|
||||
}) => {
|
||||
return (
|
||||
<TablePDF>
|
||||
{props.data.rows.map((row, index) => {
|
||||
if (index === 0) {
|
||||
return (
|
||||
<TH key={index}>
|
||||
{row.cells.map((cell, index) => {
|
||||
// Make empty cells are rendered.
|
||||
if (cell.length === 0) {
|
||||
cell.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<TD key={index}>
|
||||
{props.transformer.transformInlineContent(cell)}
|
||||
</TD>
|
||||
);
|
||||
})}
|
||||
</TH>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TR key={index}>
|
||||
{row.cells.map((cell, index) => {
|
||||
// Make empty cells are rendered.
|
||||
if (cell.length === 0) {
|
||||
cell.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<TD key={index}>
|
||||
<View>
|
||||
{
|
||||
props.transformer.transformInlineContent(
|
||||
cell,
|
||||
) as ReactNode
|
||||
}
|
||||
</View>
|
||||
</TD>
|
||||
);
|
||||
})}
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TablePDF>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Access } from '../doc-management';
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
abilities: {
|
||||
destroy: boolean;
|
||||
generate_document: boolean;
|
||||
accesses_manage: boolean;
|
||||
retrieve: boolean;
|
||||
update: boolean;
|
||||
partial_update: boolean;
|
||||
};
|
||||
accesses: Access[];
|
||||
title: string;
|
||||
is_public: boolean;
|
||||
css: string;
|
||||
code: string;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export function downloadFile(blob: Blob, filename: string) {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const exportResolveFileUrl = async (
|
||||
url: string,
|
||||
resolveFileUrl: ((url: string) => Promise<string | Blob>) | undefined,
|
||||
) => {
|
||||
if (!url.includes(window.location.hostname) && resolveFileUrl) {
|
||||
return resolveFileUrl(url);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
return response.blob();
|
||||
} catch {
|
||||
console.error(`Failed to fetch image: ${url}`);
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
@@ -87,9 +87,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
<Box aria-label={t('Content modal to delete document')}>
|
||||
{!isError && (
|
||||
<Text $size="sm" $variation="600">
|
||||
{t('Are you sure you want to delete the document "{{title}}"?', {
|
||||
title: doc.title,
|
||||
})}
|
||||
{t('Are you sure you want to delete this document ?')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export type Base64 = string;
|
||||
|
||||
export interface Doc {
|
||||
id: string;
|
||||
title: string;
|
||||
title?: string;
|
||||
content: Base64;
|
||||
creator: string;
|
||||
is_favorite: boolean;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Image from 'next/image';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DocLogo from '@/assets/icons/icon-docs.svg?url';
|
||||
import { Box, Text } from '@/components';
|
||||
@@ -10,137 +8,15 @@ import { ProConnectButton } from '@/features/auth';
|
||||
import { Title } from '@/features/header';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import SC5 from '../assets/SC5.png';
|
||||
import GithubIcon from '../assets/github.svg';
|
||||
|
||||
import { HomeSection } from './HomeSection';
|
||||
|
||||
export function HomeBottom() {
|
||||
const { componentTokens } = useCunninghamTheme();
|
||||
const withProConnect = componentTokens()['home-proconnect'].activated;
|
||||
|
||||
if (withProConnect) {
|
||||
return <HomeProConnect />;
|
||||
} else {
|
||||
return <HomeOpenSource />;
|
||||
if (!withProConnect) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function HomeOpenSource() {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { isTablet } = useResponsiveStore();
|
||||
|
||||
return (
|
||||
<HomeSection
|
||||
isColumn={false}
|
||||
isSmallDevice={isTablet}
|
||||
illustration={SC5}
|
||||
title={t('Govs ❤️ Open Source.')}
|
||||
tag={t('Open Source')}
|
||||
textWidth="60%"
|
||||
description={
|
||||
<Box
|
||||
$css={css`
|
||||
& a {
|
||||
color: ${colorsTokens()['primary-600']};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Text as="p" $display="inline">
|
||||
<Trans t={t} i18nKey="home-content-open-source-part1">
|
||||
Docs is built on top of{' '}
|
||||
<a href="https://www.django-rest-framework.org/" target="_blank">
|
||||
Django Rest Framework
|
||||
</a>
|
||||
,{' '}
|
||||
<a href="https://nextjs.org/" target="_blank">
|
||||
Next.js
|
||||
</a>
|
||||
, and{' '}
|
||||
<a href="https://min.io/" target="_blank">
|
||||
MinIO
|
||||
</a>
|
||||
. We also use{' '}
|
||||
<a href="https://github.com/yjs" target="_blank">
|
||||
Yjs
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="https://www.blocknotejs.org/" target="_blank">
|
||||
BlockNote.js
|
||||
</a>{' '}
|
||||
of which we are proud sponsors.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text as="p" $display="inline">
|
||||
<Trans t={t} i18nKey="home-content-open-source-part2">
|
||||
You can easily self-hosted Docs (check our installation{' '}
|
||||
<a
|
||||
href="https://github.com/suitenumerique/docs/tree/main/docs"
|
||||
target="_blank"
|
||||
>
|
||||
documentation
|
||||
</a>{' '}
|
||||
with production-ready examples).
|
||||
<br />
|
||||
Docs uses an innovation and business friendly{' '}
|
||||
<a
|
||||
href="https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
licence
|
||||
</a>
|
||||
.<br />
|
||||
Contributions are welcome (see our roadmap{' '}
|
||||
<a
|
||||
href="https://github.com/orgs/numerique-gouv/projects/13/views/11"
|
||||
target="_blank"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
).
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text as="p" $display="inline">
|
||||
<Trans t={t} i18nKey="home-content-open-source-part3">
|
||||
Docs is the result of a joint effort lead by the French 🇫🇷🥖
|
||||
<a href="https://www.numerique.gouv.fr/dinum/" target="_blank">
|
||||
(DINUM)
|
||||
</a>{' '}
|
||||
and German 🇩🇪🥨 governments{' '}
|
||||
<a href="https://zendis.de/" target="_blank">
|
||||
(ZenDiS)
|
||||
</a>
|
||||
. We are always looking for new public partners (we are currently
|
||||
onboarding the Netherlands 🇳🇱🧀). Feel free to reach out if you
|
||||
are interested in using or contributing to docs.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Box $direction="row" $gap="1rem" $margin={{ top: 'small' }}>
|
||||
<Button
|
||||
icon={
|
||||
<Text $isMaterialIcon $color="white">
|
||||
chat
|
||||
</Text>
|
||||
}
|
||||
href="https://matrix.to/#/#docs-official:matrix.org"
|
||||
target="_blank"
|
||||
>
|
||||
<Text $color="white">Matrix</Text>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
icon={<GithubIcon />}
|
||||
href="https://github.com/suitenumerique/docs"
|
||||
target="_blank"
|
||||
>
|
||||
Github
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <HomeProConnect />;
|
||||
}
|
||||
|
||||
function HomeProConnect() {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
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';
|
||||
@@ -17,6 +18,8 @@ import SC4En from '../assets/SC4-en.png';
|
||||
import SC4Fr from '../assets/SC4-fr.png';
|
||||
import SC4ResponsiveEn from '../assets/SC4-responsive-en.png';
|
||||
import SC4ResponsiveFr from '../assets/SC4-responsive-fr.png';
|
||||
import SC5 from '../assets/SC5.png';
|
||||
import GithubIcon from '../assets/github.svg';
|
||||
|
||||
import HomeBanner from './HomeBanner';
|
||||
import { HomeBottom } from './HomeBottom';
|
||||
@@ -24,10 +27,10 @@ import { HomeHeader, getHeaderHeight } from './HomeHeader';
|
||||
import { HomeSection } from './HomeSection';
|
||||
|
||||
export function HomeContent() {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile, isSmallMobile } = useResponsiveStore();
|
||||
const lang = useLanguage();
|
||||
const isFrLanguage = lang.language === 'fr';
|
||||
const { i18n, t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { isMobile, isSmallMobile, isTablet } = useResponsiveStore();
|
||||
const isFrLanguage = i18n.resolvedLanguage === 'fr';
|
||||
|
||||
return (
|
||||
<Box as="main">
|
||||
@@ -58,19 +61,142 @@ export function HomeContent() {
|
||||
$gap={isMobile ? '115px' : '230px'}
|
||||
$padding={{ bottom: '3rem' }}
|
||||
>
|
||||
<HomeSection
|
||||
isColumn={true}
|
||||
isSmallDevice={isMobile}
|
||||
illustration={isFrLanguage ? SC1ResponsiveFr : SC1ResponsiveEn}
|
||||
video={
|
||||
isFrLanguage ? `/assets/SC1-fr.webm` : `/assets/SC1-en.webm`
|
||||
}
|
||||
title={t('An uncompromising writing experience.')}
|
||||
tag={t('Write')}
|
||||
description={t(
|
||||
'Docs offers an intuitive writing experience. Its minimalist interface favors content over layout, while offering the essentials: media import, offline mode and keyboard shortcuts for greater efficiency.',
|
||||
)}
|
||||
/>
|
||||
<Box $gap="30px">
|
||||
<HomeSection
|
||||
isColumn={false}
|
||||
isSmallDevice={isTablet}
|
||||
illustration={SC5}
|
||||
title={t('Govs ❤️ Open Source.')}
|
||||
tag={t('Open Source')}
|
||||
textWidth="60%"
|
||||
$css={`min-height: calc(100vh - ${getHeaderHeight(isSmallMobile)}px);`}
|
||||
description={
|
||||
<Box
|
||||
$css={css`
|
||||
& a {
|
||||
color: ${colorsTokens()['primary-600']};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Text as="p" $display="inline">
|
||||
<Trans t={t} i18nKey="home-content-open-source-part1">
|
||||
Docs is built on top of{' '}
|
||||
<a
|
||||
href="https://www.django-rest-framework.org/"
|
||||
target="_blank"
|
||||
>
|
||||
Django Rest Framework
|
||||
</a>
|
||||
,{' '}
|
||||
<a href="https://nextjs.org/" target="_blank">
|
||||
Next.js
|
||||
</a>
|
||||
, and{' '}
|
||||
<a href="https://min.io/" target="_blank">
|
||||
MinIO
|
||||
</a>
|
||||
. We also use{' '}
|
||||
<a href="https://github.com/yjs" target="_blank">
|
||||
Yjs
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="https://www.blocknotejs.org/" target="_blank">
|
||||
BlockNote.js
|
||||
</a>{' '}
|
||||
of which we are proud sponsors.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text as="p" $display="inline">
|
||||
<Trans t={t} i18nKey="home-content-open-source-part2">
|
||||
You can easily self-hosted Docs (check our installation{' '}
|
||||
<a
|
||||
href="https://github.com/suitenumerique/docs/tree/main/docs"
|
||||
target="_blank"
|
||||
>
|
||||
documentation
|
||||
</a>{' '}
|
||||
with production-ready examples).
|
||||
<br />
|
||||
Docs uses an innovation and business friendly{' '}
|
||||
<a
|
||||
href="https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
licence
|
||||
</a>
|
||||
.<br />
|
||||
Contributions are welcome (see our roadmap{' '}
|
||||
<a
|
||||
href="https://github.com/orgs/numerique-gouv/projects/13/views/11"
|
||||
target="_blank"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
).
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text as="p" $display="inline">
|
||||
<Trans t={t} i18nKey="home-content-open-source-part3">
|
||||
Docs is the result of a joint effort lead by the French
|
||||
🇫🇷🥖
|
||||
<a
|
||||
href="https://www.numerique.gouv.fr/dinum/"
|
||||
target="_blank"
|
||||
>
|
||||
(DINUM)
|
||||
</a>{' '}
|
||||
and German 🇩🇪🥨 governments{' '}
|
||||
<a href="https://zendis.de/" target="_blank">
|
||||
(ZenDiS)
|
||||
</a>
|
||||
. We are always looking for new public partners (we are
|
||||
currently onboarding the Netherlands 🇳🇱🧀). Feel free to
|
||||
reach out if you are interested in using or contributing
|
||||
to docs.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="1rem"
|
||||
$margin={{ top: 'small' }}
|
||||
>
|
||||
<Button
|
||||
icon={
|
||||
<Text $isMaterialIcon $color="white">
|
||||
chat
|
||||
</Text>
|
||||
}
|
||||
href="https://matrix.to/#/#docs-official:matrix.org"
|
||||
target="_blank"
|
||||
>
|
||||
<Text $color="white">Matrix</Text>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
icon={<GithubIcon />}
|
||||
href="https://github.com/suitenumerique/docs"
|
||||
target="_blank"
|
||||
>
|
||||
Github
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<HomeSection
|
||||
isColumn={true}
|
||||
isSmallDevice={isMobile}
|
||||
illustration={isFrLanguage ? SC1ResponsiveFr : SC1ResponsiveEn}
|
||||
video={
|
||||
isFrLanguage ? `/assets/SC1-fr.webm` : `/assets/SC1-en.webm`
|
||||
}
|
||||
title={t('An uncompromising writing experience.')}
|
||||
tag={t('Write')}
|
||||
description={t(
|
||||
'Docs offers an intuitive writing experience. Its minimalist interface favors content over layout, while offering the essentials: media import, offline mode and keyboard shortcuts for greater efficiency.',
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<HomeSection
|
||||
isColumn={false}
|
||||
isSmallDevice={isMobile}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Box, BoxType, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
@@ -18,10 +18,12 @@ export type HomeSectionProps = {
|
||||
reverse?: boolean;
|
||||
textWidth?: string;
|
||||
video?: string;
|
||||
$css?: BoxType['$css'];
|
||||
};
|
||||
|
||||
export const HomeSection = ({
|
||||
availableSoon = false,
|
||||
$css,
|
||||
description,
|
||||
illustration,
|
||||
isSmallDevice,
|
||||
@@ -89,6 +91,7 @@ export const HomeSection = ({
|
||||
$hasTransition="slow"
|
||||
$css={css`
|
||||
opacity: ${isVisible ? 1 : 0};
|
||||
${$css}
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
@@ -122,7 +125,11 @@ export const HomeSection = ({
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text $variation="700" $weight="400" $size="md">
|
||||
<Text
|
||||
$variation="700"
|
||||
$weight="400"
|
||||
$size={isSmallMobile ? 'ml' : 'md'}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -146,20 +146,11 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
await RequestSerializer.fromRequest(this.initialRequest)
|
||||
).toObject();
|
||||
|
||||
if (!requestData.body) {
|
||||
return new Response('Body found', { status: 404 });
|
||||
}
|
||||
|
||||
const jsonObject = RequestSerializer.arrayBufferToJson<Partial<Doc>>(
|
||||
requestData.body,
|
||||
);
|
||||
|
||||
// Add a new doc id to the create request
|
||||
const uuid = self.crypto.randomUUID();
|
||||
const newRequestData = {
|
||||
...requestData,
|
||||
body: RequestSerializer.objectToArrayBuffer({
|
||||
...jsonObject,
|
||||
id: uuid,
|
||||
}),
|
||||
};
|
||||
@@ -175,16 +166,8 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
'doc-mutation',
|
||||
);
|
||||
|
||||
/**
|
||||
* Create new item in the cache
|
||||
*/
|
||||
const bodyMutate = (await this.initialRequest
|
||||
.clone()
|
||||
.json()) as Partial<Doc>;
|
||||
|
||||
const newResponse: Doc = {
|
||||
title: '',
|
||||
...bodyMutate,
|
||||
id: uuid,
|
||||
content: '',
|
||||
created_at: new Date().toISOString(),
|
||||
|
||||
@@ -346,13 +346,8 @@ describe('ApiPlugin', () => {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
arrayBuffer: () =>
|
||||
RequestSerializer.objectToArrayBuffer({
|
||||
title: 'my new doc',
|
||||
}),
|
||||
json: () => ({
|
||||
title: 'my new doc',
|
||||
}),
|
||||
arrayBuffer: () => RequestSerializer.objectToArrayBuffer({}),
|
||||
json: () => ({}),
|
||||
} as unknown as Request,
|
||||
} as any;
|
||||
|
||||
@@ -389,9 +384,7 @@ describe('ApiPlugin', () => {
|
||||
);
|
||||
expect(mockedPut).toHaveBeenCalledWith(
|
||||
'doc-item',
|
||||
expect.objectContaining({
|
||||
title: 'my new doc',
|
||||
}),
|
||||
expect.objectContaining({}),
|
||||
'http://test.jest/documents/444555/',
|
||||
);
|
||||
expect(mockedPut).toHaveBeenCalledWith(
|
||||
@@ -400,7 +393,6 @@ describe('ApiPlugin', () => {
|
||||
results: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: '444555',
|
||||
title: 'my new doc',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user