Compare commits

..

2 Commits

Author SHA1 Message Date
virgile-deville
faf38de099 📝(readme) add changelog entry
Added changelog entry so the pr passes the test
2025-02-23 11:48:54 +01:00
virgile-deville
c120ad4b84 📝(readme) move front-end local run instructions
New comers should only see the main info.
I removed the special commands from the readme.
And moved them to the /docs/local.md
2025-02-21 15:21:21 +01:00
144 changed files with 1369 additions and 2644 deletions

View File

@@ -8,43 +8,16 @@ 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
- 🛂(frontend) Restore version visibility #629
- 📝(doc) minor README.md formatting and wording enhancements
-Stop setting a default title on doc creation #634
- ♻️(frontend) misc ui improvements #644
- 📝(readme) remove front-end local run instructions local.md #651
## 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
@@ -432,8 +405,7 @@ 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.3.0...main
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
[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

View File

@@ -40,7 +40,7 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
* 🚀 Easy to install, scalable and secure alternative to Notion and Outline.
## Getting started 🔧
@@ -100,26 +100,6 @@ password: impress
$ make run
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
```
And run the frontend locally in development mode with the following command:
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
```
**Adding content**
You can create a basic demo site by running:

View File

@@ -68,8 +68,6 @@ server {
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {

92
docs/local.md Normal file
View File

@@ -0,0 +1,92 @@
# Run Docs locally
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose version
Docker Compose version v2.32.4
```
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to <http://localhost:3000>.
You will be prompted to log in, the default credentials are:
```
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
```
**Adding content**
You can create a basic demo site by running:
```shellscript
$ make demo
```
Finally, you can check all available Make rules using:
```shellscript
$ make help
```
**Django admin**
You can access the Django admin site at
<http://localhost:8071/admin>.
You first need to create a superuser account:
```shellscript
$ make superuser
```
## Front-end dev instructions
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
```
And run the frontend locally in development mode with the following command:
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
```

View File

@@ -23,7 +23,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -418,7 +418,6 @@ class FileUploadSerializer(serializers.Serializer):
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
self.context["file_name"] = file.name
return file
@@ -427,7 +426,6 @@ class FileUploadSerializer(serializers.Serializer):
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
attrs["content_type"] = self.context["content_type"]
attrs["file_name"] = self.context["file_name"]
return attrs

View File

@@ -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-Z0-9]{1,10}"
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
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}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
@@ -915,31 +915,15 @@ 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 (
not serializer.validated_data["content_type"].startswith("image/")
or serializer.validated_data["is_unsafe"]
):
extra_args.update(
{"ContentDisposition": f'attachment; filename="{file_name:s}"'}
)
else:
extra_args.update(
{"ContentDisposition": f'inline; filename="{file_name:s}"'}
)
file = serializer.validated_data["file"]
default_storage.connection.meta.client.upload_fileobj(
@@ -1167,14 +1151,13 @@ 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,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
language,
)
def perform_update(self, serializer):
@@ -1400,11 +1383,10 @@ 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,
self.request.user.language or settings.LANGUAGE_CODE,
invitation.email, invitation.role, self.request.user, language
)

View File

@@ -1,36 +0,0 @@
# 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",
),
),
]

View File

@@ -194,11 +194,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=None,
default=settings.LANGUAGE_CODE,
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",
@@ -699,7 +697,6 @@ 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,
}
)
@@ -741,12 +738,8 @@ 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 = (
context["title"]
if not self.title
else _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
self.send_email(subject, [email], context, language)
@@ -794,9 +787,6 @@ class Document(MP_Node, BaseModel):
}
)
# save the current deleted_at value to exclude it from the descendants update
current_deleted_at = self.deleted_at
# Restore the current document
self.deleted_at = None
@@ -804,17 +794,26 @@ 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 = ancestors_deleted_at
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
self.save()
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)
# 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
)
class LinkTrace(BaseModel):

View File

@@ -16,9 +16,6 @@ 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()
@@ -126,7 +123,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory(language="en-us")
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
@@ -202,7 +199,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory(language="en-us")
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
@@ -238,73 +235,3 @@ 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()

View File

@@ -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(language="en-us")
user = factories.UserFactory()
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_senders_language():
def test_api_document_invitations_create_email_from_content_language():
"""
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.
The email generated is from the language set in the Content-Language header
"""
user = factories.UserFactory(language="fr-fr")
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -444,6 +444,7 @@ def test_api_document_invitations_create_email_from_senders_language():
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "fr-fr"},
)
assert response.status_code == 201
@@ -463,11 +464,50 @@ def test_api_document_invitations_create_email_from_senders_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="", language="en-us")
user = factories.UserFactory(full_name="")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")

View File

@@ -79,7 +79,6 @@ def test_api_documents_attachment_upload_anonymous_success():
assert file_head["Metadata"] == {"owner": "None"}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
@pytest.mark.parametrize(
@@ -218,7 +217,6 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
def test_api_documents_attachment_upload_invalid(client):
@@ -293,9 +291,7 @@ 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
@@ -305,7 +301,6 @@ def test_api_documents_attachment_upload_fix_extension(
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == content_type
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
def test_api_documents_attachment_upload_empty_file():
@@ -345,9 +340,7 @@ 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
@@ -357,4 +350,3 @@ def test_api_documents_attachment_upload_unsafe():
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == "application/octet-stream"
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'

View File

@@ -64,30 +64,6 @@ 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):
"""

View File

@@ -39,11 +39,7 @@ def test_api_config(is_authenticated):
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
["fr-fr", "Français"],
["de-de", "Deutsch"],
],
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},

View File

@@ -158,7 +158,6 @@ 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,
}

View File

@@ -636,37 +636,6 @@ 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 &quot;editor&quot; "
"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.
@@ -796,122 +765,3 @@ 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

View File

@@ -7,12 +7,17 @@ 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": "user-e2e-chromium",
"email": "user@chromium.e2e",
"language": "en-us",
"username": "impress",
"email": "impress@impress.world",
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
},
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
]

View File

@@ -179,8 +179,7 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
language=dev_user["language"]
or random.choice(settings.LANGUAGES)[0],
language=random.choice(settings.LANGUAGES)[0],
)
)

View File

@@ -19,7 +19,6 @@ 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__)))
@@ -211,6 +210,7 @@ class Base(Configuration):
"application/x-ms-regedit",
"application/x-msdownload",
"application/xml",
"image/svg+xml",
]
# Document versions
@@ -221,9 +221,7 @@ class Base(Configuration):
# Languages
LANGUAGE_CODE = values.Value("en-us")
# cookie & language is set from frontend
LANGUAGE_COOKIE_NAME = "docs_language"
LANGUAGE_COOKIE_PATH = "/"
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -235,9 +233,9 @@ class Base(Configuration):
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -649,10 +647,8 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
sentry_sdk.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\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:455 core/api/serializers.py:455
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr "Titel"
@@ -251,143 +251,143 @@ msgstr "Dokumente"
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:734 core/models.py:734
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:738 core/models.py:738
#: build/lib/core/models.py:723 core/models.py:723
#, 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:741 core/models.py:741
#: build/lib/core/models.py:726 core/models.py:726
#, 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:777 core/models.py:777
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:844 core/models.py:844
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:857 core/models.py:857
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:896 core/models.py:896
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:897 core/models.py:897
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:903 core/models.py:903
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:909 core/models.py:909
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
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:943 core/models.py:943
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:944 core/models.py:944
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:945 core/models.py:945
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:947 core/models.py:947
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:949 core/models.py:949
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:955 core/models.py:955
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:956 core/models.py:956
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1011 core/models.py:1011
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1065 core/models.py:1065
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1066 core/models.py:1066
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1086 core/models.py:1086
#: build/lib/core/models.py:1069 core/models.py:1069
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:235 impress/settings.py:235
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr "Englisch"
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr "Französisch"
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr "Deutsch"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\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:455 core/api/serializers.py:455
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
@@ -251,143 +251,143 @@ msgstr ""
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:734 core/models.py:734
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:738 core/models.py:738
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:741 core/models.py:741
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:777 core/models.py:777
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:844 core/models.py:844
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:896 core/models.py:896
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:897 core/models.py:897
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:903 core/models.py:903
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:909 core/models.py:909
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:944 core/models.py:944
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:945 core/models.py:945
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:949 core/models.py:949
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:955 core/models.py:955
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:956 core/models.py:956
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1011 core/models.py:1011
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1065 core/models.py:1065
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1066 core/models.py:1066
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1086 core/models.py:1086
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:235 impress/settings.py:235
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-04 08:20\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\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:455 core/api/serializers.py:455
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
@@ -249,145 +249,145 @@ msgstr ""
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr "Document sans titre"
msgstr ""
#: build/lib/core/models.py:734 core/models.py:734
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:738 core/models.py:738
#: build/lib/core/models.py:723 core/models.py:723
#, 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:741 core/models.py:741
#: build/lib/core/models.py:726 core/models.py:726
#, 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:777 core/models.py:777
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:844 core/models.py:844
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:896 core/models.py:896
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:897 core/models.py:897
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:903 core/models.py:903
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:909 core/models.py:909
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:944 core/models.py:944
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:945 core/models.py:945
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:949 core/models.py:949
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:955 core/models.py:955
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:956 core/models.py:956
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1011 core/models.py:1011
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1065 core/models.py:1065
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1066 core/models.py:1066
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1086 core/models.py:1086
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:235 impress/settings.py:235
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\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:455 core/api/serializers.py:455
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
@@ -251,143 +251,143 @@ msgstr ""
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:734 core/models.py:734
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:738 core/models.py:738
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:741 core/models.py:741
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:777 core/models.py:777
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:844 core/models.py:844
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:896 core/models.py:896
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:897 core/models.py:897
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:903 core/models.py:903
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:909 core/models.py:909
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:944 core/models.py:944
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:945 core/models.py:945
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:949 core/models.py:949
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:955 core/models.py:955
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:956 core/models.py:956
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1011 core/models.py:1011
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1065 core/models.py:1065
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1066 core/models.py:1066
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1086 core/models.py:1086
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:235 impress/settings.py:235
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.4.0"
version = "2.2.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.37.5",
"boto3==1.36.7",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-filter==25.1",
"django-filter==24.3",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.5",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.6",
"django==5.1.5",
"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.3",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.65.2",
"psycopg[binary]==3.2.5",
"openai==1.60.2",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.22.0",
"sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"whitenoise==6.9.0",
"whitenoise==6.8.2",
"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==2025.3.1",
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==9.0.1",
"ipython==8.31.0",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.9",
"types-requests==2.32.0.20250301",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
]
[tool.setuptools]

View File

@@ -17,13 +17,13 @@ test.describe('404', () => {
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible();
await expect(page.getByText('Home')).toBeVisible();
await expect(page.getByText('Back to home page')).toBeVisible();
});
test('checks go back to home page redirects to home page', async ({
page,
}) => {
await page.getByText('Home').click();
await page.getByText('Back to home page').click();
await expect(page).toHaveURL('/');
});
});

View File

@@ -1,22 +0,0 @@
<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>

View File

@@ -97,7 +97,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByLabel(role).click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;

View File

@@ -12,8 +12,8 @@ const config = {
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'Français'],
['de-de', 'Deutsch'],
['fr-fr', 'French'],
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},

View File

@@ -1,7 +1,8 @@
/* 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,
@@ -15,6 +16,41 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
test('it check translations of the slash menu when changing language', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const header = page.locator('header').first();
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Reset menu
await editor.click();
await editor.fill('');
// Change language to French
await header.click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
});
test('it checks default toolbar buttons are displayed', async ({
page,
browserName,
@@ -93,7 +129,11 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page
.getByRole('button', {
name: 'Connected',
})
.click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
@@ -375,8 +415,6 @@ 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;
@@ -403,45 +441,6 @@ 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');
});
});

View File

@@ -197,49 +197,4 @@ 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
});
});

View File

@@ -7,9 +7,17 @@ type SmallDoc = {
title: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
@@ -86,10 +94,6 @@ test.describe('Documents Grid mobile', () => {
});
test.describe('Document grid item options', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
@@ -208,8 +212,6 @@ test.describe('Document grid item options', () => {
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
await page.goto('/');
// All Docs
const response = await page.waitForResponse(
(response) =>
@@ -280,9 +282,11 @@ test.describe('Documents filters', () => {
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
@@ -310,12 +314,11 @@ test.describe('Documents Grid', () => {
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse((response) => {
return (
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().endsWith(`/documents/?page=1`) &&
response.status() === 200
);
});
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
@@ -323,8 +326,6 @@ test.describe('Documents Grid', () => {
response.status() === 200,
);
await page.goto('/');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();

View File

@@ -88,14 +88,20 @@ test.describe('Doc Header', () => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Delete document').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
).toBeVisible();
await expect(
page.getByText(`Are you sure you want to delete this document ?`),
page.getByText(
`Are you sure you want to delete the document "${randomDoc}"?`,
),
).toBeVisible();
await page
@@ -146,7 +152,9 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -168,7 +176,11 @@ test.describe('Doc Header', () => {
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(page.getByLabel('Delete')).toBeEnabled();
await expect(
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
await invitationCard.click();
const memberCard = shareModal.getByLabel('List members card');
@@ -182,7 +194,11 @@ test.describe('Doc Header', () => {
).toBeVisible();
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(page.getByLabel('Delete')).toBeEnabled();
await expect(
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -216,7 +232,9 @@ test.describe('Doc Header', () => {
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -276,7 +294,9 @@ test.describe('Doc Header', () => {
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -332,7 +352,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByLabel('Copy as Markdown').click();
await page.getByRole('button', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -367,7 +387,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByLabel('Copy as HTML').click();
await page.getByRole('button', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -440,7 +460,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.getByLabel('Share').click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
@@ -474,7 +494,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Share').click();
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();

View File

@@ -65,13 +65,15 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByLabel('Reader')).toBeVisible();
await expect(page.getByLabel('Editor')).toBeVisible();
await expect(page.getByLabel('Owner')).toBeVisible();
await expect(page.getByLabel('Administrator')).toBeVisible();
await expect(page.getByRole('button', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -119,7 +121,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.getByLabel('Owner').click();
await page.getByRole('button', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -137,7 +139,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Owner').click();
await page.getByRole('button', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -153,6 +155,47 @@ test.describe('Document create member', () => {
expect(responseCreateInvitationFail.ok()).toBeFalsy();
});
test('The invitation endpoint get the language of the website', async ({
page,
browserName,
}) => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'translate 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('button', { 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);
@@ -169,7 +212,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.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -189,14 +232,14 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Reader').click();
await page.getByRole('button', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByLabel('Delete').click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});

View File

@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
await list.click();
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByLabel('Reader').click();
await page.getByRole('button', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -215,13 +215,13 @@ test.describe('Document list members', () => {
await expect(mySelfMoreActions).toBeVisible();
await userReaderMoreActions.click();
await page.getByLabel('Delete').click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByLabel('Delete').click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
});

View File

@@ -18,7 +18,11 @@ test.describe('Doc Version', () => {
await verifyDocName(page, randomDoc);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
@@ -54,7 +58,11 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -82,7 +90,9 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Version history')).toBeDisabled();
await expect(
page.getByRole('button', { name: 'Version history' }),
).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -109,7 +119,11 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');

View File

@@ -49,13 +49,21 @@ test.describe('Doc Visibility', () => {
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page
.getByRole('button', {
name: 'Connected',
})
.click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await selectVisibility.click();
await page.getByLabel('Public', { exact: true }).click();
await page
.getByRole('button', {
name: 'Public',
})
.click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
});
@@ -92,9 +100,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
@@ -127,7 +133,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
@@ -154,7 +160,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.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -207,7 +213,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -219,7 +225,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Reading',
})
.click();
@@ -281,7 +287,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -349,7 +355,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -373,10 +379,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
@@ -399,7 +402,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -408,14 +411,6 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Document accessible to any connected person', {
exact: true,
}),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const urlDoc = page.url();
@@ -461,7 +456,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();

View File

@@ -12,7 +12,7 @@ test.describe('Home page', () => {
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(
header.getByRole('button', { name: /Language/ }),
header.getByRole('combobox', { name: 'Language' }),
).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
@@ -26,7 +26,6 @@ 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();

View File

@@ -1,50 +1,27 @@
import { Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe.serial('Language', () => {
let page: Page;
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(page.getByLabel('Logout')).toBeVisible();
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();
// initial language should be english
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
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'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('button').getByText('Français').click();
await page.getByLabel('Deutsch').click();
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
@@ -74,76 +51,12 @@ test.describe.serial('Language', () => {
// Check for English 404 response
await check404Response('Not found.');
await waitForLanguageSwitch(page, TestLanguage.French);
// Switch language to French
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
// 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);
}

View File

@@ -29,7 +29,7 @@ test.describe('Left panel mobile', () => {
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('button', { name: /Language/ });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "2.4.0",
"version": "2.2.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",

View File

@@ -357,15 +357,6 @@ 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',
@@ -390,7 +381,6 @@ const config = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: {

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "2.4.0",
"version": "2.2.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,11 +16,11 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.1",
"@blocknote/core": "*",
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@blocknote/xl-docx-exporter": "*",
"@blocknote/xl-pdf-exporter": "*",
"@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",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "2.9.4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

View File

@@ -8,20 +8,17 @@ import {
import { Button, Popover } from 'react-aria-components';
import styled from 'styled-components';
import { BoxProps } from './Box';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd;
transition: opacity 0.2s ease-in-out;
`;
interface StyledButtonProps {
$css?: BoxProps['$css'];
}
const StyledButton = styled(Button)<StyledButtonProps>`
const StyledButton = styled(Button)`
cursor: pointer;
border: none;
background: none;
@@ -32,12 +29,10 @@ const StyledButton = styled(Button)<StyledButtonProps>`
font-size: 0.938rem;
padding: 0;
text-wrap: nowrap;
${({ $css }) => $css};
`;
export interface DropButtonProps {
button: ReactNode;
buttonCss?: BoxProps['$css'];
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
@@ -45,7 +40,6 @@ export interface DropButtonProps {
export const DropButton = ({
button,
buttonCss,
isOpen = false,
onOpenChange,
children,
@@ -70,7 +64,6 @@ export const DropButton = ({
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
$css={buttonCss}
>
{button}
</StyledButton>

View File

@@ -1,4 +1,4 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -20,7 +20,6 @@ export type DropdownMenuProps = {
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
buttonCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
};
@@ -31,7 +30,6 @@ export const DropdownMenu = ({
disabled = false,
showArrow = false,
arrowCss,
buttonCss,
label,
topMessage,
}: PropsWithChildren<DropdownMenuProps>) => {
@@ -39,7 +37,6 @@ export const DropdownMenu = ({
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const blockButtonRef = useRef<HTMLDivElement>(null);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
@@ -54,17 +51,10 @@ export const DropdownMenu = ({
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
button={
showArrow ? (
<Box
ref={blockButtonRef}
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Box $direction="row" $align="center">
<div>{children}</div>
<Icon
$variation="600"
$css={
@@ -77,17 +67,11 @@ export const DropdownMenu = ({
/>
</Box>
) : (
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
children
)
}
>
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu"
>
<Box $maxWidth="320px">
{topMessage && (
<Text
$variation="700"
@@ -106,7 +90,6 @@ export const DropdownMenu = ({
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"

View File

@@ -25,9 +25,7 @@ export interface TextProps extends BoxProps {
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'

View File

@@ -35,7 +35,6 @@ export const TextErrors = ({
<Text
key={`causes-${i}`}
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
@@ -44,12 +43,7 @@ export const TextErrors = ({
))}
{!causes && (
<Text
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
<Text $theme="danger" $textAlign="center" {...textProps}>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}

View File

@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Auth } from '@/features/auth';
import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { ConfigProvider } from './config/';

View File

@@ -3,8 +3,7 @@ import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useLanguageSynchronizer } from '@/features/language/hooks/useLanguageSynchronizer';
import { CrispProvider, PostHogProvider } from '@/services';
import { PostHogProvider, configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
@@ -13,7 +12,6 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
const { synchronizeLanguage } = useLanguageSynchronizer();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
@@ -32,8 +30,12 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
void synchronizeLanguage();
}, [synchronizeLanguage]);
if (!conf?.CRISP_WEBSITE_ID) {
return;
}
configureCrispSession(conf.CRISP_WEBSITE_ID);
}, [conf?.CRISP_WEBSITE_ID]);
if (!conf) {
return (
@@ -43,11 +45,5 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
);
}
return (
<PostHogProvider conf={conf.POSTHOG_KEY}>
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
{children}
</CrispProvider>
</PostHogProvider>
);
return <PostHogProvider conf={conf.POSTHOG_KEY}>{children}</PostHogProvider>;
};

View File

@@ -310,7 +310,7 @@ input:-webkit-autofill:focus {
}
/**
* Checkbox
* Others
*/
.c__checkbox:focus-within {
border-color: transparent;
@@ -365,8 +365,7 @@ input:-webkit-autofill:focus {
}
.c__button--medium {
height: auto;
min-height: var(--c--components--button--medium-height);
padding: 0.9rem var(--c--theme--spacings--s);
}
.c__button--small {
@@ -407,10 +406,6 @@ input:-webkit-autofill:focus {
);
}
.c__button--primary-text {
color: var(--c--components--button--primary-text--color);
}
.c__button--primary-text:hover,
.c__button--primary-text:focus-visible {
background-color: var(
@@ -552,8 +547,6 @@ input:-webkit-autofill:focus {
.c__modal__close .c__button {
padding: 0 !important;
top: -0.65rem;
right: -0.65rem;
}
.c__modal--full .c__modal__content {
@@ -612,22 +605,3 @@ 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
);
}

View File

@@ -484,19 +484,6 @@
--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;
@@ -518,9 +505,6 @@
--c--components--button--primary-text--color-hover: var(
--c--theme--colors--primary-text
);
--c--components--button--primary-text--color: var(
--c--theme--colors--primary-800
);
--c--components--button--secondary--background--color-hover: #f6f6f6;
--c--components--button--secondary--background--color-active: #ededed;
--c--components--button--secondary--border--color: var(

View File

@@ -483,18 +483,7 @@ export const tokens = {
},
},
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)',
},
},
},
alert: { 'border-radius': '0' },
modal: { 'width-small': '342px' },
button: {
'medium-height': '40px',
@@ -516,7 +505,6 @@ export const tokens = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: { 'color-hover': '#F6F6F6', 'color-active': '#EDEDED' },

View File

@@ -4,12 +4,10 @@
* @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;
}

View File

@@ -1,6 +1,6 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { APIError, fetchAPI } from '@/api';
import { User } from './types';
@@ -17,10 +17,7 @@ import { User } from './types';
export const getMe = async (): Promise<User> => {
const response = await fetchAPI(`users/me/`);
if (!response.ok) {
throw new APIError(
`Couldn't fetch user data: ${response.statusText}`,
await errorCauses(response),
);
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
}
return response.json() as Promise<User>;
};

View File

@@ -5,7 +5,6 @@ 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 } =
@@ -20,22 +19,6 @@ 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.
*/

View File

@@ -14,11 +14,7 @@ export const ButtonLogin = () => {
if (!authenticated) {
return (
<Button
onClick={() => gotoLogin()}
color="primary-text"
aria-label={t('Login')}
>
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
{t('Login')}
</Button>
);
@@ -36,7 +32,7 @@ export const ProConnectButton = () => {
return (
<BoxButton
onClick={() => gotoLogin()}
onClick={gotoLogin}
aria-label={t('Proconnect Login')}
$css={css`
background-color: var(--c--theme--colors--primary-text);

View File

@@ -2,12 +2,13 @@ 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 } = useRouter();
const { pathname, replace } = useRouter();
const [pathAllowed, setPathAllowed] = useState<boolean>(
!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)),
@@ -17,10 +18,17 @@ export const useAuth = () => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)));
}, [pathname]);
return {
user,
authenticated: !!user && authStates.isSuccess,
pathAllowed,
...authStates,
};
// 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 };
};

View File

@@ -1,4 +1,4 @@
export * from './api';
export * from './api/types';
export * from './components';
export * from './hooks';
export * from './utils';

View File

@@ -16,11 +16,8 @@ export const setAuthUrl = () => {
}
};
export const gotoLogin = (withRedirect = true) => {
if (withRedirect) {
setAuthUrl();
}
export const gotoLogin = () => {
setAuthUrl();
window.location.replace(LOGIN_URL);
};

View File

@@ -20,7 +20,7 @@ import {
AITransformActions,
useDocAITransform,
useDocAITranslate,
} from '../../api';
} from '../api/';
type LanguageTranslate = {
value: string;

View File

@@ -1,7 +1,6 @@
import {
BlockNoteSchema,
Dictionary,
defaultBlockSpecs,
locales,
withPageBreak,
} from '@blocknote/core';
@@ -26,17 +25,9 @@ import { cssEditor } from '../styles';
import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { QuoteBlock } from './custom-blocks';
import { BlockNoteToolbar } from './BlockNoteToolbar';
export const blockNoteSchema = withPageBreak(
BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
quote: QuoteBlock,
},
}),
);
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
interface BlockNoteEditorProps {
doc: Doc;
@@ -51,7 +42,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.resolvedLanguage;
const lang = i18n.language;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
@@ -134,7 +125,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
$css={cssEditor(readOnly)}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<Box $margin={{ bottom: 'big' }}>
<TextErrors
causes={errorAttachment.cause}
canClose
@@ -150,8 +141,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editable={!readOnly}
theme="light"
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
<BlockNoteSuggestionMenu />
</BlockNoteView>
</Box>
);

View File

@@ -5,19 +5,13 @@ import {
getDefaultReactSlashMenuItems,
getPageBreakReactSlashMenuItems,
useBlockNoteEditor,
useDictionary,
} from '@blocknote/react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { DocsBlockSchema } from '../types';
import { getQuoteReactSlashMenuItems } from './custom-blocks';
import { DocsBlockNoteEditor } from '../types';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor<DocsBlockSchema>();
const { t } = useTranslation();
const basicBlocksName = useDictionary().slash_menu.page_break.group;
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
const getSlashMenuItems = useMemo(() => {
return async (query: string) =>
@@ -26,12 +20,11 @@ export const BlockNoteSuggestionMenu = () => {
combineByGroup(
getDefaultReactSlashMenuItems(editor),
getPageBreakReactSlashMenuItems(editor),
getQuoteReactSlashMenuItems(editor, t, basicBlocksName),
),
query,
),
);
}, [basicBlocksName, editor, t]);
}, [editor]);
return (
<SuggestionMenuController

View File

@@ -1,75 +0,0 @@
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}
/>
)}
</>
);
};

View File

@@ -1,111 +0,0 @@
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()}
/>
</>
);
};

View File

@@ -1,74 +0,0 @@
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>
);
};

View File

@@ -0,0 +1,30 @@
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} />;
};

View File

@@ -1,77 +0,0 @@
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',
});

View File

@@ -1 +0,0 @@
export * from './QuoteBlock';

View File

@@ -1,2 +1 @@
export * from './DocEditor';
export * from './custom-blocks/';

View File

@@ -6,14 +6,6 @@ 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;
}
@@ -84,13 +76,13 @@ export const cssEditor = (readonly: boolean) => css`
.bn-block-outer:not(:first-child) {
&:has(h1) {
margin-top: 32px;
padding-top: 32px;
}
&:has(h2) {
margin-top: 24px;
padding-top: 24px;
}
&:has(h3) {
margin-top: 16px;
padding-top: 16px;
}
}
@@ -100,16 +92,9 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: 4px;
}
@media screen and (width <= 768px) {
& .bn-editor {
padding-right: 36px;
}
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;

View File

@@ -17,12 +17,8 @@ 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<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
typeof blockNoteSchema.blockSchema,
typeof blockNoteSchema.inlineContentSchema,
typeof blockNoteSchema.styleSchema
>;

View File

@@ -1,25 +0,0 @@
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>
);
};

View File

@@ -1,5 +0,0 @@
export * from './headingPDF';
export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';

View File

@@ -1,31 +0,0 @@
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>
);
};

View File

@@ -1,33 +0,0 @@
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),
});
};

View File

@@ -1,21 +0,0 @@
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>
);
};

View File

@@ -1,52 +0,0 @@
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>
);
};

View File

@@ -1 +0,0 @@
export * from './ModalExport';

View File

@@ -1,2 +0,0 @@
export * from './components';
export * from './utils';

View File

@@ -1,12 +0,0 @@
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,
},
};

View File

@@ -1,20 +0,0 @@
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,
},
};

View File

@@ -1,53 +0,0 @@
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
>;

View File

@@ -1,75 +0,0 @@
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);
})(),
};
}

View File

@@ -27,7 +27,6 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
const { t } = useTranslation();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const { transRole } = useTrans();
@@ -39,7 +38,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')}
>
{(docIsPublic || docIsAuth) && (
{docIsPublic && (
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
@@ -58,12 +57,10 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName={docIsPublic ? 'public' : 'vpn_lock'}
iconName="public"
/>
<Text $theme="primary" $variation="800">
{docIsPublic
? t('Public document')
: t('Document accessible to any connected person')}
{t('Public document')}
</Text>
</Box>
)}
@@ -79,9 +76,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
<Box $gap={spacings['3xs']} $overflow="auto">
<Box $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">

View File

@@ -33,13 +33,11 @@ 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"
@@ -47,7 +45,7 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
$size={isMobile ? 'h4' : 'h2'}
$variation="1000"
>
{title || untitledDocument}
{title}
</Text>
);
};
@@ -103,37 +101,39 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}, [doc]);
return (
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
$color={colorsTokens()['greyscale-1000']}
$minHeight="40px"
$padding={{ right: 'big' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
<>
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
$color={colorsTokens()['greyscale-1000']}
$margin={{ left: '-2px', right: '10px' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
</>
);
};

View File

@@ -18,12 +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,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -31,13 +26,15 @@ import {
} from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { ModalExport } from './ModalExport';
interface DocToolBoxProps {
doc: Doc;
}
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1 && doc.abilities.accesses_view;
const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -53,7 +50,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const options: DropdownMenuOption[] = [
...(isSmallMobile
@@ -70,11 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),

View File

@@ -6,7 +6,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { DocTitleText } from './DocTitle';
interface DocVersionHeaderProps {
title?: string;
title: string;
}
export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {

View File

@@ -1,5 +1,11 @@
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import {
DOCXExporter,
docxDefaultSchemaMappings,
} from '@blocknote/xl-docx-exporter';
import {
PDFExporter,
pdfDefaultSchemaMappings,
} from '@blocknote/xl-pdf-exporter';
import {
Button,
Loader,
@@ -9,20 +15,20 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { pdf } from '@react-pdf/renderer';
import { Text as PDFText, 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, useTrans } from '@/features/docs/doc-management';
import { Doc } 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',
@@ -45,7 +51,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const [format, setFormat] = useState<DocDownloadFormat>(
DocDownloadFormat.PDF,
);
const { untitledDocument } = useTrans();
const templateOptions = useMemo(() => {
const templateOptions = (templates?.pages || [])
@@ -73,7 +78,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setIsExporting(true);
const title = (doc.title || untitledDocument)
const title = doc.title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
@@ -90,25 +95,91 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
if (format === DocDownloadFormat.PDF) {
const defaultExporter = new PDFExporter(
editor.schema,
pdfDocsSchemaMappings,
pdfDefaultSchemaMappings,
);
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
});
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 pdfDocument = await exporter.toReactPDFDocument(exportDocument);
blobExport = await pdf(pdfDocument).toBlob();
} else {
const defaultExporter = new DOCXExporter(
editor.schema,
docxDocsSchemaMappings,
docxDefaultSchemaMappings,
);
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
});
const exporter = new DOCXExporter(
editor.schema,
docxDefaultSchemaMappings,
{
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
},
);
blobExport = await exporter.toBlob(exportDocument);
}

View File

@@ -0,0 +1,76 @@
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>
);
};

View File

@@ -0,0 +1,18 @@
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;
}

View File

@@ -0,0 +1,32 @@
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;
};

View File

@@ -71,15 +71,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
</Button>
</>
}
size={ModalSize.SMALL}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
{t('Delete a doc')}
</Text>
}
@@ -87,7 +81,9 @@ 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 this document ?')}
{t('Are you sure you want to delete the document "{{title}}"?', {
title: doc.title,
})}
</Text>
)}

View File

@@ -36,7 +36,7 @@ export type Base64 = string;
export interface Doc {
id: string;
title?: string;
title: string;
content: Base64;
creator: string;
is_favorite: boolean;

View File

@@ -10,6 +10,7 @@ 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';
@@ -20,15 +21,20 @@ 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,

View File

@@ -4,6 +4,7 @@ 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';
@@ -11,15 +12,20 @@ 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,

View File

@@ -12,6 +12,7 @@ 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';
@@ -41,6 +42,7 @@ 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();
@@ -88,6 +90,7 @@ export const DocShareAddMemberList = ({
const payload = {
role: invitationRole,
docId: doc.id,
contentLanguage,
};
return isInvitationMode

View File

@@ -30,7 +30,6 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
full_name: invitation.email,
email: invitation.email,
short_name: invitation.email,
language: 'en-us',
};
const { toast } = useToastProvider();

Some files were not shown because too many files have changed in this diff Show More