Compare commits

...

29 Commits

Author SHA1 Message Date
Cyril 0144044c55 (frontend) add pdf export regression test with embedded image fixtures
adds base64 png/jpg/svg image fixtures to stabilize pdf export snapshots

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-01 11:46:43 +01:00
Cyril a6da37e231 (frontend) add test to compare generated PDF against reference template
ensures no regression by validating PDF content matches the expected pattern

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-01 11:46:31 +01:00
Anthony LC 9aeedd1d03 ️(frontend) improve UploadFile process
We notices that `context.getChanges` was very
greedy, on a large document with multiple
users collaborating, it caused performance issues.
We change the way that we track a upload by
listening onUploadEnd event instead of tracking
all changes in the document.
When a doc opens, we check if there are any ongoing
uploads and resume them.
We fix as well a race condition that could happen
when multiple collaborators were on a document
during an upload.
2025-12-01 10:31:46 +01:00
Anthony LC f7d4e6810b ️(frontend) enhance Table of Contents
- the Table of Contents stickiness now covers the
full height of the viewport, before it was limited to
100vh
- we listen the scroll to highlight the heading
in the Table of Contents only when the Table of Contents
is open
- We debounce the editor change to avoid excessive updates
to the Table of Contents
2025-12-01 10:31:45 +01:00
Anthony LC b740ffa52c 📌(frontend) Bump body-parser from 2.2.0 to 2.2.1
Bumps [body-parser](https://github.com/expressjs/body-parser)
from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:52:23 +01:00
Anthony LC f555e36e98 ⬆️(dependencies) Jump js-yaml from 3.14.1 to 3.14.2
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.14.1 to 3.14.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:51:11 +01:00
Anthony LC de11ab508f ⬆️(dependencies) Bump glob from 10.4.5 to 10.5.0 in /src/mail
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:11:53 +01:00
Anthony LC dc2fe4905b ⬆️(frontend) upgrade some gitHub actions
Some github actions were using outdated versions.
This commit upgrades them to use a common versions
for all our workflows.
2025-11-28 17:36:16 +01:00
Anthony LC 2864669dde 🚸(frontend) do not show comments button on resources
The comments does not seems to work on
resources (images, pdf, ...), so we hide the button
when the selected block is not a text block.
2025-11-26 12:03:09 +01:00
Anthony LC 7dae3a3c02 🍱(frontend) Docs icon overridable from theme
Depend the theme, you can have different Docs icons
in the header.
A customization was already possible from the
theme customization file, but now it is as
well possible to override the icon from the theme
itself, making it easier to manage different themes
with different icons.
We change the theme customization variable name
to "icon" instead of "logo", "logo" was already
used for the main logo of the application inside
the theme configuration.
2025-11-26 12:03:09 +01:00
Anthony LC bdf62e2172 🔥(frontend) remove button delete invitation
We can already remove a invitation directly from
the role dropdown, so having a delete button in the
more actions menu is redundant.
2025-11-26 12:03:09 +01:00
Anthony LC 29104dfe2d 💥(frontend) upgrade to ui-kit v2
Upgrade Docs to Ui-Kit v2 and apply new color
scheme from LaSuite design system.
This commit will probably create breaking changes if
user has custom styles applied to their docs.
2025-11-26 11:32:18 +01:00
Anthony LC 785c9b21cf ️(sw) stop to cache external resources likes videos
Some videos from external sources can be very
large and slow to cache. To improve performance, we
decided to stop caching these resources in the
service worker.
We will cache only images and fonts from external
sources.
The videos will maybe not be available when offline
mode.
2025-11-26 10:27:15 +01:00
Cyril 3fee1f2081 (frontend) prevent duplicate emoji when used as first char in title
ensures icon and title are visually distinct in sub-document headers

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-25 15:38:30 +01:00
Cyril 5f9968d81e (frontend) improve screen reader support in DocShare modal
adds relevant aria-labels to enhance accessibility for assistive technologies

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-25 15:02:36 +01:00
Anthony LC f7baf238e3 🐛(frontend) fix toolbar not activated when reader
When user was a reader of the document, the toolbar
of the BlockNote editor was not activated,
making it impossible to download resources like images.
We add the toolbar even in viewer mode.
We block as well automatic document mutation
from custom blocks when the editor is in viewer mode
to avoid unwanted modifications.
2025-11-25 12:58:26 +01:00
renovate[bot] bab42efd08 ⬆️(dependencies) update @sentry/nextjs to v10.27.0 [SECURITY] 2025-11-24 21:59:02 +00:00
Jacques ROUSSEL 175d80db16 ♻️(helmfile) rename helmfile
Rename helmfile to helmfile.yaml.gotmpl to be compatible with helmfile
0.162 and 1.1.9
2025-11-20 15:13:55 +01:00
Manuel Raynaud f8b8390758 ♻️(backend) UserSerializer fallback strategy from UserLightSerializer
In the UserLightSerializer we were fallbacking on a strategy to never
have a full_name or short_name empty. We use the part of the email
befire the @. We are doing the same thing now in the main
UserSerializer.
2025-11-20 14:41:48 +01:00
Anthony LC a1463e0a10 🐛(frontend) fix button markdown not visible
On smaller screens, the markdown button in the
toolbar was not every time visible.
We fix this issue.
2025-11-20 14:41:48 +01:00
Anthony LC 0b555eed9f ♻️(frontend) replace default comment toolbar button
Replace the default comment toolbar button with
a custom one to follow the design system.
2025-11-20 14:41:47 +01:00
Anthony LC 1bf810d596 ♻️(frontend) add user avatar to thread comments
We extracted the UserAvatar component from the
doc-share feature and integrated it into
the users feature. It will be used in the
thread comments feature as well.
2025-11-20 14:41:47 +01:00
Anthony LC 48e1370ba3 (frontend) add comments feature
Implemented the comments feature for the document
editor.
We are now able to add, view, and manage comments
within the document editor interface.
2025-11-20 14:41:47 +01:00
Anthony LC b13571c6df (backend) implement thread and reactions API
In order to use comment we also have to implement a thread and reactions
API. A thread has multiple comments and comments can have multiple
reactions.
2025-11-20 14:41:47 +01:00
Manuel Raynaud a2a63cd13e (backend) add comment viewset
This commit add the CRUD part to manage comment lifeycle. Permissions
are relying on the Document and Comment abilities. Comment viewset
depends on the Document route and is added to the
document_related_router. Dedicated serializer and permission are
created.
2025-11-20 14:20:06 +01:00
Manuel Raynaud 3ebb62d786 (backend) add Comment model
In order to store the comments on a document, we created a new model
Comment. User is nullable because anonymous users can comment a Document
is this one is public with a link_role commentator.
2025-11-20 14:20:06 +01:00
Manuel Raynaud 0caee61d86 (backend) add commentator role
To allow a user to comment a document we added a new role: commentator.
Commentator is higher than reader but lower than editor.
2025-11-20 14:20:06 +01:00
Cyril 10a319881d (frontend) preserve left panel width on window resize
prevents automatic resizing to keep user-defined width stable

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-20 10:07:17 +01:00
Cyril 26620f3471 (frontend) improve share modal button accessibility
Added aria-labels to remove and invite buttons

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-20 08:28:18 +01:00
178 changed files with 15889 additions and 3925 deletions
+3 -3
View File
@@ -21,10 +21,10 @@ jobs:
shell: bash
run: |
set -e
HELMFILE=src/helm/helmfile.yaml
HELMFILE=src/helm/helmfile.yaml.gotmpl
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
helmfile -e $env lint -f $HELMFILE || exit 1
echo -e "\n"
done
done
+5 -5
View File
@@ -19,7 +19,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
@@ -46,7 +46,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -70,7 +70,7 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install codespell
run: pip install --user codespell
- name: Check for typos
@@ -92,7 +92,7 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
+21 -1
View File
@@ -6,12 +6,33 @@ and this project adheres to
## [Unreleased]
### Added
- ✨ Add comments feature to the editor #1330
- ✨(backend) Comments on text editor #1330
### Changed
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2
- ⚡️(frontend) improve perf on upload and table of contents #1662
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
- 🐛(frontend) fix toolbar not activated when reader #1640
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
## [3.10.0] - 2025-11-18
### Added
- ✨(export) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
- ✅(e2e) add test to compare generated PDF against reference template #1648
### Changed
@@ -40,7 +61,6 @@ and this project adheres to
- 🔥(backend) remove api managing templates
## [3.9.0] - 2025-11-10
### Added
+4 -4
View File
@@ -32,9 +32,9 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
----
# **Your logo** 📝
# **Your Docs icon** 📝
You can add your own logo in the header from the theme customization file.
You can add your own Docs icon in the header from the theme customization file.
### Settings 🔧
@@ -44,9 +44,9 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
### Example of JSON
You can activate it with the `header.logo` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
This configuration is optional. If not set, the default logo will be used.
This configuration is optional. If not set, the default icon will be used.
----
+16
View File
@@ -171,3 +171,19 @@ class ResourceAccessPermission(IsAuthenticated):
action = view.action
return abilities.get(action, False)
class CommentPermission(permissions.BasePermission):
"""Permission class for comments."""
def has_permission(self, request, view):
"""Check permission for a given object."""
if view.action in ["create", "list"]:
document_abilities = view.get_document_or_404().get_abilities(request.user)
return document_abilities["comment"]
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
+133 -11
View File
@@ -1,4 +1,5 @@
"""Client serializers for the impress core app."""
# pylint: disable=too-many-lines
import binascii
import mimetypes
@@ -24,22 +25,13 @@ from core.services.converter_services import (
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
def get_full_name(self, instance):
"""Return the full name of the user."""
@@ -58,6 +50,15 @@ class UserLightSerializer(UserSerializer):
return instance.short_name
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
@@ -891,3 +892,124 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)
class ReactionSerializer(serializers.ModelSerializer):
"""Serialize reactions."""
users = UserLightSerializer(many=True, read_only=True)
class Meta:
model = models.Reaction
fields = [
"id",
"emoji",
"created_at",
"users",
]
read_only_fields = ["id", "created_at", "users"]
class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments (nested under a thread) with reactions and abilities."""
user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField()
reactions = ReactionSerializer(many=True, read_only=True)
class Meta:
model = models.Comment
fields = [
"id",
"user",
"body",
"created_at",
"updated_at",
"reactions",
"abilities",
]
read_only_fields = [
"id",
"user",
"created_at",
"updated_at",
"reactions",
"abilities",
]
def validate(self, attrs):
"""Validate comment data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["thread_id"] = self.context["thread_id"]
attrs["user_id"] = user.id if user else None
return attrs
def get_abilities(self, obj):
"""Return comment's abilities."""
request = self.context.get("request")
if request:
return obj.get_abilities(request.user)
return {}
class ThreadSerializer(serializers.ModelSerializer):
"""Serialize threads in a backward compatible shape for current frontend.
We expose a flatten representation where ``content`` maps to the first
comment's body. Creating a thread requires a ``content`` field which is
stored as the first comment.
"""
creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
body = serializers.JSONField(write_only=True, required=True)
comments = serializers.SerializerMethodField(read_only=True)
comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = models.Thread
fields = [
"id",
"body",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
def validate(self, attrs):
"""Validate thread data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["document_id"] = self.context["resource_id"]
attrs["creator_id"] = user.id if user else None
return attrs
def get_abilities(self, thread):
"""Return thread's abilities."""
request = self.context.get("request")
if request:
return thread.get_abilities(request.user)
return {}
+130
View File
@@ -21,6 +21,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -2150,3 +2151,132 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
class CommentViewSetMixin:
"""Comment ViewSet Mixin."""
_document = None
def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"],
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document
class ThreadViewSet(
ResourceAccessViewsetMixin,
CommentViewSetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Thread API: list/create threads and nested comment operations."""
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.ThreadSerializer
queryset = models.Thread.objects.select_related("creator", "document").filter(
resolved=False
)
resource_field_name = "document"
def perform_create(self, serializer):
"""Create the first comment of the thread."""
body = serializer.validated_data["body"]
del serializer.validated_data["body"]
thread = serializer.save()
models.Comment.objects.create(
thread=thread,
user=self.request.user if self.request.user.is_authenticated else None,
body=body,
)
@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
def resolve(self, request, *args, **kwargs):
"""Resolve a thread."""
thread = self.get_object()
if not thread.resolved:
thread.resolved = True
thread.resolved_at = timezone.now()
thread.resolved_by = request.user
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
class CommentViewSet(
CommentViewSetMixin,
viewsets.ModelViewSet,
):
"""Comment API: list/create comments and nested reaction operations."""
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.CommentSerializer
queryset = models.Comment.objects.select_related("user").all()
def get_queryset(self):
"""Override to filter on related resource."""
return (
super()
.get_queryset()
.filter(
thread=self.kwargs["thread_id"],
thread__document=self.kwargs["resource_id"],
)
)
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["document_id"] = self.kwargs["resource_id"]
context["thread_id"] = self.kwargs["thread_id"]
return context
@drf.decorators.action(
detail=True,
methods=["post", "delete"],
)
def reactions(self, request, *args, **kwargs):
"""POST: add reaction; DELETE: remove reaction.
Emoji is expected in request.data['emoji'] for both operations.
"""
comment = self.get_object()
serializer = serializers.ReactionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if request.method == "POST":
reaction, created = models.Reaction.objects.get_or_create(
comment=comment,
emoji=serializer.validated_data["emoji"],
)
if not created and reaction.users.filter(id=request.user.id).exists():
return drf.response.Response(
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
)
reaction.users.add(request.user)
return drf.response.Response(status=status.HTTP_201_CREATED)
# DELETE
try:
reaction = models.Reaction.objects.get(
comment=comment,
emoji=serializer.validated_data["emoji"],
users__in=[request.user],
)
except models.Reaction.DoesNotExist as e:
raise drf.exceptions.NotFound("Reaction not found.") from e
reaction.users.remove(request.user)
if not reaction.users.exists():
reaction.delete()
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
+2
View File
@@ -33,6 +33,7 @@ class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
@@ -40,6 +41,7 @@ class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
+46
View File
@@ -256,3 +256,49 @@ class InvitationFactory(factory.django.DjangoModelFactory):
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
class ThreadFactory(factory.django.DjangoModelFactory):
"""A factory to create threads for a document"""
class Meta:
model = models.Thread
document = factory.SubFactory(DocumentFactory)
creator = factory.SubFactory(UserFactory)
class CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a thread"""
class Meta:
model = models.Comment
thread = factory.SubFactory(ThreadFactory)
user = factory.SubFactory(UserFactory)
body = factory.Faker("text")
class ReactionFactory(factory.django.DjangoModelFactory):
"""A factory to create reactions for a comment"""
class Meta:
model = models.Reaction
comment = factory.SubFactory(CommentFactory)
emoji = "test"
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to reaction from a given list of users or create one if not provided."""
if not create:
return
if not extracted:
# the factory is being created, but no users were provided
user = UserFactory()
self.users.add(user)
return
# Add the iterable of groups using bulk addition
self.users.add(*extracted)
@@ -0,0 +1,275 @@
# Generated by Django 5.2.6 on 2025-09-16 08:59
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0025_alter_user_short_name"),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.CreateModel(
name="Thread",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("resolved", models.BooleanField(default=False)),
("resolved_at", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"creator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="threads",
to=settings.AUTH_USER_MODEL,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="threads",
to="core.document",
),
),
(
"resolved_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="resolved_threads",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Thread",
"verbose_name_plural": "Threads",
"db_table": "impress_thread",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="Comment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("body", models.JSONField()),
("metadata", models.JSONField(blank=True, default=dict)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thread_comment",
to=settings.AUTH_USER_MODEL,
),
),
(
"thread",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.thread",
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("created_at",),
},
),
migrations.CreateModel(
name="Reaction",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("emoji", models.CharField(max_length=32)),
(
"comment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reactions",
to="core.comment",
),
),
(
"users",
models.ManyToManyField(
related_name="reactions", to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Reaction",
"verbose_name_plural": "Reactions",
"db_table": "impress_comment_reaction",
"constraints": [
models.UniqueConstraint(
fields=("comment", "emoji"),
name="unique_comment_emoji",
violation_error_message="This emoji has already been reacted to this comment.",
)
],
},
),
]
+155 -1
View File
@@ -756,6 +756,7 @@ class Document(MP_Node, BaseModel):
can_update = (
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
can_comment = (can_update or role == RoleChoices.COMMENTER) and not is_deleted
can_create_children = can_update and user.is_authenticated
can_destroy = (
is_owner
@@ -786,6 +787,7 @@ class Document(MP_Node, BaseModel):
"children_list": can_get,
"children_create": can_create_children,
"collaboration_auth": can_get,
"comment": can_comment,
"content": can_get,
"cors_proxy": can_get,
"descendants": can_get,
@@ -1146,7 +1148,12 @@ class DocumentAccess(BaseAccess):
set_role_to = []
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
[
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
@@ -1270,6 +1277,153 @@ class DocumentAskForAccess(BaseModel):
self.document.send_email(subject, [email], context, language)
class Thread(BaseModel):
"""Discussion thread attached to a document.
A thread groups one or many comments. For backward compatibility with the
existing frontend (useComments hook) we still expose a flattened serializer
that returns a "content" field representing the first comment's body.
"""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="threads",
)
creator = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="threads",
null=True,
blank=True,
)
resolved = models.BooleanField(default=False)
resolved_at = models.DateTimeField(null=True, blank=True)
resolved_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="resolved_threads",
null=True,
blank=True,
)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "impress_thread"
ordering = ("-created_at",)
verbose_name = _("Thread")
verbose_name_plural = _("Threads")
def __str__(self):
author = self.creator or _("Anonymous")
return f"Thread by {author!s} on {self.document!s}"
def get_abilities(self, user):
"""Compute and return abilities for a given user (mirrors comment logic)."""
role = self.document.get_role(user)
doc_abilities = self.document.get_abilities(user)
read_access = doc_abilities.get("comment", False)
write_access = self.creator == user or role in [
RoleChoices.OWNER,
RoleChoices.ADMIN,
]
return {
"destroy": write_access,
"update": write_access,
"partial_update": write_access,
"resolve": write_access,
"retrieve": read_access,
}
@property
def first_comment(self):
"""Return the first createdcomment of the thread."""
return self.comments.order_by("created_at").first()
class Comment(BaseModel):
"""A comment belonging to a thread."""
thread = models.ForeignKey(
Thread,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="thread_comment",
null=True,
blank=True,
)
body = models.JSONField()
metadata = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "impress_comment"
ordering = ("created_at",)
verbose_name = _("Comment")
verbose_name_plural = _("Comments")
def __str__(self):
"""Return the string representation of the comment."""
author = self.user or _("Anonymous")
return f"Comment by {author!s} on thread {self.thread_id}"
def get_abilities(self, user):
"""Return the abilities of the comment."""
role = self.thread.document.get_role(user)
doc_abilities = self.thread.document.get_abilities(user)
read_access = doc_abilities.get("comment", False)
can_react = read_access and user.is_authenticated
write_access = self.user == user or role in [
RoleChoices.OWNER,
RoleChoices.ADMIN,
]
return {
"destroy": write_access,
"update": write_access,
"partial_update": write_access,
"reactions": can_react,
"retrieve": read_access,
}
class Reaction(BaseModel):
"""Aggregated reactions for a given emoji on a comment.
We store one row per (comment, emoji) and maintain the list of user IDs who
reacted with that emoji. This matches the frontend interface where a
reaction exposes: emoji, createdAt (first reaction date) and userIds.
"""
comment = models.ForeignKey(
Comment,
on_delete=models.CASCADE,
related_name="reactions",
)
emoji = models.CharField(max_length=32)
users = models.ManyToManyField(User, related_name="reactions")
class Meta:
db_table = "impress_comment_reaction"
constraints = [
models.UniqueConstraint(
fields=["comment", "emoji"],
name="unique_comment_emoji",
violation_error_message=_(
"This emoji has already been reacted to this comment."
),
),
]
verbose_name = _("Reaction")
verbose_name_plural = _("Reactions")
def __str__(self):
"""Return the string representation of the reaction."""
return f"Reaction {self.emoji} on comment {self.comment.id}"
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""
@@ -293,6 +293,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
}
assert result_dict[str(document_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
@@ -301,7 +302,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
# Add an access for the other user on the parent
parent_access_other_user = factories.UserDocumentAccessFactory(
document=parent, user=other_user, role="editor"
document=parent, user=other_user, role="commenter"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
@@ -314,6 +315,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"commenter",
"editor",
"administrator",
"owner",
@@ -321,6 +323,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
@@ -333,28 +336,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
],
@@ -415,44 +418,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "editor", "administrator"],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
],
@@ -460,7 +463,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["editor", "editor", "administrator", "editor"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
["editor", "administrator"],
[],
@@ -360,6 +360,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
@@ -480,6 +481,7 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
@@ -0,0 +1,878 @@
"""Test API for comments on documents."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# List comments
def test_list_comments_anonymous_user_public_document():
"""Anonymous users should be allowed to list comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(AnonymousUser()),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(AnonymousUser()),
"reactions": [],
},
],
}
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
def test_list_comments_anonymous_user_non_public_document(link_reach):
"""Anonymous users should not be allowed to list comments on a non-public document."""
document = factories.DocumentFactory(
link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 401
def test_list_comments_authenticated_user_accessible_document():
"""Authenticated users should be allowed to list comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment1 = factories.CommentFactory(thread=thread)
comment2 = factories.CommentFactory(thread=thread, user=user)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(user),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(user),
"reactions": [],
},
],
}
def test_list_comments_authenticated_user_non_accessible_document():
"""Authenticated users should not be allowed to list comments on a non-accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
def test_list_comments_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to list comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
# Create comment
def test_create_comment_anonymous_user_public_document():
"""
Anonymous users should be allowed to create comments on a public document
with commenter link_role.
"""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": None,
"abilities": {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to create comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 401
def test_create_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to create comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": {
"full_name": user.full_name,
"short_name": user.short_name,
},
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to create comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 403
# Retrieve comment
def test_retrieve_comment_anonymous_user_public_document():
"""Anonymous users should be allowed to retrieve comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(comment.id),
"body": comment.body,
"created_at": comment.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment.user.full_name,
"short_name": comment.user.short_name,
},
"reactions": [],
"abilities": comment.get_abilities(AnonymousUser()),
}
def test_retrieve_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to retrieve comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_retrieve_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to retrieve comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
def test_retrieve_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to retrieve comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Update comment
def test_update_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to update comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to update comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_authenticated_user_accessible_document():
"""Authenticated users should not be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_user_own_comment():
"""Authenticated users should be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
def test_update_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_no_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
# Delete comment
def test_delete_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to delete comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to delete comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_authenticated_user_accessible_document_own_comment():
"""Authenticated users should be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_accessible_document_not_own_comment():
"""Authenticated users should not be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be able to delete comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Create reaction
@pytest.mark.parametrize("link_role", models.LinkRoleChoices.values)
def test_create_reaction_anonymous_user_public_document(link_role):
"""No matter the link_role, an anonymous user can not react to a comment."""
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 401
def test_create_reaction_authenticated_user_public_document():
"""
Authenticated users should not be able to reaction to a comment on a public document with
link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_accessible_public_document():
"""
Authenticated users should be able to react to a comment on a public document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_connected_document_link_role_reader():
"""
Authenticated users should not be able to react to a comment on a connected document
with link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"link_role",
[
role
for role in models.LinkRoleChoices.values
if role != models.LinkRoleChoices.READER
],
)
def test_create_reaction_authenticated_user_connected_document(link_role):
"""
Authenticated users should be able to react to a comment on a connected document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=link_role
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_restricted_accessible_document():
"""
Authenticated users should not be able to react to a comment on a restricted accessible document
they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_restricted_accessible_document_role_reader():
"""
Authenticated users should not be able to react to a comment on a restricted accessible
document with role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"role",
[role for role in models.RoleChoices.values if role != models.RoleChoices.READER],
)
def test_create_reaction_authenticated_user_restricted_accessible_document_role_commenter(
role,
):
"""
Authenticated users should be able to react to a comment on a restricted accessible document
with role commenter.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 400
assert response.json() == {"user_already_reacted": True}
# Delete reaction
def test_delete_reaction_not_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
def test_delete_reaction_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
reaction.refresh_from_db()
assert reaction.users.exists()
@@ -36,6 +36,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"cors_proxy": True,
"content": True,
"descendants": True,
@@ -46,8 +47,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": False,
@@ -113,6 +114,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -220,6 +222,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -229,8 +232,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -304,6 +307,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -494,13 +498,14 @@ def test_api_documents_retrieve_authenticated_related_parent():
"abilities": {
"accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True,
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"can_edit": access.role != "reader",
"children_create": access.role != "reader",
"ai_transform": access.role not in ["reader", "commenter"],
"ai_translate": access.role not in ["reader", "commenter"],
"attachment_upload": access.role not in ["reader", "commenter"],
"can_edit": access.role not in ["reader", "commenter"],
"children_create": access.role not in ["reader", "commenter"],
"children_list": True,
"collaboration_auth": True,
"comment": access.role != "reader",
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -516,11 +521,11 @@ def test_api_documents_retrieve_authenticated_related_parent():
"media_auth": True,
"media_check": True,
"move": access.role in ["administrator", "owner"],
"partial_update": access.role != "reader",
"partial_update": access.role not in ["reader", "commenter"],
"restore": access.role == "owner",
"retrieve": True,
"tree": True,
"update": access.role != "reader",
"update": access.role not in ["reader", "commenter"],
"versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True,
"versions_retrieve": True,
File diff suppressed because it is too large Load Diff
@@ -81,6 +81,7 @@ def test_api_documents_trashbin_format():
"collaboration_auth": False,
"descendants": False,
"cors_proxy": False,
"comment": False,
"content": False,
"destroy": False,
"duplicate": False,
@@ -88,8 +89,8 @@ def test_api_documents_trashbin_format():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": False,
+29
View File
@@ -278,6 +278,35 @@ def test_api_users_retrieve_me_authenticated():
}
def test_api_users_retrieve_me_authenticated_empty_name():
"""
Authenticated users should be able to retrieve their own user via the "/users/me" path.
when no name is provided, the full name and short name should be the email without the domain.
"""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name=None,
)
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(user.id),
"email": "test_foo@test.com",
"full_name": "test_foo",
"language": user.language,
"short_name": "test_foo",
}
def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
@@ -0,0 +1,283 @@
"""Test the comment model."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from core import factories
from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"role,can_comment",
[
(LinkRoleChoices.READER, False),
(LinkRoleChoices.COMMENTER, True),
(LinkRoleChoices.EDITOR, True),
],
)
def test_comment_get_abilities_anonymous_user_public_document(role, can_comment):
"""Anonymous users cannot comment on a document."""
document = factories.DocumentFactory(
link_role=role, link_reach=LinkReachChoices.PUBLIC
)
comment = factories.CommentFactory(thread__document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED]
)
def test_comment_get_abilities_anonymous_user_restricted_document(link_reach):
"""Anonymous users cannot comment on a restricted document."""
document = factories.DocumentFactory(link_reach=link_reach)
comment = factories.CommentFactory(thread__document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": False,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment):
"""Readers cannot comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader_own_comment(
link_role, link_reach, can_comment
):
"""User with reader role on a document has all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(
thread__document=document, user=user if can_comment else None
)
assert comment.get_abilities(user) == {
"destroy": can_comment,
"update": can_comment,
"partial_update": can_comment,
"reactions": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commenter(link_role, link_reach):
"""Commenters can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTER)],
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commenter_own_comment(link_role, link_reach):
"""Commenters have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTER)],
)
comment = factories.CommentFactory(thread__document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor(link_role, link_reach):
"""Editors can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach):
"""Editors have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(thread__document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
def test_comment_get_abilities_user_admin():
"""Admins have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)])
comment = factories.CommentFactory(
thread__document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
def test_comment_get_abilities_user_owner():
"""Owners have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)])
comment = factories.CommentFactory(
thread__document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
@@ -123,7 +123,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -166,7 +166,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -183,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -200,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -217,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -234,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -271,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}
@@ -288,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}
@@ -305,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}
+200 -28
View File
@@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth):
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(True, "restricted", "commenter"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "restricted", "commenter"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
(False, "authenticated", "commenter"),
],
)
def test_models_documents_get_abilities_forbidden(
@@ -165,6 +168,7 @@ def test_models_documents_get_abilities_forbidden(
"destroy": False,
"duplicate": False,
"favorite": False,
"comment": False,
"invite_owner": False,
"mask": False,
"media_auth": False,
@@ -172,8 +176,8 @@ def test_models_documents_get_abilities_forbidden(
"move": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"partial_update": False,
@@ -223,6 +227,7 @@ def test_models_documents_get_abilities_reader(
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -232,8 +237,78 @@ def test_models_documents_get_abilities_reader(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definition"]
)
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_commenter(
is_authenticated, reach, django_assert_num_queries
):
"""
Check abilities returned for a document giving commenter role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="commenter")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"content": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": is_authenticated,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -289,6 +364,7 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -298,8 +374,8 @@ def test_models_documents_get_abilities_editor(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -344,6 +420,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -353,8 +430,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -385,6 +462,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"comment": False,
"descendants": False,
"cors_proxy": False,
"content": False,
@@ -394,8 +472,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": False,
@@ -430,6 +508,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -439,8 +518,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"invite_owner": False,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -485,6 +564,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -494,8 +574,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -547,6 +627,8 @@ def test_models_documents_get_abilities_reader_user(
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_reach != "restricted"
and document.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -556,8 +638,73 @@ def test_models_documents_get_abilities_reader_user(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": access_from_link,
"restore": False,
"retrieve": True,
"tree": True,
"update": access_from_link,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definition"]
)
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
def test_models_documents_get_abilities_commenter_user(
ai_access_setting, django_assert_num_queries
):
"""Check abilities returned for the commenter of a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "commenter")])
access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor"
)
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"content": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -607,6 +754,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -616,8 +764,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -1320,7 +1468,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
"reader",
{
"public": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"public",
"commenter",
{
"public": ["commenter", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
@@ -1328,8 +1483,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"authenticated",
"reader",
{
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"authenticated",
"commenter",
{
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
},
),
(
@@ -1342,8 +1505,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"reader",
{
"restricted": None,
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"restricted",
"commenter",
{
"restricted": None,
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
},
),
(
@@ -1360,15 +1532,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
None,
{
"public": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
None,
"reader",
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"restricted": None,
},
),
@@ -1376,8 +1548,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
None,
None,
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"restricted": None,
},
),
+16 -1
View File
@@ -26,13 +26,24 @@ document_related_router.register(
viewsets.InvitationViewset,
basename="invitations",
)
document_related_router.register(
"threads",
viewsets.ThreadViewSet,
basename="threads",
)
document_related_router.register(
"ask-for-access",
viewsets.DocumentAskForAccessViewSet,
basename="ask_for_access",
)
thread_related_router = DefaultRouter()
thread_related_router.register(
"comments",
viewsets.CommentViewSet,
basename="comments",
)
urlpatterns = [
path(
@@ -45,6 +56,10 @@ urlpatterns = [
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(document_related_router.urls),
),
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
include(thread_related_router.urls),
),
]
),
),
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

@@ -0,0 +1,295 @@
import { expect, test } from '@playwright/test';
import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common';
import { writeInEditor } from './utils-editor';
import {
addNewMember,
connectOtherUserToDoc,
updateRoleUser,
updateShareLink,
} from './utils-share';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Comments', () => {
test('it checks comments with 2 users in real time', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1);
// We share the doc with another user
const otherBrowserName = getOtherBrowserName(browserName);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Administrator', otherBrowserName);
await expect(
page
.getByRole('listbox', { name: 'Suggestions' })
.getByText(new RegExp(otherBrowserName)),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
// We add a comment with the first user
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
await editor.getByText('Hello').click();
await thread.getByText('This is a comment').first().hover();
// We add a reaction with the first user
await thread.locator('[data-test="addreaction"]').first().click();
await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1');
const urlCommentDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
otherBrowserName,
docUrl: urlCommentDoc,
docTitle,
});
const otherEditor = otherPage.locator('.ProseMirror');
await otherEditor.getByText('Hello').click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread.getByText('This is a comment').first().hover();
await otherThread.locator('[data-test="addreaction"]').first().click();
await otherPage.getByRole('button', { name: '👍' }).click();
// We check that the comment made by the first user is visible for the second user
await expect(
otherThread.getByText('This is a comment').first(),
).toBeVisible();
await expect(
otherThread.getByText(`E2E ${browserName}`).first(),
).toBeVisible();
await expect(otherThread.locator('.bn-comment-reaction')).toHaveText('👍2');
// We add a comment with the second user
await otherThread
.getByRole('paragraph')
.last()
.fill('This is a comment from the other user');
await otherThread.locator('[data-test="save"]').click();
// We check that the second user can see the comment he just made
await expect(
otherThread.getByRole('img', { name: `E2E ${otherBrowserName}` }).first(),
).toBeVisible();
await expect(
otherThread.getByText('This is a comment from the other user').first(),
).toBeVisible();
await expect(
otherThread.getByText(`E2E ${otherBrowserName}`).first(),
).toBeVisible();
// We check that the first user can see the comment made by the second user in real time
await expect(
thread.getByText('This is a comment from the other user').first(),
).toBeVisible();
await expect(
thread.getByText(`E2E ${otherBrowserName}`).first(),
).toBeVisible();
await cleanup();
});
test('it checks the comments interactions', async ({ page, browserName }) => {
await createDoc(page, 'comment-interaction', browserName, 1);
// Checks add react reaction
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('Hello World');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Check background color changed
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor.getByText('Hello').click();
await thread.getByText('This is a comment').first().hover();
// We add a reaction with the first user
await thread.locator('[data-test="addreaction"]').first().click();
await page.getByRole('button', { name: '👍' }).click();
await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1');
// Edit Comment
await thread.getByText('This is a comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
const commentEditor = thread.getByText('This is a comment').first();
await commentEditor.fill('This is an edited comment');
const saveBtn = thread.getByRole('button', { name: 'Save' });
await saveBtn.click();
await expect(saveBtn).toBeHidden();
await expect(
thread.getByText('This is an edited comment').first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Add second comment
await thread.getByRole('paragraph').last().fill('This is a second comment');
await thread.getByRole('button', { name: 'Save' }).click();
await expect(
thread.getByText('This is an edited comment').first(),
).toBeVisible();
await expect(
thread.getByText('This is a second comment').first(),
).toBeVisible();
// Delete second comment
await thread.getByText('This is a second comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(
thread.getByText('This is a second comment').first(),
).toBeHidden();
// Resolve thread
await thread.getByText('This is an edited comment').first().hover();
await thread.locator('[data-test="resolve"]').click();
await expect(thread).toBeHidden();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
});
test('it checks the comments abilities', async ({ page, browserName }) => {
test.slow();
const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1);
// We share the doc with another user
const otherBrowserName = getOtherBrowserName(browserName);
// Add a new member with editor role
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor', otherBrowserName);
await expect(
page
.getByRole('listbox', { name: 'Suggestions' })
.getByText(new RegExp(otherBrowserName)),
).toBeVisible();
const urlCommentDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
otherBrowserName,
docUrl: urlCommentDoc,
docTitle,
});
const otherEditor = await writeInEditor({
page: otherPage,
text: 'Hello, I can edit the document',
});
await expect(
otherEditor.getByText('Hello, I can edit the document'),
).toBeVisible();
await otherEditor.getByText('Hello').selectText();
await otherPage.getByRole('button', { name: 'Comment' }).click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread
.getByRole('paragraph')
.first()
.fill('I can add a comment');
await otherThread.locator('[data-test="save"]').click();
await expect(
otherThread.getByText('I can add a comment').first(),
).toBeHidden();
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
// We change the role of the second user to reader
await updateRoleUser(page, 'Reader', `user.test@${otherBrowserName}.test`);
// With the reader role, the second user cannot see comments
await otherPage.reload();
await verifyDocName(otherPage, docTitle);
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await otherEditor.getByText('Hello').click();
await expect(otherThread).toBeHidden();
await otherEditor.getByText('Hello').selectText();
await expect(
otherPage.getByRole('button', { name: 'Comment' }),
).toBeHidden();
await otherPage.reload();
// Change the link role of the doc to set it in commenting mode
await updateShareLink(page, 'Public', 'Editing');
// Anonymous user can see and add comments
await otherPage.getByRole('button', { name: 'Logout' }).click();
await otherPage.goto(urlCommentDoc);
await verifyDocName(otherPage, docTitle);
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await otherEditor.getByText('Hello').click();
await expect(
otherThread.getByText('I can add a comment').first(),
).toBeVisible();
await otherThread
.locator('.ProseMirror.bn-editor[contenteditable="true"]')
.getByRole('paragraph')
.first()
.fill('Comment by anonymous user');
await otherThread.locator('[data-test="save"]').click();
await expect(
otherThread.getByText('Comment by anonymous user').first(),
).toBeVisible();
await expect(
otherThread.getByRole('img', { name: `Anonymous` }).first(),
).toBeVisible();
await otherThread.getByText('Comment by anonymous user').first().hover();
await expect(otherThread.locator('[data-test="moreactions"]')).toBeHidden();
await cleanup();
});
});
@@ -24,15 +24,13 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
test('it checks default toolbar buttons are displayed', async ({
test('it checks toolbar buttons are displayed', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('test content');
const editor = await writeInEditor({ page, text: 'test content' });
await editor
.getByText('test content', {
@@ -41,6 +39,9 @@ test.describe('Doc Editor', () => {
.selectText();
const toolbar = page.locator('.bn-formatting-toolbar');
await expect(
toolbar.locator('button[data-test="comment-toolbar-button"]'),
).toBeVisible();
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
await expect(toolbar.locator('button[data-test="italic"]')).toBeVisible();
await expect(
@@ -63,6 +64,53 @@ test.describe('Doc Editor', () => {
await expect(
toolbar.locator('button[data-test="createLink"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="ai-actions"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="convertMarkdown"]'),
).toBeVisible();
await page.keyboard.press('Escape');
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toHaveAttribute('role', 'presentation');
await image.dblclick();
await expect(
toolbar.locator('button[data-test="comment-toolbar-button"]'),
).toBeHidden();
await expect(
toolbar.locator('button[data-test="ai-actions"]'),
).toBeHidden();
await expect(
toolbar.locator('button[data-test="convertMarkdown"]'),
).toBeHidden();
await expect(
toolbar.locator('button[data-test="editcaption"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="downloadfile"]'),
).toBeVisible();
});
/**
@@ -241,20 +289,66 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
user_role: 'reader',
test('it cannot edit if viewer but see and can get resources', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello World' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl: page.url(),
withoutSignIn: true,
docTitle,
});
await goToGridDoc(page);
await expect(
otherPage.getByLabel('It is the card information').getByText('Reader'),
).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
const editor = page.locator('.ProseMirror');
// Cannot edit
const editor = otherPage.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
// Owner add a image
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
// Owner see the image
await expect(
page.locator('.--docs--editor-container img.bn-visual-media').first(),
).toBeVisible();
// Viewser see the image
const viewerImg = otherPage
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(viewerImg).toBeVisible();
// Viewer can download the image
await viewerImg.click();
const downloadPromise = otherPage.waitForEvent('download');
await otherPage.getByRole('button', { name: 'Download image' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('logo-suite-numerique.png');
await cleanup();
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
@@ -1,3 +1,4 @@
import fs from 'fs';
import path from 'path';
import { expect, test } from '@playwright/test';
@@ -13,6 +14,35 @@ import {
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
const REGRESSION_FIXTURE_CONTENT = fs.readFileSync(
path.join(__dirname, 'assets/content.txt'),
'utf-8',
);
const REGRESSION_SNAPSHOT_NAME = 'doc-export-regression.pdf';
const REGRESSION_DOC_TITLE = 'doc-export-regression-reference';
/**
* Playwright snapshots store the raw PDF bytes. However, each export embeds
* dynamic metadata (timestamps, font-subset identifiers, etc.) that would make
* the snapshot differ at every run. To ensure deterministic comparisons we
* strip/neutralize those fields before matching against the reference PDF.
*/
const sanitizePdfBuffer = (buffer: Buffer) => {
const pdfText = buffer.toString('latin1');
const neutralized = pdfText
// Remove per-export timestamps
.replace(/\/CreationDate\s*\(.*?\)/g, '/CreationDate ()')
.replace(/\/ModDate\s*\(.*?\)/g, '/ModDate ()')
// Remove file identifiers
.replace(/\/ID\s*\[<[^>]+>\s*<[^>]+>\]/g, '/ID [<0><0>]')
.replace(/D:\d{14}Z/g, 'D:00000000000000Z')
// Remove subset font prefixes generated by PDF renderer
.replace(/\b[A-Z]{6}\+(Inter18pt-[A-Za-z]+)\b/g, 'STATIC+$1')
.replace(/\b[A-Z]{6}\+(GeistMono-[A-Za-z]+)\b/g, 'STATIC+$1');
return Buffer.from(neutralized, 'latin1');
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -551,4 +581,118 @@ test.describe('Doc Export', () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
});
/**
* Regression guard for the full PDF export pipeline.
*
* Usage reminder:
* 1. `npx playwright test __tests__/app-impress/doc-export.spec.ts --update-snapshots -g "full document" --project=chromium`
* -> refresh the reference PDF whenever we intentionally change the export output.
* 2. `npx playwright test __tests__/app-impress/doc-export.spec.ts -g "full document" --project=chromium`
* -> CI (and local runs without --update-snapshots) will compare the PDF to the reference
* and fail on any byte-level difference once the dynamic metadata has been sanitized.
*/
test('it keeps the full document PDF export identical to the reference snapshot', async ({
page,
browserName,
}, testInfo) => {
// PDF generation for a large, image-heavy document can be slow in CI.
// Give this regression test a higher timeout budget than the default.
testInfo.setTimeout(120000);
const snapshotPath = testInfo.snapshotPath(REGRESSION_SNAPSHOT_NAME);
test.skip(
!fs.existsSync(snapshotPath) &&
testInfo.config.updateSnapshots === 'none',
`Missing PDF snapshot at ${snapshotPath}. Run Playwright with --update-snapshots to record it.`,
);
// We must use a deterministic title so that block content (and thus the
// exported PDF) stays identical between runs.
await createDoc(page, 'doc-export-regression', browserName, 1);
const titleInput = page.getByRole('textbox', { name: 'Document title' });
await expect(titleInput).toBeVisible();
await titleInput.fill(REGRESSION_DOC_TITLE);
await titleInput.blur();
await verifyDocName(page, REGRESSION_DOC_TITLE);
const regressionDoc = REGRESSION_DOC_TITLE;
const docId = page
.url()
.split('/docs/')[1]
?.split('/')
.filter(Boolean)
.shift();
expect(docId).toBeTruthy();
// Inject the pre-crafted blocknote document via the REST API to avoid
// rebuilding it through the UI (which would be slow and flaky).
const cookies = await page.context().cookies();
const csrfToken = cookies.find(
(cookie) => cookie.name === 'csrftoken',
)?.value;
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const updateResponse = await page.request.patch(
`http://localhost:8071/api/v1.0/documents/${docId}/`,
{
headers,
data: {
content: REGRESSION_FIXTURE_CONTENT,
websocket: true,
},
},
);
if (!updateResponse.ok()) {
throw new Error(
`Failed to seed document content. Status: ${updateResponse.status()}, body: ${await updateResponse.text()}`,
);
}
await page.reload();
// After reloading, just ensure the editor container is present before exporting.
await expect(page.locator('.--docs--editor-container')).toBeVisible({
timeout: 15000,
});
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeEnabled({
timeout: 15000,
});
// Export to PDF and confirm the generated bytes match the reference file.
const downloadPromise = page.waitForEvent('download', {
timeout: 60000,
predicate: (download) =>
download.suggestedFilename().includes(`${regressionDoc}.pdf`),
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${regressionDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const normalizedPdfBuffer = sanitizePdfBuffer(pdfBuffer);
expect(normalizedPdfBuffer).toMatchSnapshot(REGRESSION_SNAPSHOT_NAME);
});
});
@@ -208,7 +208,7 @@ test.describe('Doc Header', () => {
await expect(
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
const invitationRole = invitationCard.getByTestId('doc-role-dropdown');
await expect(invitationRole).toBeVisible();
await invitationRole.click();
@@ -217,7 +217,7 @@ test.describe('Doc Header', () => {
await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
const roles = memberCard.getByLabel('doc-role-dropdown');
const roles = memberCard.getByTestId('doc-role-dropdown');
await expect(memberCard).toBeVisible();
await expect(
memberCard.getByText('test.test@accesses.test').first(),
@@ -74,7 +74,7 @@ test.describe('Document create member', () => {
await expect(list.getByText(email)).toBeVisible();
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await list.getByTestId('doc-role-dropdown').click();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
@@ -84,7 +84,7 @@ test.describe('Document create member', () => {
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByTestId('doc-share-invite-button').click();
// Check invitation added
await expect(
@@ -128,14 +128,14 @@ 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 container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByTestId('doc-share-invite-button').click();
// Check invitation sent
@@ -146,7 +146,7 @@ test.describe('Document create member', () => {
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
@@ -154,7 +154,7 @@ test.describe('Document create member', () => {
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByTestId('doc-share-invite-button').click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
@@ -183,7 +183,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 container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
@@ -191,7 +191,7 @@ test.describe('Document create member', () => {
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByTestId('doc-share-invite-button').click();
// Check invitation sent
const responseCreateInvitation = await responsePromiseCreateInvitation;
@@ -210,18 +210,14 @@ test.describe('Document create member', () => {
response.request().method() === 'PATCH',
);
await userInvitation.getByLabel('doc-role-dropdown').click();
await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
const moreActions = userInvitation.getByRole('button', {
name: 'Open invitation actions menu',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
await expect(userInvitation).toBeHidden();
});
@@ -272,7 +268,7 @@ test.describe('Document create member', () => {
const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByLabel('doc-role-dropdown').click();
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click();
@@ -152,7 +152,7 @@ test.describe('Document list members', () => {
const currentUser = list.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await expect(currentUser).toBeVisible();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
@@ -169,7 +169,7 @@ test.describe('Document list members', () => {
});
const newUserEmail = await addNewMember(page, 0, 'Owner');
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
const newUserRoles = newUser.getByLabel('doc-role-dropdown');
const newUserRoles = newUser.getByTestId('doc-role-dropdown');
await expect(newUser).toBeVisible();
@@ -214,9 +214,7 @@ test.describe('Document list members', () => {
const emailMyself = `user.test@${browserName}.test`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfRole = mySelf.getByRole('button', {
name: 'doc-role-dropdown',
});
const mySelfRole = mySelf.getByTestId('doc-role-dropdown');
const userOwnerEmail = await addNewMember(page, 0, 'Owner');
const userOwner = list.getByTestId(
@@ -231,9 +229,7 @@ test.describe('Document list members', () => {
const userReader = list.getByTestId(
`doc-share-member-row-${userReaderEmail}`,
);
const userReaderRole = userReader.getByRole('button', {
name: 'doc-role-dropdown',
});
const userReaderRole = userReader.getByTestId('doc-role-dropdown');
await expect(mySelf).toBeVisible();
await expect(userOwner).toBeVisible();
@@ -226,7 +226,7 @@ test.describe('Doc Tree', () => {
const currentUser = list.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
@@ -156,9 +156,10 @@ test.describe('Header: Override configuration', () => {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
logo: {
icon: {
src: '/assets/logo-gouv.svg',
width: '220px',
height: 'auto',
alt: '',
},
},
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './utils-common';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
@@ -10,8 +10,12 @@ test.describe('Left panel desktop', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
await expect(page.getByTestId('home-button')).toBeVisible();
await expect(page.getByTestId('home-button')).toBeHidden();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
await goToGridDoc(page);
await expect(page.getByTestId('home-button')).toBeVisible();
});
test('checks resize handle is present and functional on document page', async ({
@@ -88,6 +92,9 @@ test.describe('Left panel mobile', () => {
await expect(languageButton).not.toBeInViewport();
await expect(logoutButton).not.toBeInViewport();
const title = await goToGridDoc(page);
await verifyDocName(page, title);
await header.getByLabel('Open the header menu').click();
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
@@ -70,6 +70,14 @@ export const keyCloakSignIn = async (
await page.click('button[type="submit"]', { force: true });
};
export const getOtherBrowserName = (browserName: BrowserName) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
return otherBrowserName;
};
export const randomName = (name: string, browserName: string, length: number) =>
Array.from({ length }, (_el, index) => {
return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`;
@@ -125,7 +133,9 @@ export const verifyDocName = async (page: Page, docName: string) => {
try {
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName);
).toContainText(docName, {
timeout: 1000,
});
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
}
@@ -1,8 +1,8 @@
import { Page, chromium, expect } from '@playwright/test';
import {
BROWSERS,
BrowserName,
getOtherBrowserName,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
@@ -38,9 +38,9 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByTestId('doc-share-invite-button').click();
return users[index].email;
};
@@ -74,7 +74,7 @@ export const updateRoleUser = async (
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: role }).click();
await list.click();
@@ -88,21 +88,30 @@ export const updateRoleUser = async (
* @param docTitle The title of the document (optional).
* @returns An object containing the other browser, context, and page.
*/
type ConnectOtherUserToDocParams = {
docUrl: string;
docTitle?: string;
withoutSignIn?: boolean;
} & (
| {
otherBrowserName: BrowserName;
browserName?: never;
}
| {
browserName: BrowserName;
otherBrowserName?: never;
}
);
export const connectOtherUserToDoc = async ({
browserName,
docUrl,
docTitle,
otherBrowserName: _otherBrowserName,
withoutSignIn,
}: {
browserName: BrowserName;
docUrl: string;
docTitle?: string;
withoutSignIn?: boolean;
}) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
}: ConnectOtherUserToDocParams) => {
const otherBrowserName =
_otherBrowserName || getOtherBrowserName(browserName);
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
+29
View File
@@ -0,0 +1,29 @@
//utilitary script to print the datauris of the targeted assets
const fs = require('fs');
const path = require('path');
const ASSETS_ROOT = path.resolve(__dirname, '__tests__/app-impress/assets');
function saveDataUrl(file, mime, outName) {
const abs = path.join(ASSETS_ROOT, file);
const base64 = fs.readFileSync(abs).toString('base64');
const dataUrl = `data:${mime};base64,${base64}`;
const outPath = path.join(ASSETS_ROOT, outName);
fs.writeFileSync(outPath, dataUrl, 'utf8');
console.log(`Wrote ${outName}`);
}
// PNG
saveDataUrl('panopng.png', 'image/png', 'pano-png-dataurl.txt');
// JPG
saveDataUrl('panojpg.jpeg', 'image/jpeg', 'pano-jpg-dataurl.txt');
// SVG
const svgPath = path.join(ASSETS_ROOT, 'test.svg');
const svgText = fs.readFileSync(svgPath, 'utf8');
const svgDataUrl =
'data:image/svg+xml;base64,' + Buffer.from(svgText).toString('base64');
fs.writeFileSync(path.join(ASSETS_ROOT, 'test-svg-dataurl.txt'), svgDataUrl, 'utf8');
console.log('Wrote test-svg-dataurl.txt');
+329 -207
View File
@@ -1,57 +1,25 @@
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
import { defaultTokens } from '@openfun/cunningham-react';
import merge from 'lodash/merge';
const customColors = {
'primary-action': '#1212FF',
'primary-bg': '#FAFAFA',
'primary-focus': '#0A76F6',
'secondary-icon': 'var(--c--theme--colors--primary-text)',
'blue-400': '#7AB1E8',
'blue-500': '#417DC4',
'blue-600': '#3558A2',
'brown-400': '#E6BE92',
'brown-500': '#BD987A',
'brown-600': '#745B47',
'cyan-400': '#34BAB5',
'cyan-500': '#009099',
'cyan-600': '#006A6F',
'gold-400': '#FFCA00',
'gold-500': '#C3992A',
'gold-600': '#695240',
'green-400': '#34CB6A',
'green-500': '#00A95F',
'green-600': '#297254',
'olive-400': '#99C221',
'olive-500': '#68A532',
'olive-600': '#447049',
'orange-400': '#FF732C',
'orange-500': '#E4794A',
'orange-600': '#755348',
'pink-400': '#FFB7AE',
'pink-500': '#E18B76',
'pink-600': '#8D533E',
'purple-400': '#CE70CC',
'purple-500': '#A558A0',
'purple-600': '#6E445A',
'yellow-400': '#D8C634',
'yellow-500': '#B7A73F',
'yellow-600': '#66673D',
// Uikit does not provide the full list of tokens.
// To be able to override correctly, we need to merge with the default tokens.
let mergedColors = merge(
defaultTokens.globals.colors,
tokens.themes.default.globals.colors,
);
mergedColors = {
...mergedColors,
'logo-1': '#2845C1',
};
tokens.themes.default.theme = {
...tokens.themes.default.theme,
tokens.themes.default.globals = {
...tokens.themes.default.globals,
...{
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
colors: {
...tokens.themes.default.theme.colors,
...customColors,
},
colors: mergedColors,
font: {
...tokens.themes.default.theme.font,
...tokens.themes.default.globals.font,
families: {
base: 'sans-serif',
accent: 'sans-serif',
@@ -63,49 +31,50 @@ tokens.themes.default.theme = {
tokens.themes.default.components = {
...tokens.themes.default.components,
...{
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'la-gaufre': false,
'home-proconnect': false,
'image-system-filter': '',
icon: {
src: '/assets/icon-docs.svg',
width: '32px',
height: 'auto',
},
favicon: {
ico: '/assets/favicon-light.ico',
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
button: {
...tokens.themes.default.components.button,
primary: {
...tokens.themes.default.components.button.primary,
...{
'background--disabled': 'var(--c--theme--colors--greyscale-100)',
},
disabled: 'var(--c--theme--colors--greyscale-400)',
},
},
},
};
const dsfrTheme = {
dsfr: {
theme: {
colors: {
'secondary-icon': '#C9191E',
globals: {
font: {
families: {
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
},
components: {
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
font: {
families: {
base: 'Marianne',
accent: 'Marianne',
},
},
},
components: {
'la-gaufre': true,
'home-proconnect': true,
icon: {
src: '/assets/icon-docs-dsfr.svg',
width: '32px',
height: 'auto',
},
favicon: {
ico: '/assets/favicon-dsfr.ico',
'png-light': '/assets/favicon-dsfr.png',
@@ -117,131 +86,296 @@ const dsfrTheme = {
const genericTheme = {
generic: {
theme: {
globals: {
colors: {
'primary-action': '#206EBD',
'primary-focus': '#1E64BF',
'primary-text': '#2E2C28',
'primary-050': '#F8F8F7',
'primary-100': '#F0EFEC',
'primary-150': '#F4F4FD',
'primary-200': '#E8E7E4',
'primary-300': '#CFCDC9',
'primary-400': '#979592',
'primary-500': '#82807D',
'primary-600': '#3F3D39',
'primary-700': '#2E2C28',
'primary-800': '#302E29',
'primary-900': '#282622',
'primary-950': '#201F1C',
'secondary-text': '#fff',
'secondary-50': '#F4F7FA',
'secondary-100': '#D7E3EE',
'secondary-200': '#B8CCE1',
'secondary-300': '#99B4D3',
'secondary-400': '#7595BE',
'secondary-500': '#5874A0',
'secondary-600': '#3A5383',
'secondary-700': '#1E3462',
'secondary-800': '#091B41',
'secondary-900': '#08183B',
'secondary-950': '#071636',
'greyscale-text': '#3C3B38',
'greyscale-000': '#fff',
'greyscale-050': '#F8F7F7',
'greyscale-100': '#F3F3F2',
'greyscale-200': '#ECEBEA',
'greyscale-250': '#E4E3E2',
'greyscale-300': '#D3D2CF',
'greyscale-350': '#eee',
'greyscale-400': '#96948E',
'greyscale-500': '#817E77',
'greyscale-600': '#6A6862',
'greyscale-700': '#3C3B38',
'greyscale-750': '#383632',
'greyscale-800': '#2D2B27',
'greyscale-900': '#262522',
'greyscale-950': '#201F1C',
'greyscale-1000': '#181714',
'success-text': '#234935',
'success-50': '#F3FBF5',
'success-100': '#E4F7EA',
'success-200': '#CAEED4',
'success-300': '#A0E0B5',
'success-400': '#6CC88C',
'success-500': '#6CC88C',
'success-600': '#358D5C',
'success-700': '#2D704B',
'success-800': '#28583F',
'success-900': '#234935',
'success-950': '#0F281B',
'info-text': '#212445',
'info-50': '#F2F6FB',
'info-100': '#E2E9F5',
'info-200': '#CCD8EE',
'info-300': '#A9C0E3',
'info-400': '#809DD4',
'info-500': '#617BC7',
'info-600': '#4A5CBF',
'info-700': '#3E49B2',
'info-800': '#353C8F',
'info-900': '#303771',
'info-950': '#212445',
'warning-text': '#D97C3A',
'warning-50': '#FDF7F1',
'warning-100': '#FBEDDC',
'warning-200': '#F5D9B9',
'warning-300': '#EDBE8C',
'warning-400': '#E2985C',
'warning-500': '#D97C3A',
'warning-600': '#C96330',
'warning-700': '#A34B32',
'warning-800': '#813B2C',
'warning-900': '#693327',
'warning-950': '#381713',
'danger-action': '#C0182A',
'danger-text': '#FFF',
'danger-050': '#FDF5F4',
'danger-100': '#FBEBE8',
'danger-200': '#F9E0DC',
'danger-300': '#F3C3BD',
'danger-400': '#E26552',
'danger-500': '#C91F00',
'danger-600': '#A71901',
'danger-700': '#562C2B',
'danger-800': '#392425',
'danger-900': '#311F20',
'danger-950': '#2A191A',
'blue-400': '#8BAECC',
'blue-500': '#567AA2',
'blue-600': '#455784',
'brown-400': '#E4C090',
'brown-500': '#BA9977',
'brown-600': '#735C45',
'cyan-400': '#5CBEC9',
'cyan-500': '#43A1B3',
'cyan-600': '#39809B',
'gold-400': '#ECBF50',
'gold-500': '#DFA038',
'gold-600': '#C17B31',
'green-400': '#5DBD9A',
'green-500': '#3AA183',
'green-600': '#2A816D',
'olive-400': '#AFD662',
'olive-500': '#90BB4B',
'olive-600': '#6E9441',
'orange-400': '#E2985C',
'orange-500': '#D97C3A',
'orange-600': '#C96330',
'pink-400': '#BE8FC8',
'pink-500': '#A563B1',
'pink-600': '#8B44A5',
'purple-400': '#BE8FC8',
'purple-500': '#A563B1',
'purple-600': '#8B44A5',
'yellow-400': '#EDC947',
'yellow-500': '#DBB13A',
'yellow-600': '#B88A34',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
'brand-200': '#BEC5F0',
'brand-250': '#AFB5F1',
'brand-300': '#A0A5F6',
'brand-350': '#8F94FD',
'brand-400': '#8184FC',
'brand-450': '#7576EE',
'brand-500': '#6969DF',
'brand-550': '#5E5CD0',
'brand-600': '#534FC2',
'brand-650': '#4844AD',
'brand-700': '#3E3B98',
'brand-750': '#36347D',
'brand-800': '#2D2F5F',
'brand-850': '#262848',
'brand-900': '#1C1E32',
'brand-950': '#11131F',
'gray-000': '#FFFFFF',
'gray-025': '#F8F8F9',
'gray-050': '#F0F0F3',
'gray-100': '#E2E2EA',
'gray-150': '#D3D4E0',
'gray-200': '#C5C6D5',
'gray-250': '#B7B7CB',
'gray-300': '#A9A9BF',
'gray-350': '#9C9CB2',
'gray-400': '#8F8FA4',
'gray-450': '#828297',
'gray-500': '#75758A',
'gray-550': '#69697D',
'gray-600': '#5D5D70',
'gray-650': '#515164',
'gray-700': '#454558',
'gray-750': '#3A3A4C',
'gray-800': '#2F303D',
'gray-850': '#25252F',
'gray-900': '#1B1B23',
'gray-950': '#111114',
'gray-1000': '#000000',
'info-050': '#EAF2F9',
'info-100': '#D5E4F3',
'info-150': '#BFD7F0',
'info-200': '#A7CAEE',
'info-250': '#8DBDEF',
'info-300': '#6EB0F2',
'info-350': '#50A2F5',
'info-400': '#3593F4',
'info-450': '#1185ED',
'info-500': '#0077DE',
'info-550': '#0069CF',
'info-600': '#005BC0',
'info-650': '#0D4EAA',
'info-700': '#124394',
'info-750': '#163878',
'info-800': '#192F5A',
'info-850': '#192541',
'info-900': '#141B2D',
'info-950': '#0C111C',
'success-050': '#E8F1EA',
'success-100': '#CFE4D4',
'success-150': '#BAD9C1',
'success-200': '#A2CFAD',
'success-250': '#86C597',
'success-300': '#6CBA83',
'success-350': '#4FB070',
'success-400': '#40A363',
'success-450': '#309556',
'success-500': '#1E884A',
'success-550': '#027B3E',
'success-600': '#016D31',
'success-650': '#006024',
'success-700': '#005317',
'success-750': '#0D4511',
'success-800': '#11380E',
'success-850': '#132A11',
'success-900': '#101E0F',
'success-950': '#091209',
'warning-050': '#F8F0E9',
'warning-100': '#F1E0D3',
'warning-150': '#ECD0BC',
'warning-200': '#E8C0A4',
'warning-250': '#E8AE8A',
'warning-300': '#EB9970',
'warning-350': '#E98456',
'warning-400': '#E57036',
'warning-450': '#DA5E18',
'warning-500': '#CB5000',
'warning-550': '#BC4200',
'warning-600': '#AD3300',
'warning-650': '#9E2300',
'warning-700': '#882011',
'warning-750': '#731E16',
'warning-800': '#58201A',
'warning-850': '#401D18',
'warning-900': '#2E1714',
'warning-950': '#1D0F0D',
'error-050': '#F9EFEC',
'error-100': '#F4DFD9',
'error-150': '#F0CEC6',
'error-200': '#EEBCB2',
'error-250': '#EEA99D',
'error-300': '#EF9486',
'error-350': '#F37C6E',
'error-400': '#F65F53',
'error-450': '#F0463D',
'error-500': '#E82322',
'error-550': '#D7010E',
'error-600': '#C00100',
'error-650': '#AA0000',
'error-700': '#910C06',
'error-750': '#731E16',
'error-800': '#58201A',
'error-850': '#401D18',
'error-900': '#2E1714',
'error-950': '#1D0F0D',
'red-050': '#FAEFEE',
'red-100': '#F4DEDD',
'red-150': '#F1CDCB',
'red-200': '#EFBBBA',
'red-250': '#EEA8A8',
'red-300': '#F09394',
'red-350': '#F37B7E',
'red-400': '#EF6569',
'red-450': '#E94A55',
'red-500': '#DA3B49',
'red-550': '#CA2A3C',
'red-600': '#BB1330',
'red-650': '#A90021',
'red-700': '#910A13',
'red-750': '#731E16',
'red-800': '#58201A',
'red-850': '#411D18',
'red-900': '#2E1714',
'red-950': '#1D0F0D',
'orange-050': '#F8F0E9',
'orange-100': '#F1E0D3',
'orange-150': '#ECD0BD',
'orange-200': '#EABFA6',
'orange-250': '#EBAC90',
'orange-300': '#EC9772',
'orange-350': '#E5845A',
'orange-400': '#D6774D',
'orange-450': '#C86A40',
'orange-500': '#B95D33',
'orange-550': '#AB5025',
'orange-600': '#9D4315',
'orange-650': '#8F3600',
'orange-700': '#812900',
'orange-750': '#6C2511',
'orange-800': '#572017',
'orange-850': '#401D18',
'orange-900': '#2E1714',
'orange-950': '#1D0F0D',
'brown-050': '#F6F0E8',
'brown-100': '#F1E0D3',
'brown-150': '#EBD0BA',
'brown-200': '#E2C0A6',
'brown-250': '#D4B398',
'brown-300': '#C6A58B',
'brown-350': '#B8987E',
'brown-400': '#AA8B71',
'brown-450': '#9D7E65',
'brown-500': '#8F7158',
'brown-550': '#82654C',
'brown-600': '#765841',
'brown-650': '#694C35',
'brown-700': '#5D412A',
'brown-750': '#51361E',
'brown-800': '#452A13',
'brown-850': '#392008',
'brown-900': '#29180A',
'brown-950': '#1B0F08',
'yellow-050': '#F3F0E7',
'yellow-100': '#E9E2CF',
'yellow-150': '#E1D4B7',
'yellow-200': '#D9C599',
'yellow-250': '#D2B677',
'yellow-300': '#CAA756',
'yellow-350': '#C2972E',
'yellow-400': '#B98900',
'yellow-450': '#AB7B00',
'yellow-500': '#9D6E00',
'yellow-550': '#916100',
'yellow-600': '#855400',
'yellow-650': '#784700',
'yellow-700': '#6C3A00',
'yellow-750': '#5F2E00',
'yellow-800': '#512302',
'yellow-850': '#3E1D10',
'yellow-900': '#2D1711',
'yellow-950': '#1D0F0D',
'green-050': '#E6F1E9',
'green-100': '#CFE4D5',
'green-150': '#B8D8C1',
'green-200': '#A0CFAE',
'green-250': '#84C59A',
'green-300': '#65BA86',
'green-350': '#45B173',
'green-400': '#23A562',
'green-450': '#029755',
'green-500': '#008948',
'green-550': '#017B3B',
'green-600': '#006E2E',
'green-650': '#006022',
'green-700': '#005314',
'green-750': '#0D4510',
'green-800': '#11380E',
'green-850': '#132A11',
'green-900': '#101E0F',
'green-950': '#091209',
'blue1-050': '#EBF1F9',
'blue1-100': '#D6E4F4',
'blue1-150': '#C1D7F0',
'blue1-200': '#AACAEF',
'blue1-250': '#8FBCEF',
'blue1-300': '#7CAFEB',
'blue1-350': '#68A1E4',
'blue1-400': '#5B94D6',
'blue1-450': '#4E86C7',
'blue1-500': '#4279B9',
'blue1-550': '#356CAC',
'blue1-600': '#28609E',
'blue1-650': '#1B5390',
'blue1-700': '#0B4783',
'blue1-750': '#0F3C6E',
'blue1-800': '#133059',
'blue1-850': '#152641',
'blue1-900': '#121C2D',
'blue1-950': '#0B111C',
'blue2-050': '#E7F3F4',
'blue2-100': '#CEE7E9',
'blue2-150': '#B2DCE0',
'blue2-200': '#91D1D7',
'blue2-250': '#68C7D0',
'blue2-300': '#43BBC5',
'blue2-350': '#00AFBA',
'blue2-400': '#01A0AA',
'blue2-450': '#00929D',
'blue2-500': '#00848F',
'blue2-550': '#007682',
'blue2-600': '#016874',
'blue2-650': '#005B67',
'blue2-700': '#004E5A',
'blue2-750': '#00424E',
'blue2-800': '#003642',
'blue2-850': '#002A38',
'blue2-900': '#061E28',
'blue2-950': '#071219',
'purple-050': '#F7F0F6',
'purple-100': '#EEE0EE',
'purple-150': '#E7D1E7',
'purple-200': '#DBBFE4',
'purple-250': '#D3AEE2',
'purple-300': '#CB99E1',
'purple-350': '#C188D9',
'purple-400': '#B47BCB',
'purple-450': '#A66EBD',
'purple-500': '#9961AF',
'purple-550': '#8B55A1',
'purple-600': '#7E4894',
'purple-650': '#723C87',
'purple-700': '#633376',
'purple-750': '#552A65',
'purple-800': '#452551',
'purple-850': '#35213D',
'purple-900': '#261A2C',
'purple-950': '#17111C',
'pink-050': '#F8EFF4',
'pink-100': '#F0DFEA',
'pink-150': '#EACEDF',
'pink-200': '#E9BBD1',
'pink-250': '#E9A7C2',
'pink-300': '#E095B4',
'pink-350': '#D685A8',
'pink-400': '#C7799B',
'pink-450': '#B86C8D',
'pink-500': '#AA5F80',
'pink-550': '#9C5374',
'pink-600': '#8E4767',
'pink-650': '#813B5B',
'pink-700': '#732E4F',
'pink-750': '#632643',
'pink-800': '#521F38',
'pink-850': '#3E1C2B',
'pink-900': '#2D171F',
'pink-950': '#1C0E12',
},
font: {
families: {
@@ -250,18 +384,6 @@ const genericTheme = {
},
},
},
components: {
button: {
primary: {
background: {
'color-hover': 'var(--c--theme--colors--primary-focus)',
'color-active': 'var(--c--theme--colors--primary-focus)',
'color-focus': 'var(--c--theme--colors--primary-focus)',
},
},
},
'image-system-filter': 'saturate(0.2)',
},
},
};
+3 -3
View File
@@ -35,13 +35,13 @@
"@fontsource-variable/material-symbols-outlined": "5.2.28",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.16.2",
"@gouvfr-lasuite/ui-kit": "0.18.0",
"@hocuspocus/provider": "3.4.0",
"@mantine/core": "8.3.6",
"@mantine/hooks": "8.3.6",
"@openfun/cunningham-react": "3.2.3",
"@openfun/cunningham-react": "4.0.0",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.22.0",
"@sentry/nextjs": "10.27.0",
"@tanstack/react-query": "5.90.6",
"@tiptap/extensions": "*",
"canvg": "4.0.3",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,12 @@
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
fill="#C9191E"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.58203 26.405V7.5977C4.58203 6.45251 4.88938 5.58519 5.50408 4.99575C6.1272 4.40631 6.95242 4.08212 7.97972 4.02318C9.49542 3.93055 10.9311 3.80425 12.2868 3.64425C13.6425 3.47584 14.9393 3.28217 16.1771 3.06324C17.4234 2.8443 18.6359 2.60011 19.8148 2.33065C21.0274 2.04435 21.9578 2.1875 22.6062 2.7601C23.2546 3.33269 23.5788 4.24632 23.5788 5.50099V23.9925C23.5788 25.0956 23.3893 25.9166 23.0104 26.4555C22.6315 27.0029 21.9915 27.4028 21.0905 27.6554C19.4906 28.0933 17.9833 28.4386 16.5687 28.6912C15.154 28.9522 13.7731 29.1501 12.4258 29.2848C11.0785 29.4196 9.69751 29.5248 8.28286 29.6006C7.11241 29.668 6.20299 29.4238 5.5546 28.868C4.90622 28.3207 4.58203 27.4997 4.58203 26.405ZM9.20865 11.0124C11.0635 10.8944 12.7632 10.7131 14.3075 10.4683C14.6822 10.4072 15.0564 10.3436 15.4291 10.2776C15.8192 10.2085 16.1013 9.86859 16.1013 9.47337C16.1013 8.96154 15.638 8.57609 15.135 8.66189C14.846 8.71118 14.5555 8.75909 14.2635 8.80562C12.7346 9.04923 11.0452 9.22998 9.19523 9.3477C8.91819 9.36558 8.69776 9.45188 8.55608 9.62391C8.42209 9.78661 8.35645 9.98229 8.35645 10.2053C8.35645 10.4321 8.43296 10.6295 8.58568 10.7918L8.58783 10.7939C8.75336 10.9595 8.96369 11.0311 9.20865 11.0124ZM9.20801 15.206C11.0631 15.088 12.763 14.9066 14.3075 14.6619C15.8588 14.4089 17.3936 14.1138 18.9112 13.7766C19.2191 13.7081 19.4498 13.6003 19.5652 13.433C19.6786 13.2721 19.7347 13.0876 19.7347 12.8832C19.7347 12.6526 19.6469 12.454 19.476 12.2926C19.2921 12.1189 19.0348 12.0784 18.7304 12.1411L18.7285 12.1415C17.2823 12.4694 15.794 12.7553 14.2635 12.9992C12.7346 13.2428 11.0452 13.4235 9.19523 13.5413C8.91819 13.5591 8.69776 13.6454 8.55608 13.8175C8.42276 13.9794 8.35645 14.1705 8.35645 14.3863C8.35645 14.6203 8.43209 14.8223 8.58558 14.9854L8.59 14.9896C8.75499 15.1449 8.96316 15.2155 9.20551 15.2062L9.20801 15.206ZM9.20847 19.3994C11.0634 19.2729 12.7631 19.0874 14.3075 18.8427C15.8589 18.5982 17.3934 18.3073 18.9112 17.97C19.2199 17.9014 19.4508 17.7891 19.566 17.6127C19.6783 17.4529 19.7347 17.2733 19.7347 17.0766C19.7347 16.8461 19.6469 16.6474 19.476 16.4861C19.2921 16.3123 19.0348 16.2718 18.7304 16.3345L18.729 16.3348C17.2827 16.6543 15.7942 16.9361 14.2635 17.18C12.7345 17.4236 11.045 17.6086 9.19495 17.7347C8.91804 17.7526 8.69771 17.8389 8.55608 18.0109C8.42276 18.1728 8.35645 18.3639 8.35645 18.5797C8.35645 18.8137 8.43209 19.0158 8.58558 19.1789L8.59 19.183C8.75499 19.3383 8.96316 19.4089 9.20551 19.3996L9.20847 19.3994ZM14.3075 23.007C12.7632 23.2518 11.0635 23.4331 9.20867 23.5512C8.9637 23.5698 8.75337 23.4982 8.58783 23.3326L8.58572 23.3305C8.433 23.1682 8.35645 22.9708 8.35645 22.7441C8.35645 22.521 8.42209 22.3253 8.55608 22.1626C8.69776 21.9906 8.91827 21.9043 9.19531 21.8864C11.0453 21.7687 12.7346 21.588 14.2635 21.3443C14.5555 21.2978 14.846 21.2499 15.135 21.2006C15.638 21.1148 16.1013 21.5003 16.1013 22.0121C16.1013 22.4073 15.8192 22.7472 15.4291 22.8163C15.0564 22.8823 14.6822 22.9459 14.3075 23.007Z"
fill="#2845C1"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,12 +1,4 @@
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
fill="#C9191E"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.58203 26.405V7.5977C4.58203 6.45251 4.88938 5.58519 5.50408 4.99575C6.1272 4.40631 6.95242 4.08212 7.97972 4.02318C9.49542 3.93055 10.9311 3.80425 12.2868 3.64425C13.6425 3.47584 14.9393 3.28217 16.1771 3.06324C17.4234 2.8443 18.6359 2.60011 19.8148 2.33065C21.0274 2.04435 21.9578 2.1875 22.6062 2.7601C23.2546 3.33269 23.5788 4.24632 23.5788 5.50099V23.9925C23.5788 25.0956 23.3893 25.9166 23.0104 26.4555C22.6315 27.0029 21.9915 27.4028 21.0905 27.6554C19.4906 28.0933 17.9833 28.4386 16.5687 28.6912C15.154 28.9522 13.7731 29.1501 12.4258 29.2848C11.0785 29.4196 9.69751 29.5248 8.28286 29.6006C7.11241 29.668 6.20299 29.4238 5.5546 28.868C4.90622 28.3207 4.58203 27.4997 4.58203 26.405ZM9.20865 11.0124C11.0635 10.8944 12.7632 10.7131 14.3075 10.4683C14.6822 10.4072 15.0564 10.3436 15.4291 10.2776C15.8192 10.2085 16.1013 9.86859 16.1013 9.47337C16.1013 8.96154 15.638 8.57609 15.135 8.66189C14.846 8.71118 14.5555 8.75909 14.2635 8.80562C12.7346 9.04923 11.0452 9.22998 9.19523 9.3477C8.91819 9.36558 8.69776 9.45188 8.55608 9.62391C8.42209 9.78661 8.35645 9.98229 8.35645 10.2053C8.35645 10.4321 8.43296 10.6295 8.58568 10.7918L8.58783 10.7939C8.75336 10.9595 8.96369 11.0311 9.20865 11.0124ZM9.20801 15.206C11.0631 15.088 12.763 14.9066 14.3075 14.6619C15.8588 14.4089 17.3936 14.1138 18.9112 13.7766C19.2191 13.7081 19.4498 13.6003 19.5652 13.433C19.6786 13.2721 19.7347 13.0876 19.7347 12.8832C19.7347 12.6526 19.6469 12.454 19.476 12.2926C19.2921 12.1189 19.0348 12.0784 18.7304 12.1411L18.7285 12.1415C17.2823 12.4694 15.794 12.7553 14.2635 12.9992C12.7346 13.2428 11.0452 13.4235 9.19523 13.5413C8.91819 13.5591 8.69776 13.6454 8.55608 13.8175C8.42276 13.9794 8.35645 14.1705 8.35645 14.3863C8.35645 14.6203 8.43209 14.8223 8.58558 14.9854L8.59 14.9896C8.75499 15.1449 8.96316 15.2155 9.20551 15.2062L9.20801 15.206ZM9.20847 19.3994C11.0634 19.2729 12.7631 19.0874 14.3075 18.8427C15.8589 18.5982 17.3934 18.3073 18.9112 17.97C19.2199 17.9014 19.4508 17.7891 19.566 17.6127C19.6783 17.4529 19.7347 17.2733 19.7347 17.0766C19.7347 16.8461 19.6469 16.6474 19.476 16.4861C19.2921 16.3123 19.0348 16.2718 18.7304 16.3345L18.729 16.3348C17.2827 16.6543 15.7942 16.9361 14.2635 17.18C12.7345 17.4236 11.045 17.6086 9.19495 17.7347C8.91804 17.7526 8.69771 17.8389 8.55608 18.0109C8.42276 18.1728 8.35645 18.3639 8.35645 18.5797C8.35645 18.8137 8.43209 19.0158 8.58558 19.1789L8.59 19.183C8.75499 19.3383 8.96316 19.4089 9.20551 19.3996L9.20847 19.3994ZM14.3075 23.007C12.7632 23.2518 11.0635 23.4331 9.20867 23.5512C8.9637 23.5698 8.75337 23.4982 8.58783 23.3326L8.58572 23.3305C8.433 23.1682 8.35645 22.9708 8.35645 22.7441C8.35645 22.521 8.42209 22.3253 8.55608 22.1626C8.69776 21.9906 8.91827 21.9043 9.19531 21.8864C11.0453 21.7687 12.7346 21.588 14.2635 21.3443C14.5555 21.2978 14.846 21.2499 15.135 21.2006C15.638 21.1148 16.1013 21.5003 16.1013 22.0121C16.1013 22.4073 15.8192 22.7472 15.4291 22.8163C15.0564 22.8823 14.6822 22.9459 14.3075 23.007Z"
fill="#000091"
/>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.6305 28.8312C22.7983 28.5038 23.9166 27.9062 24.6505 26.8503C25.3749 25.8163 25.5789 24.5047 25.5789 23.2425V4.75099C25.5789 4.42358 25.5611 4.09557 25.5216 3.77148C26.1016 3.99961 26.5486 4.37658 26.8626 4.90239C27.2331 5.50024 27.4184 6.28757 27.4184 7.26435V26.0464C27.4184 27.3684 27.0942 28.3578 26.4458 29.0146C25.7974 29.6714 24.8207 29.9998 23.5155 29.9998H16.4209C16.5889 29.9704 16.7574 29.9401 16.9262 29.909C18.4067 29.6444 19.9713 29.2854 21.6185 28.8346L21.6305 28.8312Z" fill="#4747A3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58203 25.655V6.8477C4.58203 5.70251 4.88938 4.83519 5.50408 4.24575C6.1272 3.65631 6.95242 3.33212 7.97972 3.27318C9.49542 3.18055 10.9311 3.05425 12.2868 2.89425C13.6425 2.72584 14.9393 2.53217 16.1771 2.31324C17.4234 2.0943 18.6359 1.85011 19.8148 1.58065C21.0274 1.29435 21.9578 1.4375 22.6062 2.0101C23.2546 2.58269 23.5788 3.49632 23.5788 4.75099V23.2425C23.5788 24.3456 23.3893 25.1666 23.0104 25.7055C22.6315 26.2529 21.9915 26.6528 21.0905 26.9054C19.4906 27.3433 17.9833 27.6886 16.5687 27.9412C15.154 28.2022 13.7731 28.4001 12.4258 28.5348C11.0785 28.6696 9.69751 28.7748 8.28286 28.8506C7.11241 28.918 6.20299 28.6738 5.5546 28.118C4.90622 27.5707 4.58203 26.7497 4.58203 25.655ZM9.20865 10.2624C11.0635 10.1444 12.7632 9.96305 14.3075 9.71831C14.6822 9.65722 15.0564 9.5936 15.4291 9.52759C15.8192 9.45851 16.1013 9.11859 16.1013 8.72337C16.1013 8.21154 15.638 7.82609 15.135 7.91189C14.846 7.96118 14.5555 8.00909 14.2635 8.05562C12.7346 8.29923 11.0452 8.47998 9.19523 8.5977C8.91819 8.61558 8.69776 8.70188 8.55608 8.87391C8.42209 9.03661 8.35645 9.23229 8.35645 9.45535C8.35645 9.68212 8.43296 9.87951 8.58568 10.0418L8.58783 10.0439C8.75336 10.2095 8.96369 10.2811 9.20865 10.2624ZM9.20801 14.456C11.0631 14.338 12.763 14.1566 14.3075 13.9119C15.8588 13.6589 17.3936 13.3638 18.9112 13.0266C19.2191 12.9581 19.4498 12.8503 19.5652 12.683C19.6786 12.5221 19.7347 12.3376 19.7347 12.1332C19.7347 11.9026 19.6469 11.704 19.476 11.5426C19.2921 11.3689 19.0348 11.3284 18.7304 11.3911L18.7285 11.3915C17.2823 11.7194 15.794 12.0053 14.2635 12.2492C12.7346 12.4928 11.0452 12.6735 9.19523 12.7913C8.91819 12.8091 8.69776 12.8954 8.55608 13.0675C8.42276 13.2294 8.35645 13.4205 8.35645 13.6363C8.35645 13.8703 8.43209 14.0723 8.58558 14.2354L8.59 14.2396C8.75499 14.3949 8.96316 14.4655 9.20551 14.4562L9.20801 14.456ZM9.20847 18.6494C11.0634 18.5229 12.7631 18.3374 14.3075 18.0927C15.8589 17.8482 17.3934 17.5573 18.9112 17.22C19.2199 17.1514 19.4508 17.0391 19.566 16.8627C19.6783 16.7029 19.7347 16.5233 19.7347 16.3266C19.7347 16.0961 19.6469 15.8974 19.476 15.7361C19.2921 15.5623 19.0348 15.5218 18.7304 15.5845L18.729 15.5848C17.2827 15.9043 15.7942 16.1861 14.2635 16.43C12.7345 16.6736 11.045 16.8586 9.19495 16.9847C8.91804 17.0026 8.69771 17.0889 8.55608 17.2609C8.42276 17.4228 8.35645 17.6139 8.35645 17.8297C8.35645 18.0637 8.43209 18.2658 8.58558 18.4289L8.59 18.433C8.75499 18.5883 8.96316 18.6589 9.20551 18.6496L9.20847 18.6494ZM14.3075 22.257C12.7632 22.5018 11.0635 22.6831 9.20867 22.8012C8.9637 22.8198 8.75337 22.7482 8.58783 22.5826L8.58572 22.5805C8.433 22.4182 8.35645 22.2208 8.35645 21.9941C8.35645 21.771 8.42209 21.5753 8.55608 21.4126C8.69776 21.2406 8.91827 21.1543 9.19531 21.1364C11.0453 21.0187 12.7346 20.838 14.2635 20.5943C14.5555 20.5478 14.846 20.4999 15.135 20.4506C15.638 20.3648 16.1013 20.7503 16.1013 21.2621C16.1013 21.6573 15.8192 21.9972 15.4291 22.0663C15.0564 22.1323 14.6822 22.1959 14.3075 22.257Z" fill="#4747A3"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,7 +1,7 @@
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
fill="var(--c--theme--colors--secondary-icon)"
fill="var(--c--globals--colors--red-600)"
/>
<path
fill-rule="evenodd"

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -14,6 +14,7 @@ export interface BoxProps {
as?: HTMLElementType;
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$border?: CSSProperties['border'];
$color?: CSSProperties['color'];
$css?: string | RuleSet<object>;
$cursor?: CSSProperties['cursor'];
@@ -36,41 +37,34 @@ export interface BoxProps {
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$shrink?: CSSProperties['flexShrink'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$zIndex?: CSSProperties['zIndex'];
$wrap?: CSSProperties['flexWrap'];
// Theming props
$layer?: 'background' | 'content' | 'border';
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'brand'
| 'error'
| 'gray'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'greyscale';
$transition?: CSSProperties['transition'];
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
| '400'
| '500'
| '600'
| '700'
| '800'
| '900'
| '1000';
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
$zIndex?: CSSProperties['zIndex'];
| 'neutral'
| 'contextual'
| 'disabled'
| (string & {});
$scope?: 'surface' | 'semantic' | 'palette' | (string & {});
$variation?: 'primary' | 'secondary' | 'tertiary' | (string & {});
$withThemeBG?: boolean;
$withThemeBorder?: boolean;
$withThemeInherited?: boolean;
}
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}
${({ $cursor }) => $cursor && `cursor: ${$cursor};`}
${({ $direction }) => `flex-direction: ${$direction || 'column'};`}
${({ $display, as }) =>
@@ -80,9 +74,9 @@ export const Box = styled('div')<BoxProps>`
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
$hasTransition && $hasTransition === 'slow'
? `transition: all 0.5s ease-in-out;`
? `transition: all 0.5s var(--c--globals--transitions--ease-out);`
: $hasTransition
? `transition: all 0.3s ease-in-out;`
? `transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);`
: ''}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $margin }) => $margin && stylesMargin($margin)}
@@ -96,11 +90,85 @@ export const Box = styled('div')<BoxProps>`
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
${({ $theme, $variation }) => {
if (!$theme || !$variation) {
${({
$layer = 'border',
$theme = 'brand',
$variation = 'primary',
$scope = 'semantic',
$border,
$withThemeBorder,
$withThemeInherited,
}) => {
if ($border) {
return `border: ${$border};`;
}
if (!$layer || !$scope || !$theme || !$withThemeBorder) {
return '';
}
return `color: var(--c--theme--colors--${$theme}-${$variation});`;
if ($withThemeInherited) {
return `border: inherit;`;
}
return `border: 1px solid var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({
$layer = 'background',
$theme = 'brand',
$variation = 'primary',
$scope = 'semantic',
$background,
$withThemeBG,
$withThemeInherited,
}) => {
if ($background) {
return `background: ${$background};`;
}
if (!$layer || !$scope || !$theme || !$withThemeBG) {
return '';
}
if ($withThemeInherited) {
return `background: inherit;`;
}
return `background: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({
$layer = 'content',
$theme = 'neutral',
$variation = 'primary',
$scope = 'semantic',
$color,
$withThemeBG,
$withThemeInherited,
}) => {
if ($color) {
return `color: ${$color};`;
}
if (!$layer || !$scope) {
return '';
}
// There is a special case when primary with background
if (
$withThemeBG &&
$layer === 'content' &&
$scope === 'semantic' &&
$variation === 'primary' &&
$theme
) {
$variation = `on-${$theme}`;
}
if ($withThemeInherited) {
return `color: inherit;`;
}
return `color: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
@@ -121,7 +189,7 @@ export const Box = styled('div')<BoxProps>`
return (
effect &&
`
transition: all 0.3s ease-in-out;
transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);
${effect}
`
);
@@ -24,8 +24,8 @@ export type BoxButtonType = BoxType & {
*/
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
const theme = props.$theme || 'greyscale';
const variation = props.$variation || '400';
const theme = props.$theme || 'gray';
const variation = props.$variation || 'primary';
return (
<Box
@@ -40,15 +40,14 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
border: none;
outline: none;
font-family: inherit;
color: ${props.disabled
? `var(--c--theme--colors--${theme}-400) !important`
: `inherit`};
color: ${props.disabled &&
`var(--c--contextuals--content--semantic--disabled--primary)`};
&:focus-visible {
transition: none;
outline: 2px solid var(--c--theme--colors--${theme}-${variation});
outline: 2px solid
var(--c--contextuals--content--semantic--${theme}--${variation});
border-radius: 1px;
outline-offset: 4px;
outline-offset: var(--c--globals--spacings--st);
}
${$css || ''}
`}
@@ -1,27 +1,23 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box, BoxType } from '.';
export const Card = ({
children,
className,
$css,
...props
}: PropsWithChildren<BoxType>) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
role="region"
$withThemeBG
$withThemeBorder
className={`--docs--card ${className || ''}`}
$background="white"
$radius="4px"
$css={css`
border: 1px solid ${colorsTokens['greyscale-200']};
${$css}
`}
$radius="var(--c--globals--spacings--st)"
$padding={{ horizontal: 'xs', vertical: '3xs' }}
$scope={props.$theme ? props.$scope || 'semantic' : 'surface'}
$theme={props.$theme || 'primary'}
$variation={props.$theme ? props.$variation || 'tertiary' : ''}
{...props}
>
{children}
@@ -14,10 +14,11 @@ 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;
border-radius: var(--c--globals--spacings--st);
box-shadow: 0 0 6px 0 rgba(0, 0, 145, 0.1);
border: 1px solid var(--c--contextuals--border--surface--primary);
transition: opacity var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
`;
interface StyledButtonProps {
@@ -28,18 +29,22 @@ const StyledButton = styled(Button)<StyledButtonProps>`
border: none;
background: none;
outline: none;
font-weight: 500;
font-size: 0.938rem;
padding: 0;
border-radius: 4px;
font-weight: var(--c--components--button--font-weight);
font-size: var(--c--components--button--medium-font-size);
padding: var(--c--globals--spacings--0);
border-radius: var(--c--globals--spacings--st);
color: var(--c--contextuals--content--semantic--brand--tertiary);
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
--c--contextuals--background--semantic--contextual--primary
);
}
&:focus-visible {
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400);
border-radius: 4px;
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400);
background-color: var(
--c--contextuals--background--semantic--brand--tertiary-hover
);
border-radius: var(--c--globals--spacings--st);
}
${({ $css }) => $css};
`;
@@ -13,7 +13,7 @@ export const Icon = ({
iconName,
disabled,
variant = 'outlined',
$variation = 'text',
$theme = 'neutral',
...textProps
}: IconProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
@@ -28,7 +28,7 @@ export const Icon = ({
'material-icons': variant === 'outlined',
'material-symbols-outlined': variant === 'symbols-outlined',
})}
$variation={disabled ? '300' : $variation}
$theme={disabled ? 'disabled' : $theme}
aria-disabled={disabled}
{...textProps}
>
@@ -39,7 +39,8 @@ export const InfiniteScroll = ({
{!isLoading && hasMore && (
<Button
onClick={() => void next()}
color="primary-text"
color="brand"
variant="bordered"
icon={<Icon iconName="arrow_downward" />}
>
{buttonLabel ?? t('Load more')}
@@ -24,12 +24,12 @@ export const LoadMoreText = ({
className="--docs--load-more"
>
<Icon
$theme="primary"
$variation="800"
$theme="brand"
$variation="secondary"
iconName="arrow_downward"
$size="md"
/>
<Text $theme="primary" $variation="800">
<Text $theme="brand" $variation="secondary">
{t('Load more')}
</Text>
</Box>
@@ -5,7 +5,7 @@ import { tokens } from '@/cunningham';
import { Box, BoxProps } from './Box';
const { sizes } = tokens.themes.default.theme.font;
const { sizes } = tokens.themes.default.globals.font;
type TextSizes = keyof typeof sizes;
export interface TextProps extends BoxProps {
@@ -27,24 +27,14 @@ export const TextStyled = styled(Box)<TextProps>`
${({ $size }) =>
$size &&
`font-size: ${$size in sizes ? sizes[$size as TextSizes] : $size};`}
${({ $color }) => $color && `color: ${$color};`}
${({ $ellipsis }) =>
$ellipsis &&
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={className}
{...props}
/>
);
(props, ref) => {
return <TextStyled ref={ref} as="span" {...props} />;
},
);
@@ -7,7 +7,7 @@ import { Box, Text, TextType } from '@/components';
const AlertStyled = styled(Alert)`
& .c__button--tertiary:hover {
background-color: var(--c--theme--colors--greyscale-200);
background-color: var(--c--globals--colors--gray-200);
}
`;
@@ -54,8 +54,7 @@ export const TextOnlyErrors = ({
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
$theme="danger"
$variation="600"
$theme="error"
$textAlign="center"
{...textProps}
>
@@ -64,12 +63,7 @@ export const TextOnlyErrors = ({
))}
{!causes && (
<Text
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
<Text $theme="error" $textAlign="center" {...textProps}>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}
@@ -124,18 +124,19 @@ export const DropdownMenu = ({
>
<Box>{children}</Box>
<Icon
$variation="600"
$css={
arrowCss ??
css`
color: var(--c--theme--colors--primary-600);
color: var(--c--globals--colors--brand-600);
`
}
iconName={isOpen ? 'arrow_drop_up' : 'arrow_drop_down'}
/>
</Box>
) : (
<Box ref={blockButtonRef}>{children}</Box>
<Box ref={blockButtonRef} $color="inherit">
{children}
</Box>
)
}
>
@@ -147,7 +148,6 @@ export const DropdownMenu = ({
>
{topMessage && (
<Text
$variation="700"
$wrap="wrap"
$size="xs"
$weight="bold"
@@ -186,8 +186,8 @@ export const DropdownMenu = ({
key={option.label}
$align="center"
$justify="space-between"
$background={colorsTokens['greyscale-000']}
$color={colorsTokens['primary-600']}
$background={colorsTokens['gray-000']}
$color={colorsTokens['brand-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacingsTokens['base']}
@@ -200,30 +200,39 @@ export const DropdownMenu = ({
`}
${index === options.length - 1 &&
css`
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: var(--c--globals--spacings--st);
border-bottom-right-radius: var(--c--globals--spacings--st);
`}
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-1000);
font-weight: 500;
font-size: var(--c--globals--font--sizes--sm);
color: var(--c--globals--colors--gray-1000);
font-weight: var(--c--globals--font--weights--medium);
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-400);
outline: 2px solid var(--c--globals--colors--brand-400);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
${isFocused &&
/**
* TODO: This part seems to have a problem with DocToolBox
*/
/* ${isFocused &&
css`
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
`}
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
`} */
`}
>
<Box
@@ -234,8 +243,8 @@ export const DropdownMenu = ({
{option.icon && typeof option.icon === 'string' && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
$theme="gray"
$variation={isDisabled ? 'tertiary' : 'primary'}
iconName={option.icon}
aria-hidden="true"
/>
@@ -243,7 +252,7 @@ export const DropdownMenu = ({
{option.icon &&
typeof option.icon !== 'string' &&
option.icon}
<Text $variation={isDisabled ? '400' : '1000'}>
<Text $variation={isDisabled ? 'tertiary' : 'primary'}>
{option.label}
</Text>
</Box>
@@ -252,7 +261,7 @@ export const DropdownMenu = ({
<Icon
iconName="check"
$size="20px"
$theme="greyscale"
$theme="gray"
aria-hidden="true"
/>
)}
@@ -33,32 +33,27 @@ export const FilterDropdown = ({
<Box
$css={css`
border: 1px solid
${selectedOption
? 'var(--c--theme--colors--primary-500)'
: 'var(--c--theme--colors--greyscale-250)'};
border-radius: 4px;
background-color: ${selectedOption
? 'var(--c--theme--colors--primary-100)'
: 'var(--c--theme--colors--greyscale-000)'};
gap: var(--c--theme--spacings--2xs);
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
var(--c--contextuals--border--semantic--neutral--tertiary);
border-radius: var(--c--globals--spacings--st);
background-color: var(
--c--contextuals--background--semantic--neutral--tertiary
);
gap: var(--c--globals--spacings--2xs);
padding: var(--c--globals--spacings--2xs)
var(--c--globals--spacings--xs);
`}
color="secondary"
$direction="row"
$align="center"
>
<Text
$weight={400}
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
>
<Text $weight={400} $variation="tertiary" $theme="neutral">
{selectedOption?.label ?? options[0].label}
</Text>
<Icon
$size="16px"
$size="s"
iconName="keyboard_arrow_down"
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
$variation="tertiary"
$theme="neutral"
/>
</Box>
</DropdownMenu>
@@ -38,7 +38,6 @@ export const AlertModal = ({
$margin="0"
id="alert-modal-title"
$align="flex-start"
$variation="1000"
>
{title}
</Text>
@@ -47,7 +46,7 @@ export const AlertModal = ({
<>
<Button
aria-label={`${t('Cancel')} - ${title}`}
color="secondary"
variant="secondary"
fullWidth
onClick={() => onClose()}
>
@@ -55,7 +54,7 @@ export const AlertModal = ({
</Button>
<Button
aria-label={confirmLabel ?? t('Confirm')}
color="danger"
color="error"
onClick={onConfirm}
>
{confirmLabel ?? t('Confirm')}
@@ -65,7 +64,7 @@ export const AlertModal = ({
>
<Box className="--docs--alert-modal">
<Box>
<Text $variation="600" as="p">
<Text $variation="secondary" as="p">
{description}
</Text>
</Box>
@@ -1,18 +1,21 @@
import { Button, type ButtonProps } from '@openfun/cunningham-react';
import React from 'react';
import { Box } from '@/components';
import { Icon } from '@/components';
export const ButtonCloseModal = (props: ButtonProps) => {
return (
<Button
type="button"
size="small"
color="primary-text"
color="brand"
variant="tertiary"
icon={
<Box as="span" aria-hidden="true" className="material-icons-filled">
close
</Box>
<Icon
$withThemeInherited
iconName="close"
className="material-icons-filled"
/>
}
{...props}
/>
@@ -69,7 +69,7 @@ export const QuickSearch = ({
label={label}
shouldFilter={false}
ref={ref}
tabIndex={0}
tabIndex={-1}
value={selectedValue}
onValueChange={handleValueChange}
>
@@ -59,11 +59,7 @@ export const QuickSearchGroup = <T,>({
);
})}
{group.emptyString && group.elements.length === 0 && (
<Text
$variation="500"
$margin={{ left: '2xs', bottom: '3xs' }}
$size="sm"
>
<Text $margin={{ left: '2xs', bottom: '3xs' }} $size="sm">
{group.emptyString}
</Text>
)}
@@ -53,7 +53,7 @@ export const QuickSearchInput = ({
$padding={{ horizontal: 'base', vertical: 'sm' }}
>
{!loading && (
<Icon iconName="search" $variation="600" aria-hidden="true" />
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
)}
{loading && (
<div>
@@ -19,25 +19,25 @@ export const QuickSearchStyle = createGlobalStyle`
border: none;
width: 100%;
font-size: 17px;
padding: 8px;
padding: var(--c--globals--spacings--xs);
background: white;
outline: none;
color: var(--c--theme--colors--greyscale-1000);
border-radius: 0;
color: var(--c--globals--colors--gray-1000);
border-radius: var(--c--globals--spacings--0);
&::placeholder {
color: var(--c--theme--colors--greyscale-500);
color: var(--c--globals--colors--gray-500);
}
}
[cmdk-item] {
content-visibility: auto;
cursor: pointer;
border-radius: var(--c--theme--spacings--xs);
font-size: 14px;
border-radius: var(--c--globals--spacings--xs);
font-size: var(--c--globals--font--sizes--sm);
display: flex;
align-items: center;
gap: 8px;
gap: var(--c--globals--spacings--xs);
user-select: none;
will-change: background, color;
transition: all 150ms ease;
@@ -49,19 +49,19 @@ export const QuickSearchStyle = createGlobalStyle`
&:hover,
&[data-selected='true'] {
background: var(--c--theme--colors--greyscale-100);
background: var(--c--contextuals--background--semantic--contextual--primary);
.show-right-on-focus {
opacity: 1;
}
}
&[data-disabled='true'] {
color: var(--c--theme--colors--greyscale-500);
color: var(--c--globals--colors--gray-500);
cursor: not-allowed;
}
& + [cmdk-item] {
margin-top: 4px;
margin-top: var(--c--globals--spacings--st);
}
}
@@ -79,11 +79,11 @@ export const QuickSearchStyle = createGlobalStyle`
kbd {
font-size: 12px;
min-width: 20px;
padding: 4px;
padding: var(--c--globals--spacings--st);
height: 20px;
border-radius: 4px;
border-radius: var(--c--globals--spacings--st);
color: white;
background: var(--c--theme--colors--greyscale-500);
background: var(--c--globals--colors--gray-500);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -94,23 +94,23 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-separator] {
height: 1px;
width: 100%;
background: var(--c--theme--colors--greyscale-500);
margin: 4px 0;
background: var(--c--globals--colors--gray-500);
margin: var(--c--globals--spacings--st) 0;
}
*:not([hidden]) + [cmdk-group] {
margin-top: 8px;
margin-top: var(--c--globals--spacings--xs);
}
[cmdk-group-heading] {
user-select: none;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-700);
font-size: var(--c--globals--font--sizes--sm);
color: var(--c--globals--colors--gray-700);
font-weight: bold;
display: flex;
align-items: center;
margin-bottom: var(--c--theme--spacings--xs);
margin-bottom: var(--c--globals--spacings--xs);
}
[cmdk-empty] {
@@ -128,9 +128,9 @@ export const QuickSearchStyle = createGlobalStyle`
}
.c__modal__title {
font-size: var(--c--theme--font--sizes--xs);
padding: var(--c--theme--spacings--base);
margin-bottom: 0;
font-size: var(--c--globals--font--sizes--xs);
padding: var(--c--globals--spacings--base);
margin-bottom: var(--c--globals--spacings--0);
}
}
`;
@@ -1,26 +1,16 @@
import { useCunninghamTheme } from '@/cunningham';
import { Spacings } from '@/utils';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
$withPadding?: boolean;
customPadding?: Spacings;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
$withPadding = true,
customPadding,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
const padding = $withPadding
? (customPadding ?? 'base')
: ('none' as Spacings);
@@ -30,11 +20,7 @@ export const HorizontalSeparator = ({
$height="1px"
$width="100%"
$margin={{ vertical: padding }}
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens['greyscale-100']
}
$background="var(--c--contextuals--border--surface--primary)"
className="--docs--horizontal-separator"
/>
);
@@ -13,7 +13,8 @@ export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
return (
<Box
$css={css`
@@ -21,7 +22,8 @@ export const SeparatedSection = ({
padding: ${spacingsTokens['sm']} 0;
${showSeparator &&
css`
border-bottom: 1px solid ${colorsTokens['greyscale-200']};
border-bottom: 1px solid
var(--c--contextuals--border--surface--primary);
`}
`}
>
@@ -2,13 +2,13 @@ import { useCunninghamTheme } from '../useCunninghamTheme';
describe('<useCunninghamTheme />', () => {
it('has the logo correctly set', () => {
expect(useCunninghamTheme.getState().themeTokens.logo?.src).toBe('');
expect(useCunninghamTheme.getState().componentTokens.logo?.src).toBe('');
// Change theme
useCunninghamTheme.getState().setTheme('dsfr');
const { themeTokens } = useCunninghamTheme.getState();
const logo = themeTokens.logo;
const { componentTokens } = useCunninghamTheme.getState();
const logo = componentTokens.logo;
expect(logo?.src).toBe('/assets/logo-gouv.svg');
expect(logo?.widthHeader).toBe('110px');
expect(logo?.widthFooter).toBe('220px');
@@ -26,7 +26,7 @@
* Select
**/
--c--components--forms-select--value-color--disabled: var(
--c--theme--colors--greyscale-400
--c--globals--colors--gray-400
);
/**
@@ -44,6 +44,14 @@
contain: content;
}
.c__button:disabled {
cursor: unset;
}
.c__button:focus-visible {
outline: none;
}
.c__button--medium {
min-height: var(--c--components--button--medium-height);
height: auto;
@@ -60,14 +68,11 @@
* Tooltip
*/
.c__tooltip {
padding: 4px 6px;
padding: var(--c--globals--font--sizes--sm) var(--c--globals--spacings--xxs);
}
/**
* Image System
*/
.c__image-system-filter {
filter: var(--c--components--image-system-filter);
.c__tooltip .react-aria-OverlayArrow svg {
display: none;
}
/**
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,21 +5,23 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default &
Partial<(typeof tokens.themes)[keyof typeof tokens.themes]>;
type ColorsTokens = Tokens['theme']['colors'];
type FontSizesTokens = Tokens['theme']['font']['sizes'];
type SpacingsTokens = Tokens['theme']['spacings'];
type ColorsTokens = Tokens['globals']['colors'];
type FontSizesTokens = Tokens['globals']['font']['sizes'];
type SpacingsTokens = Tokens['globals']['spacings'];
type ComponentTokens = Tokens['components'];
type ContextualTokens = Tokens['contextuals'];
export type Theme = keyof typeof tokens.themes;
interface ThemeStore {
colorsTokens: Partial<ColorsTokens>;
componentTokens: ComponentTokens;
contextualTokens: ContextualTokens;
currentTokens: Partial<Tokens>;
fontSizesTokens: Partial<FontSizesTokens>;
setTheme: (theme: Theme) => void;
spacingsTokens: Partial<SpacingsTokens>;
theme: Theme;
themeTokens: Partial<Tokens['theme']>;
themeTokens: Partial<Tokens['globals']>;
}
const getMergedTokens = (theme: Theme) => {
@@ -30,14 +32,15 @@ const DEFAULT_THEME: Theme = 'generic';
const defaultTokens = getMergedTokens(DEFAULT_THEME);
const initialState: ThemeStore = {
colorsTokens: defaultTokens.theme.colors,
colorsTokens: defaultTokens.globals.colors,
componentTokens: defaultTokens.components,
contextualTokens: defaultTokens.contextuals,
currentTokens: tokens.themes[DEFAULT_THEME] as Partial<Tokens>,
fontSizesTokens: defaultTokens.theme.font.sizes,
fontSizesTokens: defaultTokens.globals.font.sizes,
setTheme: () => {},
spacingsTokens: defaultTokens.theme.spacings,
spacingsTokens: defaultTokens.globals.spacings,
theme: DEFAULT_THEME,
themeTokens: defaultTokens.theme,
themeTokens: defaultTokens.globals,
};
export const useCunninghamTheme = create<ThemeStore>((set) => ({
@@ -46,13 +49,14 @@ export const useCunninghamTheme = create<ThemeStore>((set) => ({
const newTokens = getMergedTokens(theme);
set({
colorsTokens: newTokens.theme.colors,
colorsTokens: newTokens.globals.colors,
componentTokens: newTokens.components,
contextualTokens: newTokens.contextuals,
currentTokens: tokens.themes[theme] as Partial<Tokens>,
fontSizesTokens: newTokens.theme.font.sizes,
spacingsTokens: newTokens.theme.spacings,
fontSizesTokens: newTokens.globals.font.sizes,
spacingsTokens: newTokens.globals.spacings,
theme,
themeTokens: newTokens.theme,
themeTokens: newTokens.globals,
});
},
}));
@@ -13,3 +13,5 @@ export interface User {
short_name: string;
language?: string;
}
export type UserLight = Pick<User, 'full_name' | 'short_name'>;
@@ -0,0 +1,49 @@
import React from 'react';
import { Box, BoxType } from '@/components';
type AvatarSvgProps = {
initials: string;
background: string;
fontFamily?: string;
} & BoxType;
export const AvatarSvg: React.FC<AvatarSvgProps> = ({
initials,
background,
fontFamily,
...props
}) => (
<Box
as="svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
{...props}
>
<rect
x="0.5"
y="0.5"
width="23"
height="23"
rx="11.5"
ry="11.5"
fill={background}
stroke="rgba(255,255,255,0.5)"
strokeWidth="1"
/>
<text
x="50%"
y="50%"
dy="0.35em"
textAnchor="middle"
fontSize="10"
fontWeight="600"
fill="rgba(255,255,255,0.9)"
fontFamily={fontFamily || 'Arial'}
>
{initials}
</text>
</Box>
);
@@ -18,7 +18,8 @@ export const ButtonLogin = () => {
return (
<Button
onClick={() => gotoLogin()}
color="primary-text"
color="brand"
variant="tertiary"
aria-label={t('Login')}
className="--docs--button-login"
>
@@ -31,14 +32,15 @@ export const ButtonLogin = () => {
<Box
$css={css`
.--docs--button-logout:focus-visible {
box-shadow: 0 0 0 2px ${colorsTokens['primary-400']} !important;
border-radius: 4px;
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']} !important;
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Button
onClick={gotoLogout}
color="primary-text"
color="brand"
variant="tertiary"
aria-label={t('Logout')}
className="--docs--button-logout"
>
@@ -56,9 +58,13 @@ export const ProConnectButton = () => {
onClick={() => gotoLogin()}
aria-label={t('Proconnect Login')}
$css={css`
background-color: var(--c--theme--colors--primary-text);
background-color: var(
--c--contextuals--background--semantic--brand--primary
);
&:hover {
background-color: var(--c--theme--colors--primary-action);
background-color: var(
--c--contextuals--background--semantic--brand--primary-hover
);
}
`}
$radius="4px"
@@ -0,0 +1,67 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { tokens } from '@/cunningham';
import { AvatarSvg } from './AvatarSvg';
const colors = tokens.themes.default.globals.colors;
const avatarsColors = [
colors['blue-1-500'],
colors['brown-500'],
colors['green-500'],
colors['orange-500'],
colors['pink-500'],
colors['purple-500'],
colors['yellow-500'],
];
const getColorFromName = (name: string) => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return avatarsColors[Math.abs(hash) % avatarsColors.length];
};
const getInitialFromName = (name: string) => {
const splitName = name?.split(' ');
return (splitName[0]?.charAt(0) || '?') + (splitName?.[1]?.charAt(0) || '');
};
type UserAvatarProps = {
fullName?: string;
background?: string;
};
export const UserAvatar = ({ fullName, background }: UserAvatarProps) => {
const name = fullName?.trim() || '?';
return (
<AvatarSvg
className="--docs--user-avatar"
initials={getInitialFromName(name).toUpperCase()}
background={background || getColorFromName(name)}
/>
);
};
export const avatarUrlFromName = (
fullName?: string,
fontFamily?: string,
): string => {
const name = fullName?.trim() || '?';
const initials = getInitialFromName(name).toUpperCase();
const background = getColorFromName(name);
const svgMarkup = renderToStaticMarkup(
<AvatarSvg
className="--docs--user-avatar"
initials={initials}
background={background}
fontFamily={fontFamily}
/>,
);
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgMarkup)}`;
};
@@ -1,2 +1,3 @@
export * from './Auth';
export * from './ButtonLogin';
export * from './UserAvatar';
@@ -12,13 +12,16 @@ import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import {
useHeadings,
@@ -34,6 +37,7 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { cssComments, useComments } from './comments/';
import {
AccessibleImageBlock,
CalloutBlock,
@@ -79,8 +83,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { user } = useAuth();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const { themeTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const refEditorContainer = useRef<HTMLDivElement>(null);
const canSeeComment = doc.abilities.comment && isDesktop;
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
@@ -88,16 +95,25 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName = user?.full_name || user?.email || t('Anonymous');
const collabName = user?.full_name || user?.email;
const cursorName = collabName || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const threadStore = useComments(doc.id, canSeeComment, user);
const currentUserAvatarUrl = useMemo(() => {
if (canSeeComment) {
return avatarUrlFromName(collabName, themeTokens?.font?.families?.base);
}
}, [canSeeComment, collabName, themeTokens?.font?.families?.base]);
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider: provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: collabName,
name: cursorName,
color: randomColor(),
},
/**
@@ -138,11 +154,28 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
},
showCursorLabels: showCursorLabels as 'always' | 'activity',
},
comments: { threadStore },
dictionary: {
...locales[lang as keyof typeof locales],
multi_column:
multiColumnLocales?.[lang as keyof typeof multiColumnLocales],
},
resolveUsers: async (userIds) => {
return Promise.resolve(
userIds.map((encodedURIUserId) => {
const fullName = decodeURIComponent(encodedURIUserId);
return {
id: encodedURIUserId,
username: fullName || t('Anonymous'),
avatarUrl: avatarUrlFromName(
fullName,
themeTokens?.font?.families?.base,
),
};
}),
);
},
tables: {
splitCells: true,
cellBackgroundColor: true,
@@ -152,7 +185,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
uploadFile,
schema: blockNoteSchema,
},
[collabName, lang, provider, uploadFile],
[cursorName, lang, provider, uploadFile, threadStore],
);
useHeadings(editor);
@@ -170,7 +203,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box ref={refEditorContainer} $css={cssEditor}>
<Box
ref={refEditorContainer}
$css={css`
${cssEditor};
${cssComments(canSeeComment, currentUserAvatarUrl)}
`}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
@@ -180,12 +219,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
/>
</Box>
)}
<BlockNoteView
className="--docs--main-editor"
editor={editor}
formattingToolbar={false}
slashMenu={false}
theme="light"
comments={canSeeComment}
aria-label={t('Document editor')}
>
<BlockNoteSuggestionMenu />
@@ -196,11 +236,17 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
};
interface BlockNoteReaderProps {
docId: Doc['id'];
initialContent: Y.XmlFragment;
}
export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
export const BlockNoteReader = ({
docId,
initialContent,
}: BlockNoteReaderProps) => {
const { user } = useAuth();
const { setEditor } = useEditorStore();
const threadStore = useComments(docId, false, user);
const { t } = useTranslation();
const editor = useCreateBlockNote(
{
@@ -213,6 +259,10 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
provider: undefined,
},
schema: blockNoteSchema,
comments: { threadStore },
resolveUsers: async () => {
return Promise.resolve([]);
},
},
[initialContent],
);
@@ -228,15 +278,24 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
useHeadings(editor);
return (
<Box $css={cssEditor}>
<Box
$css={css`
${cssEditor};
${cssComments(false)}
`}
>
<BlockNoteView
className="--docs--main-editor"
editor={editor}
editable={false}
theme="light"
aria-label={t('Document version viewer')}
aria-label={t('Document viewer')}
formattingToolbar={false}
slashMenu={false}
/>
comments={false}
>
<BlockNoteToolbar />
</BlockNoteView>
</Box>
);
};
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core/config/api';
import { CommentToolbarButton } from '../comments/CommentToolbarButton';
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
import { AIGroupButton } from './AIButton';
@@ -25,10 +26,12 @@ export const BlockNoteToolbar = () => {
const { data: conf } = useConfig();
const toolbarItems = useMemo(() => {
const toolbarItems = getFormattingToolbarItems([
let toolbarItems = getFormattingToolbarItems([
...blockTypeSelectItems(dict),
getCalloutFormattingToolbarItems(t),
]);
// Find the index of the file download button
const fileDownloadButtonIndex = toolbarItems.findIndex(
(item) =>
typeof item === 'object' &&
@@ -36,6 +39,8 @@ export const BlockNoteToolbar = () => {
'key' in item &&
(item as { key: string }).key === 'fileDownloadButton',
);
// Replace the default file download button with our custom FileDownloadButton
if (fileDownloadButtonIndex !== -1) {
toolbarItems.splice(
fileDownloadButtonIndex,
@@ -50,12 +55,22 @@ export const BlockNoteToolbar = () => {
);
}
// Remove default Comment button
toolbarItems = toolbarItems.filter((item) => {
if (typeof item === 'object' && item !== null && 'key' in item) {
return item.key !== 'addCommentButton';
}
return true;
});
return toolbarItems;
}, [dict, t]);
const formattingToolbar = useCallback(() => {
return (
<FormattingToolbar>
<CommentToolbarButton />
{toolbarItems}
{/* Extra button to do some AI powered actions */}
@@ -6,6 +6,9 @@ import {
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Text } from '@/components';
type Block = {
type: string;
@@ -83,8 +86,18 @@ export function MarkdownButton() {
mainTooltip={t('Convert Markdown')}
onClick={handleConvertMarkdown}
className="--docs--editor-markdown-button"
>
M
</Components.FormattingToolbar.Button>
label="M"
icon={
<Text
aria-hidden={true}
$css={css`
font-family: var(--c--globals--font--families--base);
`}
$weight="bold"
>
M
</Text>
}
/>
);
}
@@ -24,14 +24,14 @@ export const ModalConfirmDownloadUnsafe = ({
<>
<Button
aria-label={t('Cancel the download')}
color="secondary"
variant="secondary"
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
<Button
aria-label={t('Download')}
color="danger"
color="error"
data-testid="modal-download-unsafe-button"
onClick={() => {
if (onConfirm) {
@@ -52,7 +52,6 @@ export const ModalConfirmDownloadUnsafe = ({
$gap="0.7rem"
$size="h6"
$align="flex-start"
$variation="1000"
$direction="row"
$margin="0"
>
@@ -67,8 +66,10 @@ export const ModalConfirmDownloadUnsafe = ({
>
<Box>
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
<Text $variation="700">{t('This file is flagged as unsafe.')}</Text>
<Text $variation="600">
<Text $variation="secondary">
{t('This file is flagged as unsafe.')}
</Text>
<Text $variation="secondary">
{t('Please download it only if it comes from a trusted source.')}
</Text>
</Box>
@@ -1,6 +1,5 @@
import clsx from 'clsx';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
@@ -97,18 +96,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
return (
<>
{isDesktop && (
<Box
$height="100vh"
$position="absolute"
$css={css`
top: 72px;
right: 20px;
`}
>
<TableContent />
</Box>
)}
{isDesktop && <TableContent />}
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docEditor={
@@ -117,6 +105,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
initialContent={provider.document.getXmlFragment(
'document-store',
)}
docId={doc.id}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
@@ -0,0 +1,85 @@
import {
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDocStore } from '@/features/docs/doc-management';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '../../types';
export const CommentToolbarButton = () => {
const Components = useComponentsContext();
const { currentDoc } = useDocStore();
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const editor = useBlockNoteEditor<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>();
const selectedBlocks = useSelectedBlocks(editor);
const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
if (
!show ||
!editor.isEditable ||
!Components ||
!currentDoc?.abilities.comment
) {
return null;
}
return (
<Box $direction="row" className="--docs--comment-toolbar-button">
<Components.Generic.Toolbar.Button
className="bn-button"
onClick={() => {
editor.comments?.startPendingComment();
editor.formattingToolbar.closeMenu();
}}
aria-haspopup="dialog"
data-test="comment-toolbar-button"
>
<Box
$direction="row"
$align="center"
$gap={spacingsTokens['xs']}
$padding={{ right: '2xs' }}
>
<Icon
iconName="comment"
className="--docs--icon-bg"
$theme="gray"
$padding="0.15rem"
$size="md"
/>
{t('Comment')}
</Box>
</Components.Generic.Toolbar.Button>
<Box
$background={colorsTokens['gray-100']}
$width="1px"
$height="70%"
$margin={{ left: 'var(--c--globals--spacings--4xs)' }}
$css={css`
align-self: center;
`}
/>
</Box>
);
};
@@ -0,0 +1,569 @@
import { CommentBody, ThreadStore } from '@blocknote/core/comments';
import type { Awareness } from 'y-protocols/awareness';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs/doc-management';
import { useEditorStore } from '../../stores';
import { DocsThreadStoreAuth } from './DocsThreadStoreAuth';
import {
ClientCommentData,
ClientThreadData,
ServerComment,
ServerReaction,
ServerThread,
} from './types';
type ServerThreadListResponse = APIList<ServerThread>;
export class DocsThreadStore extends ThreadStore {
protected static COMMENTS_PING = 'commentsPing';
protected threads: Map<string, ClientThreadData> = new Map();
private subscribers = new Set<
(threads: Map<string, ClientThreadData>) => void
>();
private awareness?: Awareness;
private lastPingAt = 0;
private pingTimer?: ReturnType<typeof setTimeout>;
constructor(
protected docId: Doc['id'],
awareness: Awareness | undefined,
protected docAuth: DocsThreadStoreAuth,
) {
super(docAuth);
if (docAuth.canSee) {
this.awareness = awareness;
this.awareness?.on('update', this.onAwarenessUpdate);
void this.refreshThreads();
}
}
public destroy() {
this.awareness?.off('update', this.onAwarenessUpdate);
if (this.pingTimer) {
clearTimeout(this.pingTimer);
}
}
private onAwarenessUpdate = async ({
added,
updated,
}: {
added: number[];
updated: number[];
}) => {
if (!this.awareness) {
return;
}
const states = this.awareness.getStates();
const listClientIds = [...added, ...updated];
for (const clientId of listClientIds) {
// Skip our own client ID
if (clientId === this.awareness.clientID) {
continue;
}
const state = states.get(clientId) as
| {
[DocsThreadStore.COMMENTS_PING]?: {
at: number;
docId: string;
isResolving: boolean;
threadId: string;
};
}
| undefined;
const ping = state?.commentsPing;
// Skip if no ping information is available
if (!ping) {
continue;
}
// Skip if the document ID doesn't match
if (ping.docId !== this.docId) {
continue;
}
// Skip if the ping timestamp is past
if (ping.at <= this.lastPingAt) {
continue;
}
this.lastPingAt = ping.at;
// If we know the threadId, schedule a targeted refresh. Otherwise, fall back to full refresh.
if (ping.threadId) {
await this.refreshThread(ping.threadId);
} else {
await this.refreshThreads();
}
}
};
/**
* To ping the other clients for updates on a specific thread
* @param threadId
*/
private ping(threadId?: string) {
this.awareness?.setLocalStateField(DocsThreadStore.COMMENTS_PING, {
at: Date.now(),
docId: this.docId,
threadId,
});
}
/**
* Notifies all subscribers about the current thread state
*/
private notifySubscribers() {
// Always emit a new Map reference to help consumers detect changes
const threads = new Map(this.threads);
this.subscribers.forEach((cb) => {
try {
cb(threads);
} catch (e) {
console.warn('DocsThreadStore subscriber threw', e);
}
});
}
private upsertClientThreadData(thread: ClientThreadData) {
const next = new Map(this.threads);
next.set(thread.id, thread);
this.threads = next;
}
private removeThread(threadId: string) {
const next = new Map(this.threads);
next.delete(threadId);
this.threads = next;
}
/**
* To subscribe to thread updates
* @param cb
* @returns
*/
public subscribe(cb: (threads: Map<string, ClientThreadData>) => void) {
if (!this.docAuth.canSee) {
return () => {};
}
this.subscribers.add(cb);
// Emit initial state asynchronously to avoid running during editor init
setTimeout(() => {
if (this.subscribers.has(cb)) {
cb(this.getThreads());
}
}, 0);
return () => {
this.subscribers.delete(cb);
};
}
public addThreadToDocument = (options: {
threadId: string;
selection: {
prosemirror: {
head: number;
anchor: number;
};
yjs: {
head: unknown;
anchor: unknown;
};
};
}) => {
const { threadId } = options;
const { editor } = useEditorStore.getState();
// Should not happen
if (!editor) {
console.warn('Editor to add thread not ready');
return Promise.resolve();
}
editor._tiptapEditor
.chain()
.focus?.()
.setMark?.('comment', { orphan: false, threadId })
.run?.();
return Promise.resolve();
};
public createThread = async (options: {
initialComment: {
body: CommentBody;
metadata?: unknown;
};
metadata?: unknown;
}) => {
const response = await fetchAPI(`documents/${this.docId}/threads/`, {
method: 'POST',
body: JSON.stringify({
body: options.initialComment.body,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to create thread in document',
await errorCauses(response),
);
}
const thread = (await response.json()) as ServerThread;
const threadData: ClientThreadData = serverThreadToClientThread(thread);
this.upsertClientThreadData(threadData);
this.notifySubscribers();
this.ping(threadData.id);
return threadData;
};
public getThread(threadId: string) {
const thread = this.threads.get(threadId);
if (!thread) {
throw new Error('Thread not found');
}
return thread;
}
public getThreads(): Map<string, ClientThreadData> {
if (!this.docAuth.canSee) {
return new Map();
}
return this.threads;
}
public async refreshThread(threadId: string) {
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/`,
{ method: 'GET' },
);
// If not OK and 404, the thread might have been deleted but the
// thread modal is still open, so we close it to avoid side effects
if (response.status === 404) {
// use escape key event to close the thread modal
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
bubbles: true,
cancelable: true,
}),
);
await this.refreshThreads();
return;
}
if (!response.ok) {
throw new APIError(
`Failed to fetch thread ${threadId}`,
await errorCauses(response),
);
}
const serverThread = (await response.json()) as ServerThread;
const clientThread = serverThreadToClientThread(serverThread);
this.upsertClientThreadData(clientThread);
this.notifySubscribers();
}
public async refreshThreads(): Promise<void> {
const response = await fetchAPI(`documents/${this.docId}/threads/`, {
method: 'GET',
});
if (!response.ok) {
throw new APIError(
'Failed to get threads in document',
await errorCauses(response),
);
}
const threads = (await response.json()) as ServerThreadListResponse;
const next = new Map<string, ClientThreadData>();
threads.results.forEach((thread) => {
const threadData: ClientThreadData = serverThreadToClientThread(thread);
next.set(thread.id, threadData);
});
this.threads = next;
this.notifySubscribers();
}
public addComment = async (options: {
comment: {
body: CommentBody;
metadata?: unknown;
};
threadId: string;
}) => {
const { threadId } = options;
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/comments/`,
{
method: 'POST',
body: JSON.stringify({
body: options.comment.body,
}),
},
);
if (!response.ok) {
throw new APIError('Failed to add comment ', await errorCauses(response));
}
const comment = (await response.json()) as ServerComment;
// Optimistically update local thread with new comment
const existing = this.threads.get(threadId);
if (existing) {
const updated: ClientThreadData = {
...existing,
updatedAt: new Date(comment.updated_at || comment.created_at),
comments: [...existing.comments, serverCommentToClientComment(comment)],
};
this.upsertClientThreadData(updated);
this.notifySubscribers();
} else {
// Fallback to fetching the thread if we don't have it locally
await this.refreshThread(threadId);
}
this.ping(threadId);
return serverCommentToClientComment(comment);
};
public updateComment = async (options: {
comment: {
body: CommentBody;
metadata?: unknown;
};
threadId: string;
commentId: string;
}) => {
const { threadId, commentId, comment } = options;
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/comments/${commentId}/`,
{
method: 'PUT',
body: JSON.stringify({
body: comment.body,
}),
},
);
if (!response.ok) {
throw new APIError(
'Failed to add thread to document',
await errorCauses(response),
);
}
await this.refreshThread(threadId);
this.ping(threadId);
return;
};
public deleteComment = async (options: {
threadId: string;
commentId: string;
softDelete?: boolean;
}) => {
const { threadId, commentId } = options;
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/comments/${commentId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete comment',
await errorCauses(response),
);
}
// Optimistically remove the comment locally if we have the thread
const existing = this.threads.get(threadId);
if (existing) {
const updated: ClientThreadData = {
...existing,
updatedAt: new Date(),
comments: existing.comments.filter((c) => c.id !== commentId),
};
this.upsertClientThreadData(updated);
this.notifySubscribers();
} else {
// Fallback to fetching the thread
await this.refreshThread(threadId);
}
this.ping(threadId);
};
/**
* UI not implemented
* @param _options
*/
public deleteThread = async (_options: { threadId: string }) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${_options.threadId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete thread',
await errorCauses(response),
);
}
// Remove locally and notify; no need to refetch everything
this.removeThread(_options.threadId);
this.notifySubscribers();
this.ping(_options.threadId);
};
public resolveThread = async (_options: { threadId: string }) => {
const { threadId } = _options;
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/resolve/`,
{ method: 'POST' },
);
if (!response.ok) {
throw new APIError(
'Failed to resolve thread',
await errorCauses(response),
);
}
await this.refreshThreads();
this.ping(threadId);
};
/**
* Todo: Not implemented backend side
* @returns
* @throws
*/
public unresolveThread = async (_options: { threadId: string }) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${_options.threadId}/unresolve/`,
{ method: 'POST' },
);
if (!response.ok) {
throw new APIError(
'Failed to unresolve thread',
await errorCauses(response),
);
}
await this.refreshThread(_options.threadId);
this.ping(_options.threadId);
};
public addReaction = async (options: {
threadId: string;
commentId: string;
emoji: string;
}) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${options.threadId}/comments/${options.commentId}/reactions/`,
{
method: 'POST',
body: JSON.stringify({ emoji: options.emoji }),
},
);
if (!response.ok) {
throw new APIError(
'Failed to add reaction to comment',
await errorCauses(response),
);
}
await this.refreshThread(options.threadId);
this.notifySubscribers();
this.ping(options.threadId);
};
public deleteReaction = async (options: {
threadId: string;
commentId: string;
emoji: string;
}) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${options.threadId}/comments/${options.commentId}/reactions/`,
{ method: 'DELETE', body: JSON.stringify({ emoji: options.emoji }) },
);
if (!response.ok) {
throw new APIError(
'Failed to delete reaction from comment',
await errorCauses(response),
);
}
await this.refreshThread(options.threadId);
this.notifySubscribers();
this.ping(options.threadId);
};
}
const serverReactionToReactionData = (r: ServerReaction) => {
return {
emoji: r.emoji,
createdAt: new Date(r.created_at),
userIds: r.users?.map((user) =>
encodeURIComponent(user.full_name || ''),
) || [''],
};
};
const serverCommentToClientComment = (c: ServerComment): ClientCommentData => ({
type: 'comment',
id: c.id,
userId: encodeURIComponent(c.user?.full_name || ''),
body: c.body,
createdAt: new Date(c.created_at),
updatedAt: new Date(c.updated_at),
reactions: (c.reactions ?? []).map(serverReactionToReactionData),
metadata: { abilities: c.abilities },
});
const serverThreadToClientThread = (t: ServerThread): ClientThreadData => ({
type: 'thread',
id: t.id,
createdAt: new Date(t.created_at),
updatedAt: new Date(t.updated_at),
comments: (t.comments ?? []).map(serverCommentToClientComment),
resolved: t.resolved,
resolvedUpdatedAt: t.resolved_updated_at
? new Date(t.resolved_updated_at)
: undefined,
resolvedBy: t.resolved_by || undefined,
metadata: { abilities: t.abilities, metadata: t.metadata },
});
@@ -0,0 +1,94 @@
import { ThreadStoreAuth } from '@blocknote/core/comments';
import { ClientCommentData, ClientThreadData } from './types';
export class DocsThreadStoreAuth extends ThreadStoreAuth {
constructor(
private readonly userId: string,
public canSee: boolean,
) {
super();
}
canCreateThread(): boolean {
return true;
}
canAddComment(_thread: ClientThreadData): boolean {
return true;
}
canUpdateComment(comment: ClientCommentData): boolean {
if (
comment.metadata.abilities.partial_update &&
comment.userId === this.userId
) {
return true;
}
return false;
}
canDeleteComment(comment: ClientCommentData): boolean {
if (comment.metadata.abilities.destroy) {
return true;
}
return false;
}
canDeleteThread(thread: ClientThreadData): boolean {
if (thread.metadata.abilities.destroy) {
return true;
}
return false;
}
canResolveThread(thread: ClientThreadData): boolean {
if (thread.metadata.abilities.resolve) {
return true;
}
return false;
}
/**
* Not implemented backend side
* @param _thread
* @returns
*/
canUnresolveThread(_thread: ClientThreadData): boolean {
return false;
}
canAddReaction(comment: ClientCommentData, emoji?: string): boolean {
if (!comment.metadata.abilities.reactions) {
return false;
}
if (!emoji) {
return true;
}
return !comment.reactions.some(
(reaction) =>
reaction.emoji === emoji && reaction.userIds.includes(this.userId),
);
}
canDeleteReaction(comment: ClientCommentData, emoji?: string): boolean {
if (!comment.metadata.abilities.reactions) {
return false;
}
if (!emoji) {
return true;
}
return comment.reactions.some(
(reaction) =>
reaction.emoji === emoji && reaction.userIds.includes(this.userId),
);
}
}
@@ -0,0 +1,3 @@
export * from './CommentToolbarButton';
export * from './styles';
export * from './useComments';
@@ -0,0 +1,215 @@
import { css } from 'styled-components';
export const cssComments = (
canSeeComment: boolean,
currentUserAvatarUrl?: string,
) => css`
& .--docs--main-editor,
& .--docs--main-editor .ProseMirror {
// Comments marks in the editor
.bn-editor {
.bn-thread-mark:not([data-orphan='true']),
.bn-thread-mark-selected:not([data-orphan='true']) {
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
color: var(--c--globals--colors--greyscale-700);
}
}
em-emoji-picker {
box-shadow: 0px 6px 18px 0px #00001229;
min-height: 420px;
}
// Thread modal
.bn-thread {
width: 400px;
padding: 8px;
box-shadow: 0px 6px 18px 0px #00001229;
margin-left: 20px;
gap: 0;
overflow: auto;
max-height: 500px;
.bn-default-styles {
font-family: var(--c--globals--font--families--base);
}
.bn-block {
font-size: 14px;
}
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before {
font-style: normal;
font-size: 14px;
}
// Remove tooltip
*[role='tooltip'] {
display: none;
}
.bn-thread-comment {
padding: 8px;
& .bn-editor {
padding-left: var(--c--globals--spacings--lg);
.bn-inline-content {
color: var(--c--globals--colors--greyscale-700);
}
}
// Emoji
& .bn-badge-group {
padding-left: var(--c--globals--spacings--lg);
.bn-badge label {
padding: var(--c--globals--spacings--0)
var(--c--globals--spacings--st);
background: none;
border: 1px solid var(--c--globals--colors--greyscale-300);
border-radius: var(--c--globals--spacings--st);
height: var(--c--globals--spacings--md);
}
}
// Top bar (Name / Date / Actions) when actions displayed
&:has(.bn-comment-actions) {
& > .mantine-Group-root {
max-width: 70%;
right: 0.3rem !important;
top: 0.3rem !important;
}
.bn-menu-dropdown {
box-shadow: 0px 0px 6px 0px #0000911a;
}
}
// Top bar (Name / Date / Actions)
& > .mantine-Group-root {
flex-wrap: nowrap;
max-width: 100%;
gap: 0.5rem;
// Date
span.mantine-focus-auto {
display: inline-block;
}
.bn-comment-actions {
background: transparent;
border: none;
.mantine-Button-root {
background-color: transparent;
&:hover {
background-color: var(--c--globals--colors--greyscale-100);
}
}
button[role='menuitem'] svg {
color: var(--c--globals--colors--greyscale-600);
}
}
& svg {
color: var(--c--globals--colors--info-600);
}
}
// Actions button edit comment
.bn-container + .bn-comment-actions-wrapper {
.bn-comment-actions {
flex-direction: row-reverse;
background: none;
border: none;
gap: 0.4rem !important;
& > button {
height: var(--c--globals--spacings--md);
padding-inline: var(--c--globals--spacings--st);
&[data-test='save'] {
border: 1px solid var(--c--globals--colors--info-600);
background: var(--c--globals--colors--info-600);
color: white;
}
&[data-test='cancel'] {
background: white;
border: 1px solid var(--c--globals--colors--greyscale-300);
color: var(--c--globals--colors--info-600);
}
}
}
}
}
// Input to add a new comment
.bn-thread-composer,
&:has(> .bn-comment-editor + .bn-comment-actions-wrapper) {
padding: 0.5rem 8px;
flex-direction: row;
gap: 10px;
.bn-container.bn-comment-editor {
min-width: 0;
}
&::before {
content: '';
width: 26px;
height: 26px;
flex: 0 0 26px;
background-image: ${currentUserAvatarUrl
? `url("${currentUserAvatarUrl}")`
: 'none'};
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
}
// Actions button send comment
.bn-thread-composer .bn-comment-actions-wrapper,
&:not(.selected) .bn-comment-actions-wrapper {
flex-basis: fit-content;
.bn-action-toolbar.bn-comment-actions {
border: none;
button {
font-size: 0;
background: var(--c--globals--colors--info-600);
width: var(--c--globals--spacings--md);
height: var(--c--globals--spacings--md);
padding: var(--c--globals--spacings--0);
&:disabled {
background: var(--c--globals--colors--greyscale-300);
}
& .mantine-Button-label::before {
content: '🡡';
font-size: 13px;
color: var(--c--globals--colors--greyscale-100);
}
}
}
}
// Input first comment
&:not(.selected) {
gap: 0.5rem;
.bn-container.bn-comment-editor {
min-width: 0;
.ProseMirror.bn-editor {
cursor: text;
}
}
}
}
}
`;
@@ -0,0 +1,55 @@
import { CommentData, ThreadData } from '@blocknote/core/comments';
import { UserLight } from '@/features/auth';
export interface CommentAbilities {
destroy: boolean;
update: boolean;
partial_update: boolean;
retrieve: boolean;
reactions: boolean;
}
export interface ThreadAbilities {
destroy: boolean;
update: boolean;
partial_update: boolean;
retrieve: boolean;
resolve: boolean;
}
export interface ServerReaction {
emoji: string;
created_at: string;
users: UserLight[] | null;
}
export interface ServerComment {
id: string;
user: UserLight | null;
body: unknown;
created_at: string;
updated_at: string;
reactions: ServerReaction[];
abilities: CommentAbilities;
}
export interface ServerThread {
id: string;
created_at: string;
updated_at: string;
user: UserLight | null;
resolved: boolean;
resolved_updated_at: string | null;
resolved_by: string | null;
metadata: unknown;
comments: ServerComment[];
abilities: ThreadAbilities;
}
export type ClientCommentData = Omit<CommentData, 'metadata'> & {
metadata: { abilities: CommentAbilities };
};
export type ClientThreadData = Omit<ThreadData, 'metadata'> & {
metadata: { abilities: ThreadAbilities; metadata: unknown };
};
@@ -0,0 +1,33 @@
import { useEffect, useMemo } from 'react';
import { User } from '@/features/auth';
import { Doc, useProviderStore } from '@/features/docs/doc-management';
import { DocsThreadStore } from './DocsThreadStore';
import { DocsThreadStoreAuth } from './DocsThreadStoreAuth';
export function useComments(
docId: Doc['id'],
canComment: boolean,
user: User | null | undefined,
) {
const { provider } = useProviderStore();
const threadStore = useMemo(() => {
return new DocsThreadStore(
docId,
provider?.awareness ?? undefined,
new DocsThreadStoreAuth(
encodeURIComponent(user?.full_name || ''),
canComment,
),
);
}, [docId, canComment, provider?.awareness, user?.full_name]);
useEffect(() => {
return () => {
threadStore?.destroy();
};
}, [threadStore]);
return threadStore;
}
@@ -21,8 +21,8 @@ import emojidata from './initEmojiCallout';
const CalloutBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="callout"][data-background-color] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
border-radius: var(--c--globals--spacings--3xs);
}
`;
@@ -59,9 +59,15 @@ const UploadLoaderBlockComponent = ({
editor,
}: UploadLoaderBlockComponentProps) => {
const mediaUrl = useMediaUrl();
const isEditable = editor.isEditable;
useEffect(() => {
if (!block.props.blockUploadUrl || block.props.type !== 'loading') {
const shouldCheckStatus =
block.props.blockUploadUrl &&
block.props.type === 'loading' &&
isEditable;
if (!shouldCheckStatus) {
return;
}
@@ -69,27 +75,31 @@ const UploadLoaderBlockComponent = ({
loopCheckDocMediaStatus(url)
.then((response) => {
// Replace the loading block with the resource block (image, audio, video, pdf ...)
try {
editor.replaceBlocks(
[block.id],
[
{
type: block.props.blockUploadType,
props: {
url: `${mediaUrl}${response.file}`,
showPreview: block.props.blockUploadShowPreview,
name: block.props.blockUploadName,
caption: '',
backgroundColor: 'default',
textAlignment: 'left',
},
} as never,
],
);
} catch {
/* During collaboration, another user might have updated the block */
}
// Add random delay to reduce collision probability during collaboration
const randomDelay = Math.random() * 800;
setTimeout(() => {
// Replace the loading block with the resource block (image, audio, video, pdf ...)
try {
editor.replaceBlocks(
[block.id],
[
{
type: block.props.blockUploadType,
props: {
url: `${mediaUrl}${response.file}`,
showPreview: block.props.blockUploadShowPreview,
name: block.props.blockUploadName,
caption: '',
backgroundColor: 'default',
textAlignment: 'left',
},
} as never,
],
);
} catch {
/* During collaboration, another user might have updated the block */
}
}, randomDelay);
})
.catch((error) => {
console.error('Error analyzing file:', error);
@@ -108,7 +118,7 @@ const UploadLoaderBlockComponent = ({
/* During collaboration, another user might have updated the block */
}
});
}, [block, editor, mediaUrl]);
}, [block, editor, mediaUrl, isEditable]);
return (
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
@@ -26,14 +26,19 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
content: 'none',
},
{
render: ({ inlineContent, updateInlineContent }) => {
render: ({ editor, inlineContent, updateInlineContent }) => {
const { data: doc } = useDoc({ id: inlineContent.props.docId });
const isEditable = editor.isEditable;
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (doc?.title && doc.title !== inlineContent.props.title) {
if (
isEditable &&
doc?.title &&
doc.title !== inlineContent.props.title
) {
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
@@ -50,7 +55,7 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title]);
}, [doc?.title, isEditable]);
return <LinkSelected {...inlineContent.props} />;
},
@@ -86,9 +91,10 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
margin-right: 0.2rem;
}
&:hover {
background-color: ${colorsTokens['greyscale-100']};
background-color: ${colorsTokens['gray-100']};
}
transition: background-color 0.2s ease-in-out;
transition: background-color var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
.--docs--doc-deleted & {
pointer-events: none;
@@ -98,7 +104,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
{emoji ? (
<Text $size="16px">{emoji}</Text>
) : (
<SelectedPageIcon width={11.5} color={colorsTokens['primary-400']} />
<SelectedPageIcon width={11.5} color={colorsTokens['brand-400']} />
)}
<Text
$weight="500"
@@ -35,11 +35,11 @@ import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search';
import { useResponsiveStore } from '@/stores';
const inputStyle = css`
background-color: var(--c--theme--colors--greyscale-100);
background-color: var(--c--globals--colors--gray-100);
border: none;
outline: none;
color: var(--c--theme--colors--greyscale-700);
font-size: 16px;
color: var(--c--globals--colors--gray-700);
font-size: var(--c--globals--font--sizes--md);
width: 100%;
font-family: 'Inter';
`;
@@ -86,6 +86,7 @@ export const SearchPage = ({
const [search, setSearch] = useState('');
const { isDesktop } = useResponsiveStore();
const { untitledDocument } = useTrans();
const isEditable = editor.isEditable;
/**
* createReactInlineContentSpec add automatically the focus after
@@ -101,6 +102,10 @@ export const SearchPage = ({
}, [inputRef]);
const closeSearch = (insertContent: string) => {
if (!isEditable) {
return;
}
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
@@ -154,8 +159,8 @@ export const SearchPage = ({
<Box
as="span"
className="inline-content"
$background={colorsTokens['greyscale-100']}
$color="var(--c--theme--colors--greyscale-700)"
$background={colorsTokens['gray-100']}
$color="var(--c--globals--colors--gray-700)"
$direction="row"
$radius="3px"
$padding="1px"
@@ -193,9 +198,9 @@ export const SearchPage = ({
<QuickSearch showInput={false}>
<Card
$css={css`
box-shadow: 0 0 3px 0px var(--c--theme--colors--greyscale-200);
box-shadow: 0 0 3px 0px var(--c--globals--colors--gray-200);
& > div {
margin-top: 0;
margin-top: var(--c--globals--spacings--0);
& [cmdk-group-heading] {
padding: 0.4rem;
margin: 0;
@@ -223,6 +228,10 @@ export const SearchPage = ({
search={search}
filters={{ target: DocSearchTarget.CURRENT }}
onSelect={(doc) => {
if (!isEditable) {
return;
}
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
@@ -278,8 +287,8 @@ export const SearchPage = ({
</Box>
<Text
$size="14px"
$color="var(--c--theme--colors--greyscale-1000)"
$size="sm"
$color="var(--c--globals--colors--gray-1000)"
spellCheck="false"
>
{titleWithoutEmoji}
@@ -287,11 +296,7 @@ export const SearchPage = ({
</Box>
}
right={
<Icon
iconName="keyboard_return"
$variation="600"
spellCheck="false"
/>
<Icon iconName="keyboard_return" spellCheck="false" />
}
/>
);
@@ -308,7 +313,7 @@ export const SearchPage = ({
<Box
$css={css`
border-top: 1px solid
var(--c--theme--colors--greyscale-200);
var(--c--globals--colors--gray-200);
`}
$width="100%"
>
@@ -323,15 +328,15 @@ export const SearchPage = ({
$css={css`
&:hover {
background-color: var(
--c--theme--colors--greyscale-100
--c--globals--colors--gray-100
);
}
`}
>
<AddPageIcon />
<Text
$size="14px"
$color="var(--c--theme--colors--greyscale-1000)"
$size="sm"
$color="var(--c--globals--colors--gray-1000)"
contentEditable={false}
>
{t('New sub-doc')}
@@ -9,11 +9,33 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
useEffect(() => {
setHeadings(editor);
const unsubscribe = editor?.onChange(() => {
setHeadings(editor);
let timeoutId: NodeJS.Timeout;
const DEBOUNCE_DELAY = 500;
const unsubscribe = editor?.onChange((_, context) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
return;
}
const blockChanges = blocksChanges[0];
if (
blockChanges.type !== 'update' ||
blockChanges.block.type !== 'heading'
) {
return;
}
setHeadings(editor);
}, DEBOUNCE_DELAY);
});
return () => {
clearTimeout(timeoutId);
resetHeadings();
unsubscribe();
};
@@ -1,3 +1,4 @@
import { Block } from '@blocknote/core';
import { captureException } from '@sentry/nextjs';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -36,73 +37,93 @@ export const useUploadFile = (docId: string) => {
};
};
/**
* When we upload a file it can takes some time to analyze it (e.g. virus scan).
* This hook listen to upload end and replace the uploaded block by a uploadLoader
* block to show analyzing status.
* The uploadLoader block will then handle the status display until the analysis is done
* then replaced by the final block (e.g. image, pdf, etc.).
* @param editor
*/
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
const ANALYZE_URL = 'media-check';
const { t } = useTranslation();
useEffect(() => {
const unsubscribe = editor.onChange((_, context) => {
const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
return;
}
const blockChanges = blocksChanges[0];
/**
* Replace the resource block by a uploadLoader block to show analyzing status
*/
const replaceBlockWithUploadLoader = useCallback(
(block: Block) => {
if (
blockChanges.source.type !== 'local' ||
blockChanges.type !== 'update' ||
!('url' in blockChanges.block.props) ||
('url' in blockChanges.block.props &&
!blockChanges.block.props.url.includes(ANALYZE_URL))
!block ||
!('url' in block.props) ||
('url' in block.props && !block.props.url.includes(ANALYZE_URL))
) {
return;
}
const blockUploadUrl = blockChanges.block.props.url;
const blockUploadType = blockChanges.block.type;
const blockUploadName = blockChanges.block.props.name;
const blockUploadUrl = block.props.url;
const blockUploadType = block.type;
const blockUploadName = block.props.name;
const blockUploadShowPreview =
('showPreview' in blockChanges.block.props &&
blockChanges.block.props.showPreview) ||
false;
('showPreview' in block.props && block.props.showPreview) || false;
const timeoutId = setTimeout(() => {
// Replace the resource block by a uploadLoader block
// to show analyzing status
try {
editor.replaceBlocks(
[blockChanges.block.id],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
blockUploadName,
blockUploadType,
blockUploadUrl,
blockUploadShowPreview,
},
try {
editor.replaceBlocks(
[block.id],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
blockUploadName,
blockUploadType,
blockUploadUrl,
blockUploadShowPreview,
},
],
);
} catch (error) {
captureException(error, {
extra: { info: 'Error replacing block for upload loader' },
});
}
}, 250);
},
],
);
} catch (error) {
captureException(error, {
extra: { info: 'Error replacing block for upload loader' },
});
}
},
[editor, t],
);
useEffect(() => {
const imagesBlocks = editor?.document.filter(
(block) =>
block.type === 'image' && block.props.url.includes(ANALYZE_URL),
);
imagesBlocks.forEach((block) => {
replaceBlockWithUploadLoader(block as Block);
});
}, [editor, replaceBlockWithUploadLoader]);
/**
* Handle upload end to replace the upload block by a uploadLoader
* block to show analyzing status
*/
useEffect(() => {
editor.onUploadEnd((blockId) => {
if (!blockId) {
return;
}
const innerTimeoutId = setTimeout(() => {
const block = editor.getBlock({ id: blockId });
replaceBlockWithUploadLoader(block as Block);
}, 300);
return () => {
clearTimeout(timeoutId);
unsubscribe();
clearTimeout(innerTimeoutId);
};
});
return () => {
unsubscribe();
};
}, [editor, t]);
}, [editor, replaceBlockWithUploadLoader]);
};
@@ -8,7 +8,7 @@ export const cssEditor = css`
}
& .bn-editor {
color: var(--c--theme--colors--greyscale-700);
color: var(--c--globals--colors--greyscale-700);
}
/**
@@ -104,14 +104,14 @@ export const cssEditor = css`
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block > .bn-block-content[data-background-color] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
@@ -127,7 +127,7 @@ export const cssEditor = css`
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-600);
color: var(--c--globals--colors--greyscale-600);
cursor: pointer;
}
.bn-block-group
@@ -136,11 +136,15 @@ export const cssEditor = css`
border-left: none;
}
.bn-toolbar {
max-width: 95vw;
}
/**
* Quotes
*/
blockquote {
border-left: 4px solid var(--c--theme--colors--greyscale-300);
border-left: 4px solid var(--c--globals--colors--gray-300);
font-style: italic;
}
@@ -174,7 +174,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
<>
<Button
aria-label={t('Cancel the download')}
color="secondary"
variant="secondary"
fullWidth
onClick={() => onClose()}
>
@@ -183,7 +183,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
<Button
data-testid="doc-export-download-button"
aria-label={t('Download')}
color="primary"
variant="primary"
fullWidth
onClick={() => void onSubmit()}
disabled={isExporting}
@@ -205,7 +205,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
$margin="0"
id="modal-export-title"
$size="h6"
$variation="1000"
$align="flex-start"
data-testid="modal-export-title"
>
@@ -225,7 +224,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
$gap="1rem"
className="--docs--modal-export-content"
>
<Text $variation="600" $size="sm" as="p">
<Text $variation="secondary" $size="sm" as="p">
{t('Download your document in a .docx, .odt or .pdf format.')}
</Text>
<Select
@@ -2,36 +2,37 @@ import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { Box, BoxButton, Card, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export const AlertNetwork = () => {
const { t } = useTranslation();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<Box>
<Box
<Card
$direction="row"
$justify="space-between"
$width="100%"
$background={colorsTokens['warning-100']}
$radius={spacingsTokens['3xs']}
$padding="xs"
$flex={1}
$align="center"
$gap={spacingsTokens['2xs']}
$css={css`
border: 1px solid var(--c--theme--colors--warning-300);
`}
$theme="warning"
>
<Box $direction="row" $gap={spacingsTokens['2xs']} $align="center">
<Icon iconName="mobiledata_off" $theme="warning" $variation="600" />
<Text $theme="warning" $variation="600" $weight={500}>
<Box
$direction="row"
$gap={spacingsTokens['2xs']}
$align="center"
$withThemeInherited
>
<Icon iconName="mobiledata_off" $withThemeInherited />
<Text $withThemeInherited $weight={500}>
{t('Others are editing. Your network prevent changes.')}
</Text>
</Box>
@@ -40,20 +41,20 @@ export const AlertNetwork = () => {
$gap={spacingsTokens['3xs']}
$align="center"
onClick={() => setIsModalOpen(true)}
$withThemeInherited
>
<Icon
iconName="info"
$theme="warning"
$variation="600"
$size="16px"
$withThemeInherited
$size="md"
$weight="500"
$margin={{ top: 'auto' }}
/>
<Text $theme="warning" $variation="600" $weight="500" $size="xs">
<Text $withThemeInherited $weight="500" $size="xs">
{t('Learn more')}
</Text>
</BoxButton>
</Box>
</Card>
</Box>
{isModalOpen && (
<AlertNetworkModal onClose={() => setIsModalOpen(false)} />
@@ -74,20 +75,14 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
onClose={() => onClose()}
rightActions={
<>
<Button aria-label={t('OK')} onClick={onClose} color="danger">
<Button aria-label={t('OK')} onClick={onClose} color="error">
{t('I understand')}
</Button>
</>
}
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("Why you can't edit the document?")}
</Text>
}
@@ -97,14 +92,14 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
className="--docs--modal-alert-network"
$margin={{ top: 'md' }}
>
<Text $size="sm" $variation="600">
<Text $size="sm" $variation="secondary">
{t(
'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.',
)}
</Text>
<Text
$size="sm"
$variation="600"
$variation="secondary"
$margin={{ top: 'xs' }}
$weight="bold"
$display="inline"
@@ -112,7 +107,7 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
{t("This means you can't edit until others leave.")}{' '}
<Text
$size="sm"
$variation="600"
$variation="secondary"
$margin={{ top: 'xs' }}
$weight="normal"
$display="inline"
@@ -1,39 +1,33 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Card, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export const AlertPublic = ({ isPublicDoc }: { isPublicDoc: boolean }) => {
const { t } = useTranslation();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
return (
<Box
<Card
aria-label={t('Public document')}
$color={colorsTokens['primary-800']}
$background={colorsTokens['primary-050']}
$radius={spacingsTokens['3xs']}
$direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacingsTokens['2xs']}
$css={css`
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
`}
$theme="brand"
>
<Icon
$theme="primary"
$variation="800"
$withThemeInherited
data-testid="public-icon"
iconName={isPublicDoc ? 'public' : 'vpn_lock'}
/>
<Text $theme="primary" $variation="800" $weight="500">
<Text $withThemeInherited $weight="500">
{isPublicDoc
? t('Public document')
: t('Document accessible to any connected person')}
</Text>
</Box>
</Card>
);
};
@@ -1,9 +1,12 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { Box, Card, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
@@ -17,7 +20,7 @@ export const AlertRestore = ({ doc }: { doc: Doc }) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const treeContext = useTreeContext<Doc>();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const { mutate: restoreDoc, error } = useRestoreDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN, KEY_DOC],
options: {
@@ -44,57 +47,52 @@ export const AlertRestore = ({ doc }: { doc: Doc }) => {
});
return (
<Box
<Card
className="--docs--alert-restore"
aria-label={t('Alert deleted document')}
$color={colorsTokens['danger-800']}
$background={colorsTokens['danger-100']}
$radius={spacingsTokens['3xs']}
$direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacingsTokens['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--danger-300, #e3e3fd);
`}
$justify="space-between"
$theme="error"
>
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<Box
$withThemeInherited
$direction="row"
$align="center"
$gap={spacingsTokens['2xs']}
>
<Icon
$theme="danger"
$variation="700"
$withThemeInherited
data-testid="public-icon"
iconName="delete"
variant="symbols-outlined"
/>
<Text $theme="danger" $variation="700" $weight="500">
{t('Document deleted')}
</Text>
{t('Document deleted')}
</Box>
<BoxButton
<Button
onClick={() =>
restoreDoc({
docId: doc.id,
})
}
$direction="row"
$gap="0.2rem"
$theme="danger"
$variation="600"
$align="center"
color="error"
variant="tertiary"
size="nano"
icon={
<Icon
iconName="undo"
$withThemeInherited
$size="18px"
variant="symbols-outlined"
/>
}
>
<Icon
iconName="undo"
$theme="danger"
$variation="600"
$size="18px"
variant="symbols-outlined"
/>
<Text $theme="danger" $variation="600" $size="s" $css="line-height:1;">
{t('Restore')}
</Text>
</BoxButton>
</Box>
Restore
</Button>
</Card>
);
};
@@ -46,20 +46,19 @@ export const BoutonShare = ({
<Box
$css={css`
.c__button--medium {
height: 32px;
padding: 10px var(--c--theme--spacings--xs);
height: var(--c--globals--spacings--lg);
padding: 10px var(--c--globals--spacings--xs);
gap: 7px;
}
`}
>
<Button
color="tertiary"
aria-label={t('Share button')}
variant="secondary"
icon={
<Icon
iconName="group"
$theme="primary"
$variation="800"
$color="inherit"
variant="filled"
disabled={isDisabled}
/>
@@ -76,7 +75,8 @@ export const BoutonShare = ({
return (
<Button
color="primary-text"
color="brand"
variant="tertiary"
onClick={open}
size="medium"
disabled={isDisabled}
@@ -47,15 +47,15 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
return (
<>
<Text
$variation="600"
$variation="tertiary"
$size="s"
$weight="bold"
$theme={isEditable ? 'greyscale' : 'warning'}
$theme={isEditable ? 'gray' : 'warning'}
>
{transRole(isEditable ? doc.user_role || doc.link_role : Role.READER)}
&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
<Text $variation="tertiary" $size="s">
{dateToDisplay}
</Text>
</>
@@ -64,11 +64,11 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
return (
<>
<Text $variation="400" $size="s">
<Text $variation="tertiary" $size="s">
{hasChildren ? relativeOnly : dateToDisplay}
</Text>
{hasChildren && (
<Text $variation="400" $size="s">
<Text $variation="tertiary" $size="s">
&nbsp;&nbsp;
{t('Contains {{count}} sub-documents', {
count: childrenCount,

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