From d129cc043b199355485467548f4875d40a821724 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Tue, 26 Aug 2025 17:55:53 +0200 Subject: [PATCH 01/14] =?UTF-8?q?=E2=9C=A8(backend)=20add=20commentator=20?= =?UTF-8?q?role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To allow a user to comment a document we added a new role: commentator. Commentator is higher than reader but lower than editor. --- src/backend/core/choices.py | 2 + src/backend/core/models.py | 9 +- .../documents/test_api_document_accesses.py | 39 ++-- .../documents/test_api_documents_retrieve.py | 15 +- .../documents/test_api_documents_trashbin.py | 5 +- .../tests/test_models_document_accesses.py | 18 +- .../core/tests/test_models_documents.py | 221 +++++++++++++++--- 7 files changed, 248 insertions(+), 61 deletions(-) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index e6b975111a..8505ebab87 100644 --- a/src/backend/core/choices.py +++ b/src/backend/core/choices.py @@ -33,6 +33,7 @@ class LinkRoleChoices(PriorityTextChoices): """Defines the possible roles a link can offer on a document.""" READER = "reader", _("Reader") # Can read + COMMENTATOR = "commentator", _("Commentator") # 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 + COMMENTATOR = "commentator", _("Commentator") # Can read and comment EDITOR = "editor", _("Editor") # Can read and edit ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share OWNER = "owner", _("Owner") diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 6e0ad69e4f..acc3e11572 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -756,6 +756,7 @@ def get_abilities(self, user): can_update = ( is_owner_or_admin or role == RoleChoices.EDITOR ) and not is_deleted + can_comment = (can_update or role == RoleChoices.COMMENTATOR) and not is_deleted can_create_children = can_update and user.is_authenticated can_destroy = ( is_owner @@ -786,6 +787,7 @@ def get_abilities(self, user): "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 @@ def get_abilities(self, user): set_role_to = [] if is_owner_or_admin: set_role_to.extend( - [RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN] + [ + RoleChoices.READER, + RoleChoices.COMMENTATOR, + RoleChoices.EDITOR, + RoleChoices.ADMIN, + ] ) if role == RoleChoices.OWNER: set_role_to.append(RoleChoices.OWNER) diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 280b2bc921..cdb3d4d63a 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -293,6 +293,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): } assert result_dict[str(document_access_other_user.id)] == [ "reader", + "commentator", "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="commentator" ) 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)] == [ + "commentator", "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", + "commentator", "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", "commentator", "editor", "administrator"], [], [], - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "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", "commentator", "editor", "administrator"], [], [], - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], [ ["reader", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], [ ["reader", "administrator", "reader", "editor"], [ - ["reader", "editor", "administrator"], - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], + ["reader", "commentator", "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", "commentator", "editor", "administrator"], [], ["editor", "administrator"], [], diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index fa8b1e2eb6..de158fba44 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -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 ["commentator", "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", "commentator", "editor"], + "public": ["reader", "commentator", "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 ["commentator", "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 ["commentator", "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", "commentator", "editor"], + "public": ["reader", "commentator", "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 ["commentator", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -497,10 +501,11 @@ def test_api_documents_retrieve_authenticated_related_parent(): "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", - "can_edit": access.role != "reader", + "can_edit": access.role not in ["reader", "commentator"], "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, + "comment": access.role != "reader", "descendants": True, "cors_proxy": True, "content": True, diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 98a218acc2..28ea6e8bfa 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -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", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": False, diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index 2fa88cf1fb..eb7675c00c 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "editor", "administrator"], } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 69236b6e96..49fb3070c2 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth): [ (True, "restricted", "reader"), (True, "restricted", "editor"), + (True, "restricted", "commentator"), (False, "restricted", "reader"), (False, "restricted", "editor"), + (False, "restricted", "commentator"), (False, "authenticated", "reader"), (False, "authenticated", "editor"), + (False, "authenticated", "commentator"), ], ) 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", "commentator", "editor"], + "public": ["reader", "commentator", "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,77 @@ def test_models_documents_get_abilities_reader( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "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_commentator( + is_authenticated, reach, django_assert_num_queries +): + """ + Check abilities returned for a document giving commentator 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="commentator") + 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, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": is_authenticated, + "favorite": is_authenticated, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -289,6 +363,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 +373,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", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -344,6 +419,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 +429,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", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -430,6 +506,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 +516,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", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -485,6 +562,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 +572,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", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -547,6 +625,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 ["commentator", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -556,8 +636,72 @@ 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", "commentator", "editor"], + "public": ["reader", "commentator", "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_commentator_user( + ai_access_setting, django_assert_num_queries +): + """Check abilities returned for the commentator of a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "commentator")]) + + 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, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -607,6 +751,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 +761,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", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -1320,7 +1465,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", "reader", { - "public": ["reader", "editor"], + "public": ["reader", "commentator", "editor"], + }, + ), + ( + "public", + "commentator", + { + "public": ["commentator", "editor"], }, ), ("public", "editor", {"public": ["editor"]}), @@ -1328,8 +1480,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "authenticated", "reader", { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], + }, + ), + ( + "authenticated", + "commentator", + { + "authenticated": ["commentator", "editor"], + "public": ["commentator", "editor"], }, ), ( @@ -1342,8 +1502,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "reader", { "restricted": None, - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], + }, + ), + ( + "restricted", + "commentator", + { + "restricted": None, + "authenticated": ["commentator", "editor"], + "public": ["commentator", "editor"], }, ), ( @@ -1360,15 +1529,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", None, { - "public": ["reader", "editor"], + "public": ["reader", "commentator", "editor"], }, ), ( None, "reader", { - "public": ["reader", "editor"], - "authenticated": ["reader", "editor"], + "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commentator", "editor"], "restricted": None, }, ), @@ -1376,8 +1545,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): None, None, { - "public": ["reader", "editor"], - "authenticated": ["reader", "editor"], + "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commentator", "editor"], "restricted": None, }, ), From 4133a7879f91833e21f19b83ac141af792e3d24d Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 27 Aug 2025 16:38:42 +0200 Subject: [PATCH 02/14] =?UTF-8?q?=E2=9C=A8(backend)=20add=20Comment=20mode?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/backend/core/factories.py | 11 + ...role_alter_documentaccess_role_and_more.py | 146 ++++++++++ src/backend/core/models.py | 42 +++ src/backend/core/tests/test_models_comment.py | 273 ++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py create mode 100644 src/backend/core/tests/test_models_comment.py diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 1b3715e749..24bdd317e9 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -256,3 +256,14 @@ class Meta: document = factory.SubFactory(DocumentFactory) role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices]) issuer = factory.SubFactory(UserFactory) + + +class CommentFactory(factory.django.DjangoModelFactory): + """A factory to create comments for a document""" + + class Meta: + model = models.Comment + + document = factory.SubFactory(DocumentFactory) + user = factory.SubFactory(UserFactory) + content = factory.Faker("text") diff --git a/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py b/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py new file mode 100644 index 0000000000..a34ad05b88 --- /dev/null +++ b/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py @@ -0,0 +1,146 @@ +# Generated by Django 5.2.4 on 2025-08-26 08:11 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0024_add_is_masked_field_to_link_trace"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="link_role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaskforaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="invitation", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="templateaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + 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", + ), + ), + ("content", models.TextField()), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="core.document", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Comment", + "verbose_name_plural": "Comments", + "db_table": "impress_comment", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index acc3e11572..a285c508ca 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1285,6 +1285,48 @@ def send_ask_for_access_email(self, email, language=None): self.document.send_email(subject, [email], context, language) +class Comment(BaseModel): + """User comment on a document.""" + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="comments", + ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="comments", + null=True, + blank=True, + ) + content = models.TextField() + + class Meta: + db_table = "impress_comment" + ordering = ("-created_at",) + verbose_name = _("Comment") + verbose_name_plural = _("Comments") + + def __str__(self): + author = self.user or _("Anonymous") + return f"{author!s} on {self.document!s}" + + def get_abilities(self, user): + """Compute and return abilities for a given user.""" + role = self.document.get_role(user) + can_comment = self.document.get_abilities(user)["comment"] + return { + "destroy": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "update": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "partial_update": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "retrieve": can_comment, + } + + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" diff --git a/src/backend/core/tests/test_models_comment.py b/src/backend/core/tests/test_models_comment.py new file mode 100644 index 0000000000..dac0b36c22 --- /dev/null +++ b/src/backend/core/tests/test_models_comment.py @@ -0,0 +1,273 @@ +"""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.COMMENTATOR, 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(document=document) + user = AnonymousUser() + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": 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(document=document) + user = AnonymousUser() + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": False, + } + + +@pytest.mark.parametrize( + "link_role,link_reach,can_comment", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), + (LinkRoleChoices.COMMENTATOR, 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(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_role,link_reach,can_comment", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), + (LinkRoleChoices.COMMENTATOR, 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( + document=document, user=user if can_comment else None + ) + + assert comment.get_abilities(user) == { + "destroy": can_comment, + "update": can_comment, + "partial_update": can_comment, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_commentator(link_role, link_reach): + """Commentators can comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, + link_reach=link_reach, + users=[(user, RoleChoices.COMMENTATOR)], + ) + comment = factories.CommentFactory(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_commentator_own_comment(link_role, link_reach): + """Commentators have all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, + link_reach=link_reach, + users=[(user, RoleChoices.COMMENTATOR)], + ) + comment = factories.CommentFactory(document=document, user=user) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, 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(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, 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(document=document, user=user) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": 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( + document=document, user=random.choice([user, None]) + ) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": 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( + document=document, user=random.choice([user, None]) + ) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + } From 271d5514da7a37647e4b41ac4fb4e8bf8cbe3750 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Thu, 28 Aug 2025 08:21:35 +0200 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9C=A8(backend)=20add=20comment=20view?= =?UTF-8?q?set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + src/backend/core/api/permissions.py | 16 + src/backend/core/api/serializers.py | 44 ++ src/backend/core/api/viewsets.py | 33 + .../documents/test_api_documents_comments.py | 588 ++++++++++++++++++ src/backend/core/urls.py | 6 +- 6 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/tests/documents/test_api_documents_comments.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a5009531..09a80fa5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,7 @@ and this project adheres to ### Added +- ✨(backend) Comments on text editor #1309 - 👷(CI) add bundle size check job #1268 - ✨(frontend) use title first emoji as doc icon in tree #1289 diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 09007847bf..29df311c82 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -171,3 +171,19 @@ def has_object_permission(self, request, view, obj): 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) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 81b26d5e80..92ef44ab55 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -889,3 +889,47 @@ class MoveDocumentSerializer(serializers.Serializer): choices=enums.MoveNodePositionChoices.choices, default=enums.MoveNodePositionChoices.LAST_CHILD, ) + + +class CommentSerializer(serializers.ModelSerializer): + """Serialize comments.""" + + user = UserLightSerializer(read_only=True) + abilities = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.Comment + fields = [ + "id", + "content", + "created_at", + "updated_at", + "user", + "document", + "abilities", + ] + read_only_fields = [ + "id", + "created_at", + "updated_at", + "user", + "document", + "abilities", + ] + + def get_abilities(self, comment) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return comment.get_abilities(request.user) + return {} + + def validate(self, attrs): + """Validate invitation data.""" + request = self.context.get("request") + user = getattr(request, "user", None) + + attrs["document_id"] = self.context["resource_id"] + attrs["user_id"] = user.id if user else None + + return attrs diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 84402ceaae..1470bd150f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2236,3 +2236,36 @@ def _load_theme_customization(self): ) return theme_customization + + +class CommentViewSet( + viewsets.ModelViewSet, +): + """API ViewSet for comments.""" + + permission_classes = [permissions.CommentPermission] + queryset = models.Comment.objects.select_related("user", "document").all() + serializer_class = serializers.CommentSerializer + pagination_class = Pagination + _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 + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["resource_id"] = self.kwargs["resource_id"] + return context + + def get_queryset(self): + """Return the queryset according to the action.""" + return super().get_queryset().filter(document=self.kwargs["resource_id"]) diff --git a/src/backend/core/tests/documents/test_api_documents_comments.py b/src/backend/core/tests/documents/test_api_documents_comments.py new file mode 100644 index 0000000000..2a0cb7ced7 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_comments.py @@ -0,0 +1,588 @@ +"""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.COMMENTATOR + ) + comment1, comment2 = factories.CommentFactory.create_batch(2, document=document) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/") + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": str(comment2.id), + "content": comment2.content, + "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, + }, + "document": str(comment2.document.id), + "abilities": comment2.get_abilities(AnonymousUser()), + }, + { + "id": str(comment1.id), + "content": comment1.content, + "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, + }, + "document": str(comment1.document.id), + "abilities": comment1.get_abilities(AnonymousUser()), + }, + ], + } + + +@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.COMMENTATOR + ) + factories.CommentFactory(document=document) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + response = APIClient().get(f"/api/v1.0/documents/{document.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.COMMENTATOR)] + ) + comment1 = factories.CommentFactory(document=document) + comment2 = factories.CommentFactory(document=document, 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}/comments/") + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": str(comment2.id), + "content": comment2.content, + "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, + }, + "document": str(comment2.document.id), + "abilities": comment2.get_abilities(user), + }, + { + "id": str(comment1.id), + "content": comment1.content, + "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, + }, + "document": str(comment1.document.id), + "abilities": comment1.get_abilities(user), + }, + ], + } + + +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") + factories.CommentFactory(document=document) + # 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}/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)] + ) + factories.CommentFactory(document=document) + # 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}/comments/") + assert response.status_code == 403 + + +# Create comment + + +def test_create_comment_anonymous_user_public_document(): + """Anonymous users should not be allowed to create comments on a public document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR + ) + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + ) + + assert response.status_code == 201 + + assert response.json() == { + "id": str(response.json()["id"]), + "content": "test", + "created_at": response.json()["created_at"], + "updated_at": response.json()["updated_at"], + "user": None, + "document": str(document.id), + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": True, + }, + } + + +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 + ) + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "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.COMMENTATOR)] + ) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + ) + assert response.status_code == 201 + + assert response.json() == { + "id": str(response.json()["id"]), + "content": "test", + "created_at": response.json()["created_at"], + "updated_at": response.json()["updated_at"], + "user": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "document": str(document.id), + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + }, + } + + +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)] + ) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "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.COMMENTATOR + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(comment.id), + "content": comment.content, + "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, + }, + "document": str(comment.document.id), + "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 + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.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.COMMENTATOR)] + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.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)] + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.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.COMMENTATOR + ) + comment = factories.CommentFactory(document=document, content="test") + client = APIClient() + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "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 + ) + comment = factories.CommentFactory(document=document, content="test") + client = APIClient() + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "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.COMMENTATOR, models.LinkRoleChoices.EDITOR] + ), + ) + ], + ) + comment = factories.CommentFactory(document=document, content="test") + client = APIClient() + client.force_login(user) + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "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.COMMENTATOR, models.LinkRoleChoices.EDITOR] + ), + ) + ], + ) + comment = factories.CommentFactory(document=document, content="test", user=user) + client = APIClient() + client.force_login(user) + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "other content"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.content == "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)] + ) + comment = factories.CommentFactory(document=document, content="test") + client = APIClient() + client.force_login(user) + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "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") + comment = factories.CommentFactory(document=document, content="test") + client = APIClient() + client.force_login(user) + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "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)]) + comment = factories.CommentFactory(document=document, content="test") + client = APIClient() + client.force_login(user) + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "other content"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.content == "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)]) + comment = factories.CommentFactory(document=document, content="test", user=user) + client = APIClient() + client.force_login(user) + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "other content"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.content == "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.COMMENTATOR + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.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 + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.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.COMMENTATOR)] + ) + comment = factories.CommentFactory(document=document, user=user) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.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.COMMENTATOR)] + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.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)]) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.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)]) + comment = factories.CommentFactory(document=document, user=user) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.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)] + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 403 diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 2ad8b00395..2df79fcc4a 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -26,7 +26,11 @@ viewsets.InvitationViewset, basename="invitations", ) - +document_related_router.register( + "comments", + viewsets.CommentViewSet, + basename="comments", +) document_related_router.register( "ask-for-access", viewsets.DocumentAskForAccessViewSet, From 431804c84cf98c2067e0639ef7b51bd0bd7ec3dd Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 12 Sep 2025 15:28:25 +0200 Subject: [PATCH 04/14] =?UTF-8?q?=E2=9C=A8(backend)=20implement=20thread?= =?UTF-8?q?=20and=20reactions=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/backend/core/api/serializers.py | 102 +- src/backend/core/api/viewsets.py | 121 +- src/backend/core/choices.py | 4 +- src/backend/core/factories.py | 41 +- ...role_alter_documentaccess_role_and_more.py | 146 -- src/backend/core/migrations/0026_comments.py | 275 ++++ src/backend/core/models.py | 141 +- .../documents/test_api_document_accesses.py | 42 +- .../documents/test_api_documents_comments.py | 516 +++++-- .../documents/test_api_documents_retrieve.py | 18 +- .../documents/test_api_documents_threads.py | 1226 +++++++++++++++++ .../documents/test_api_documents_trashbin.py | 4 +- src/backend/core/tests/test_models_comment.py | 80 +- .../tests/test_models_document_accesses.py | 18 +- .../core/tests/test_models_documents.py | 103 +- src/backend/core/urls.py | 17 +- 16 files changed, 2419 insertions(+), 435 deletions(-) delete mode 100644 src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py create mode 100644 src/backend/core/migrations/0026_comments.py create mode 100644 src/backend/core/tests/documents/test_api_documents_threads.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 92ef44ab55..732b940a3e 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1,4 +1,5 @@ """Client serializers for the impress core app.""" +# pylint: disable=too-many-lines import binascii import mimetypes @@ -891,45 +892,122 @@ class MoveDocumentSerializer(serializers.Serializer): ) +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.""" + """Serialize comments (nested under a thread) with reactions and abilities.""" user = UserLightSerializer(read_only=True) - abilities = serializers.SerializerMethodField(read_only=True) + abilities = serializers.SerializerMethodField() + reactions = ReactionSerializer(many=True, read_only=True) class Meta: model = models.Comment fields = [ "id", - "content", + "user", + "body", "created_at", "updated_at", - "user", - "document", + "reactions", "abilities", ] read_only_fields = [ "id", + "user", "created_at", "updated_at", - "user", - "document", + "reactions", "abilities", ] - def get_abilities(self, comment) -> dict: - """Return abilities of the logged-in user on the instance.""" + 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 comment.get_abilities(request.user) + 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 invitation data.""" + """Validate thread data.""" request = self.context.get("request") user = getattr(request, "user", None) attrs["document_id"] = self.context["resource_id"] - attrs["user_id"] = user.id if user else None + 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 {} diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1470bd150f..fdf06ec0fd 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -21,6 +21,7 @@ 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 _ @@ -2238,15 +2239,9 @@ def _load_theme_customization(self): return theme_customization -class CommentViewSet( - viewsets.ModelViewSet, -): - """API ViewSet for comments.""" +class CommentViewSetMixin: + """Comment ViewSet Mixin.""" - permission_classes = [permissions.CommentPermission] - queryset = models.Comment.objects.select_related("user", "document").all() - serializer_class = serializers.CommentSerializer - pagination_class = Pagination _document = None def get_document_or_404(self): @@ -2260,12 +2255,114 @@ def get_document_or_404(self): 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["resource_id"] = self.kwargs["resource_id"] + context["document_id"] = self.kwargs["resource_id"] + context["thread_id"] = self.kwargs["thread_id"] return context - def get_queryset(self): - """Return the queryset according to the action.""" - return super().get_queryset().filter(document=self.kwargs["resource_id"]) + @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) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index 8505ebab87..e185153103 100644 --- a/src/backend/core/choices.py +++ b/src/backend/core/choices.py @@ -33,7 +33,7 @@ class LinkRoleChoices(PriorityTextChoices): """Defines the possible roles a link can offer on a document.""" READER = "reader", _("Reader") # Can read - COMMENTATOR = "commentator", _("Commentator") # Can read and comment + COMMENTER = "commenter", _("Commenter") # Can read and comment EDITOR = "editor", _("Editor") # Can read and edit @@ -41,7 +41,7 @@ class RoleChoices(PriorityTextChoices): """Defines the possible roles a user can have in a resource.""" READER = "reader", _("Reader") # Can read - COMMENTATOR = "commentator", _("Commentator") # Can read and comment + 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") diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 24bdd317e9..c0737cdce9 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -258,12 +258,47 @@ class Meta: 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 document""" + """A factory to create comments for a thread""" class Meta: model = models.Comment - document = factory.SubFactory(DocumentFactory) + thread = factory.SubFactory(ThreadFactory) user = factory.SubFactory(UserFactory) - content = factory.Faker("text") + 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) diff --git a/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py b/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py deleted file mode 100644 index a34ad05b88..0000000000 --- a/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py +++ /dev/null @@ -1,146 +0,0 @@ -# Generated by Django 5.2.4 on 2025-08-26 08:11 - -import uuid - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("core", "0024_add_is_masked_field_to_link_trace"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="link_role", - field=models.CharField( - choices=[ - ("reader", "Reader"), - ("commentator", "Commentator"), - ("editor", "Editor"), - ], - default="reader", - max_length=20, - ), - ), - migrations.AlterField( - model_name="documentaccess", - name="role", - field=models.CharField( - choices=[ - ("reader", "Reader"), - ("commentator", "Commentator"), - ("editor", "Editor"), - ("administrator", "Administrator"), - ("owner", "Owner"), - ], - default="reader", - max_length=20, - ), - ), - migrations.AlterField( - model_name="documentaskforaccess", - name="role", - field=models.CharField( - choices=[ - ("reader", "Reader"), - ("commentator", "Commentator"), - ("editor", "Editor"), - ("administrator", "Administrator"), - ("owner", "Owner"), - ], - default="reader", - max_length=20, - ), - ), - migrations.AlterField( - model_name="invitation", - name="role", - field=models.CharField( - choices=[ - ("reader", "Reader"), - ("commentator", "Commentator"), - ("editor", "Editor"), - ("administrator", "Administrator"), - ("owner", "Owner"), - ], - default="reader", - max_length=20, - ), - ), - migrations.AlterField( - model_name="templateaccess", - name="role", - field=models.CharField( - choices=[ - ("reader", "Reader"), - ("commentator", "Commentator"), - ("editor", "Editor"), - ("administrator", "Administrator"), - ("owner", "Owner"), - ], - default="reader", - max_length=20, - ), - ), - 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", - ), - ), - ("content", models.TextField()), - ( - "document", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="comments", - to="core.document", - ), - ), - ( - "user", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="comments", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "Comment", - "verbose_name_plural": "Comments", - "db_table": "impress_comment", - "ordering": ("-created_at",), - }, - ), - ] diff --git a/src/backend/core/migrations/0026_comments.py b/src/backend/core/migrations/0026_comments.py new file mode 100644 index 0000000000..f1b122f3e2 --- /dev/null +++ b/src/backend/core/migrations/0026_comments.py @@ -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.", + ) + ], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index a285c508ca..0e09291a69 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -756,7 +756,7 @@ def get_abilities(self, user): can_update = ( is_owner_or_admin or role == RoleChoices.EDITOR ) and not is_deleted - can_comment = (can_update or role == RoleChoices.COMMENTATOR) 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 @@ -1150,7 +1150,7 @@ def get_abilities(self, user): set_role_to.extend( [ RoleChoices.READER, - RoleChoices.COMMENTATOR, + RoleChoices.COMMENTER, RoleChoices.EDITOR, RoleChoices.ADMIN, ] @@ -1285,48 +1285,153 @@ def send_ask_for_access_email(self, email, language=None): self.document.send_email(subject, [email], context, language) -class Comment(BaseModel): - """User comment on a document.""" +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="comments", + related_name="thread_comment", null=True, blank=True, ) - content = models.TextField() + body = models.JSONField() + metadata = models.JSONField(default=dict, blank=True) class Meta: db_table = "impress_comment" - ordering = ("-created_at",) + 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"{author!s} on {self.document!s}" + return f"Comment by {author!s} on thread {self.thread_id}" def get_abilities(self, user): - """Compute and return abilities for a given user.""" - role = self.document.get_role(user) - can_comment = self.document.get_abilities(user)["comment"] + """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": self.user == user - or role in [RoleChoices.OWNER, RoleChoices.ADMIN], - "update": self.user == user - or role in [RoleChoices.OWNER, RoleChoices.ADMIN], - "partial_update": self.user == user - or role in [RoleChoices.OWNER, RoleChoices.ADMIN], - "retrieve": can_comment, + "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.""" diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index cdb3d4d63a..aa21544cae 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -293,7 +293,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): } assert result_dict[str(document_access_other_user.id)] == [ "reader", - "commentator", + "commenter", "editor", "administrator", "owner", @@ -302,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="commentator" + document=parent, user=other_user, role="commenter" ) response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") @@ -315,7 +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)] == [ - "commentator", + "commenter", "editor", "administrator", "owner", @@ -323,7 +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", - "commentator", + "commenter", "editor", "administrator", "owner", @@ -336,28 +336,28 @@ def test_api_document_accesses_retrieve_set_role_to_child(): [ ["administrator", "reader", "reader", "reader"], [ - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], ], @@ -418,44 +418,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul [ ["administrator", "reader", "reader", "reader"], [ - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["reader", "reader", "reader", "owner"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["reader", "administrator", "reader", "editor"], [ - ["reader", "commentator", "editor", "administrator"], - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], ], @@ -463,7 +463,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul [ ["editor", "editor", "administrator", "editor"], [ - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], ["editor", "administrator"], [], diff --git a/src/backend/core/tests/documents/test_api_documents_comments.py b/src/backend/core/tests/documents/test_api_documents_comments.py index 2a0cb7ced7..98cbc0ef98 100644 --- a/src/backend/core/tests/documents/test_api_documents_comments.py +++ b/src/backend/core/tests/documents/test_api_documents_comments.py @@ -17,42 +17,45 @@ 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.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) - comment1, comment2 = factories.CommentFactory.create_batch(2, document=document) + 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}/comments/") + 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(comment2.id), - "content": comment2.content, - "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, - }, - "document": str(comment2.document.id), - "abilities": comment2.get_abilities(AnonymousUser()), - }, { "id": str(comment1.id), - "content": comment1.content, + "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, }, - "document": str(comment1.document.id), "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": [], }, ], } @@ -62,13 +65,16 @@ def test_list_comments_anonymous_user_public_document(): 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.COMMENTATOR + link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER ) - factories.CommentFactory(document=document) + 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}/comments/") + response = APIClient().get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) assert response.status_code == 401 @@ -76,46 +82,49 @@ 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.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) - comment1 = factories.CommentFactory(document=document) - comment2 = factories.CommentFactory(document=document, user=user) + 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}/comments/") + 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(comment2.id), - "content": comment2.content, - "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, - }, - "document": str(comment2.document.id), - "abilities": comment2.get_abilities(user), - }, { "id": str(comment1.id), - "content": comment1.content, + "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, }, - "document": str(comment1.document.id), "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": [], }, ], } @@ -125,14 +134,17 @@ 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") - factories.CommentFactory(document=document) + 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}/comments/") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) assert response.status_code == 403 @@ -145,14 +157,17 @@ def test_list_comments_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) - factories.CommentFactory(document=document) + 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}/comments/") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) assert response.status_code == 403 @@ -160,30 +175,35 @@ def test_list_comments_authenticated_user_not_enough_access(): def test_create_comment_anonymous_user_public_document(): - """Anonymous users should not be allowed to create comments on a 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.COMMENTATOR + 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}/comments/", {"content": "test"} + 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"]), - "content": "test", + "body": "test", "created_at": response.json()["created_at"], "updated_at": response.json()["updated_at"], "user": None, - "document": str(document.id), "abilities": { "destroy": False, "update": False, "partial_update": False, + "reactions": False, "retrieve": True, }, + "reactions": [], } @@ -192,9 +212,11 @@ def test_create_comment_anonymous_user_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}/comments/", {"content": "test"} + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, ) assert response.status_code == 401 @@ -204,31 +226,34 @@ 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.COMMENTATOR)] + 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}/comments/", {"content": "test"} + 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"]), - "content": "test", + "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, }, - "document": str(document.id), "abilities": { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, }, + "reactions": [], } @@ -241,10 +266,12 @@ def test_create_comment_authenticated_user_not_enough_access(): 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}/comments/", {"content": "test"} + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, ) assert response.status_code == 403 @@ -255,24 +282,25 @@ def test_create_comment_authenticated_user_not_enough_access(): 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.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() response = client.get( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + 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), - "content": comment.content, + "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, }, - "document": str(comment.document.id), + "reactions": [], "abilities": comment.get_abilities(AnonymousUser()), } @@ -282,10 +310,11 @@ def test_retrieve_comment_anonymous_user_non_accessible_document(): document = factories.DocumentFactory( link_reach="public", link_role=models.LinkRoleChoices.READER ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() response = client.get( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 401 @@ -294,13 +323,14 @@ 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.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) - comment = factories.CommentFactory(document=document) + 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}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 200 @@ -314,11 +344,12 @@ def test_retrieve_comment_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) - comment = factories.CommentFactory(document=document) + 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}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 403 @@ -329,13 +360,14 @@ def test_retrieve_comment_authenticated_user_not_enough_access(): 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.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) - comment = factories.CommentFactory(document=document, content="test") + 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}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 401 @@ -345,11 +377,12 @@ def test_update_comment_anonymous_user_non_accessible_document(): document = factories.DocumentFactory( link_reach="public", link_role=models.LinkRoleChoices.READER ) - comment = factories.CommentFactory(document=document, content="test") + 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}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 401 @@ -363,17 +396,18 @@ def test_update_comment_authenticated_user_accessible_document(): ( user, random.choice( - [models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR] + [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] ), ) ], ) - comment = factories.CommentFactory(document=document, content="test") + 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}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 403 @@ -387,22 +421,23 @@ def test_update_comment_authenticated_user_own_comment(): ( user, random.choice( - [models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR] + [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] ), ) ], ) - comment = factories.CommentFactory(document=document, content="test", user=user) + 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}/comments/{comment.id!s}/", - {"content": "other content"}, + 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.content == "other content" + assert comment.body == "other content" def test_update_comment_authenticated_user_not_enough_access(): @@ -414,12 +449,13 @@ def test_update_comment_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) - comment = factories.CommentFactory(document=document, content="test") + 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}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 403 @@ -431,12 +467,13 @@ def test_update_comment_authenticated_no_access(): """ user = factories.UserFactory() document = factories.DocumentFactory(link_reach="restricted") - comment = factories.CommentFactory(document=document, content="test") + 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}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 403 @@ -448,18 +485,19 @@ def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role """ user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, role)]) - comment = factories.CommentFactory(document=document, content="test") + 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}/comments/{comment.id!s}/", - {"content": "other content"}, + 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.content == "other content" + assert comment.body == "other content" @pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) @@ -469,18 +507,19 @@ def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role """ user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, role)]) - comment = factories.CommentFactory(document=document, content="test", user=user) + 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}/comments/{comment.id!s}/", - {"content": "other content"}, + 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.content == "other content" + assert comment.body == "other content" # Delete comment @@ -489,12 +528,13 @@ def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role 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.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 401 @@ -504,10 +544,11 @@ def test_delete_comment_anonymous_user_non_accessible_document(): document = factories.DocumentFactory( link_reach="public", link_role=models.LinkRoleChoices.READER ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 401 @@ -516,13 +557,14 @@ 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.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) - comment = factories.CommentFactory(document=document, user=user) + 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}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 204 @@ -531,13 +573,14 @@ 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.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) - comment = factories.CommentFactory(document=document) + 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}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 403 @@ -547,11 +590,12 @@ def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment """Authenticated users should be able to delete comments on a document they have access to.""" user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, role)]) - comment = factories.CommentFactory(document=document) + 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}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 204 @@ -561,11 +605,12 @@ def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment """Authenticated users should be able to delete comments on a document they have access to.""" user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, role)]) - comment = factories.CommentFactory(document=document, user=user) + 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}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 204 @@ -579,10 +624,255 @@ def test_delete_comment_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) - comment = factories.CommentFactory(document=document) + 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}/comments/{comment.id!s}/" + 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() diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index de158fba44..7391b07149 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -36,7 +36,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "children_create": False, "children_list": True, "collaboration_auth": True, - "comment": document.link_role in ["commentator", "editor"], + "comment": document.link_role in ["commenter", "editor"], "cors_proxy": True, "content": True, "descendants": True, @@ -47,8 +47,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": False, @@ -114,7 +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 ["commentator", "editor"], + "comment": grand_parent.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -222,7 +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 ["commentator", "editor"], + "comment": document.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -232,8 +232,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -307,7 +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 ["commentator", "editor"], + "comment": grand_parent.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -501,7 +501,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", - "can_edit": access.role not in ["reader", "commentator"], + "can_edit": access.role not in ["reader", "commenter"], "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, diff --git a/src/backend/core/tests/documents/test_api_documents_threads.py b/src/backend/core/tests/documents/test_api_documents_threads.py new file mode 100644 index 0000000000..cea0ae966f --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_threads.py @@ -0,0 +1,1226 @@ +"""Test Thread viewset.""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + +# pylint: disable=too-many-lines + + +# Create + + +def test_api_documents_threads_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to create threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_public_document(link_role): + """ + Anonymous users should be allowed to create threads on public documents with commenter + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_restricted_document(): + """ + Authenticated users should not be allowed to create threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", + [role for role in models.RoleChoices.values if role != models.RoleChoices.READER], +) +def test_api_documents_threads_restricted_document_editor(role): + """ + Authenticated users should be allowed to create threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to create threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 401 + + +def test_api_documents_threads_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to create threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_authenticated_document(link_role): + """ + Authenticated users should be allowed to create threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +# List + + +def test_api_documents_threads_list_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to retrieve threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_list_public_document_link_role_higher_than_reader( + link_role, +): + """ + Anonymous users should be allowed to retrieve threads on public documents with commenter or + editor link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +def test_api_documents_threads_list_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_list_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_list_authenticated_document(link_role): + """ + Authenticated users should be allowed to retrieve threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +def test_api_documents_threads_list_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_list_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", + [role for role in models.RoleChoices.values if role != models.RoleChoices.READER], +) +def test_api_documents_threads_list_restricted_document_editor(role): + """ + Authenticated users should be allowed to retrieve threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +# Retrieve + + +def test_api_documents_threads_retrieve_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to retrieve threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_public_document_link_role_higher_than_reader( + link_role, +): + """ + Anonymous users should be allowed to retrieve threads on public documents with commenter or + editor link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "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": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_retrieve_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_retrieve_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_authenticated_document(link_role): + """ + Authenticated users should be allowed to retrieve threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "comments": [ + { + "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": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + } + + +def test_api_documents_threads_retrieve_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_retrieve_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_restricted_document_editor(role): + """ + Authenticated users should be allowed to retrieve threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "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": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_retrieve_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to retrieve + threads on restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "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": None, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +# Destroy + + +def test_api_documents_threads_destroy_public_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on public documents. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_public_document_authenticated_user(): + """ + Authenticated users should not be allowed to destroy threads on public documents. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_destroy_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to destroy threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_destroy_authenticated_document(link_role): + """ + Authenticated users should not be allowed to destroy threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_destroy_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to destroy threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_destroy_restricted_document_editor(role): + """ + Authenticated users should not be allowed to destroy threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_destroy_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to destroy + threads on restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 204 + assert not models.Thread.objects.filter(id=thread.id).exists() + + +# Resolve + + +def test_api_documents_threads_resolve_public_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on public documents. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_public_document_authenticated_user(): + """ + Authenticated users should not be allowed to resolve threads on public documents. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_resolve_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to resolve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_resolve_authenticated_document(link_role): + """ + Authenticated users should not be allowed to resolve threads on authenticated documents with + commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_resolve_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to resolve threads on restricted documents with + reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_resolve_restricted_document_editor(role): + """ + Authenticated users should not be allowed to resolve threads on restricted documents with + editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_resolve_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to resolve threads on + restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 204 + + # Verify thread is resolved + thread.refresh_from_db() + assert thread.resolved is True + assert thread.resolved_at is not None + assert thread.resolved_by == user diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 28ea6e8bfa..cc32c09f95 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -89,8 +89,8 @@ def test_api_documents_trashbin_format(): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": False, diff --git a/src/backend/core/tests/test_models_comment.py b/src/backend/core/tests/test_models_comment.py index dac0b36c22..7ff8cc8760 100644 --- a/src/backend/core/tests/test_models_comment.py +++ b/src/backend/core/tests/test_models_comment.py @@ -16,7 +16,7 @@ "role,can_comment", [ (LinkRoleChoices.READER, False), - (LinkRoleChoices.COMMENTATOR, True), + (LinkRoleChoices.COMMENTER, True), (LinkRoleChoices.EDITOR, True), ], ) @@ -25,13 +25,14 @@ def test_comment_get_abilities_anonymous_user_public_document(role, can_comment) document = factories.DocumentFactory( link_role=role, link_reach=LinkReachChoices.PUBLIC ) - comment = factories.CommentFactory(document=document) + 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, } @@ -42,13 +43,14 @@ def test_comment_get_abilities_anonymous_user_public_document(role, can_comment) 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(document=document) + comment = factories.CommentFactory(thread__document=document) user = AnonymousUser() assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": False, "retrieve": False, } @@ -57,13 +59,13 @@ def test_comment_get_abilities_anonymous_user_restricted_document(link_reach): "link_role,link_reach,can_comment", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), ], ) @@ -73,12 +75,13 @@ def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment): document = factories.DocumentFactory( link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] ) - comment = factories.CommentFactory(document=document) + comment = factories.CommentFactory(thread__document=document) assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": can_comment, "retrieve": can_comment, } @@ -87,13 +90,13 @@ def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment): "link_role,link_reach,can_comment", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), ], ) @@ -106,13 +109,14 @@ def test_comment_get_abilities_user_reader_own_comment( link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] ) comment = factories.CommentFactory( - document=document, user=user if can_comment else None + 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, } @@ -121,30 +125,31 @@ def test_comment_get_abilities_user_reader_own_comment( "link_role,link_reach", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), ], ) -def test_comment_get_abilities_user_commentator(link_role, link_reach): - """Commentators can comment on a document.""" +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.COMMENTATOR)], + users=[(user, RoleChoices.COMMENTER)], ) - comment = factories.CommentFactory(document=document) + comment = factories.CommentFactory(thread__document=document) assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": True, "retrieve": True, } @@ -153,30 +158,31 @@ def test_comment_get_abilities_user_commentator(link_role, link_reach): "link_role,link_reach", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), ], ) -def test_comment_get_abilities_user_commentator_own_comment(link_role, link_reach): - """Commentators have all accesses to its own comment.""" +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.COMMENTATOR)], + users=[(user, RoleChoices.COMMENTER)], ) - comment = factories.CommentFactory(document=document, user=user) + comment = factories.CommentFactory(thread__document=document, user=user) assert comment.get_abilities(user) == { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, } @@ -185,13 +191,13 @@ def test_comment_get_abilities_user_commentator_own_comment(link_role, link_reac "link_role,link_reach", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), ], ) @@ -201,12 +207,13 @@ def test_comment_get_abilities_user_editor(link_role, link_reach): document = factories.DocumentFactory( link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] ) - comment = factories.CommentFactory(document=document) + comment = factories.CommentFactory(thread__document=document) assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": True, "retrieve": True, } @@ -215,13 +222,13 @@ def test_comment_get_abilities_user_editor(link_role, link_reach): "link_role,link_reach", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), ], ) @@ -231,12 +238,13 @@ def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach): document = factories.DocumentFactory( link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] ) - comment = factories.CommentFactory(document=document, user=user) + comment = factories.CommentFactory(thread__document=document, user=user) assert comment.get_abilities(user) == { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, } @@ -246,13 +254,14 @@ def test_comment_get_abilities_user_admin(): user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)]) comment = factories.CommentFactory( - document=document, user=random.choice([user, None]) + thread__document=document, user=random.choice([user, None]) ) assert comment.get_abilities(user) == { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, } @@ -262,12 +271,13 @@ def test_comment_get_abilities_user_owner(): user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)]) comment = factories.CommentFactory( - document=document, user=random.choice([user, None]) + thread__document=document, user=random.choice([user, None]) ) assert comment.get_abilities(user) == { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, } diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index eb7675c00c..b8c3e93dd6 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "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", "commentator", "editor", "administrator"], + "set_role_to": ["reader", "commenter", "editor", "administrator"], } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 49fb3070c2..91b8abf9ba 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -134,13 +134,13 @@ def test_models_documents_soft_delete(depth): [ (True, "restricted", "reader"), (True, "restricted", "editor"), - (True, "restricted", "commentator"), + (True, "restricted", "commenter"), (False, "restricted", "reader"), (False, "restricted", "editor"), - (False, "restricted", "commentator"), + (False, "restricted", "commenter"), (False, "authenticated", "reader"), (False, "authenticated", "editor"), - (False, "authenticated", "commentator"), + (False, "authenticated", "commenter"), ], ) def test_models_documents_get_abilities_forbidden( @@ -176,8 +176,8 @@ def test_models_documents_get_abilities_forbidden( "move": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "partial_update": False, @@ -237,8 +237,8 @@ def test_models_documents_get_abilities_reader( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -278,14 +278,14 @@ def test_models_documents_get_abilities_reader( (True, "authenticated"), ], ) -def test_models_documents_get_abilities_commentator( +def test_models_documents_get_abilities_commenter( is_authenticated, reach, django_assert_num_queries ): """ - Check abilities returned for a document giving commentator role to link holders + 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="commentator") + document = factories.DocumentFactory(link_reach=reach, link_role="commenter") user = factories.UserFactory() if is_authenticated else AnonymousUser() expected_abilities = { "accesses_manage": False, @@ -298,6 +298,7 @@ def test_models_documents_get_abilities_commentator( "children_list": True, "collaboration_auth": True, "comment": True, + "content": True, "descendants": True, "cors_proxy": True, "destroy": False, @@ -306,8 +307,8 @@ def test_models_documents_get_abilities_commentator( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -373,8 +374,8 @@ def test_models_documents_get_abilities_editor( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -429,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", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -461,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, @@ -470,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, @@ -516,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", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -572,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", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -626,7 +628,7 @@ def test_models_documents_get_abilities_reader_user( "children_list": True, "collaboration_auth": True, "comment": document.link_reach != "restricted" - and document.link_role in ["commentator", "editor"], + and document.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -636,8 +638,8 @@ def test_models_documents_get_abilities_reader_user( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -668,12 +670,12 @@ def test_models_documents_get_abilities_reader_user( @pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"]) -def test_models_documents_get_abilities_commentator_user( +def test_models_documents_get_abilities_commenter_user( ai_access_setting, django_assert_num_queries ): - """Check abilities returned for the commentator of a document.""" + """Check abilities returned for the commenter of a document.""" user = factories.UserFactory() - document = factories.DocumentFactory(users=[(user, "commentator")]) + document = factories.DocumentFactory(users=[(user, "commenter")]) access_from_link = ( document.link_reach != "restricted" and document.link_role == "editor" @@ -692,6 +694,7 @@ def test_models_documents_get_abilities_commentator_user( "children_list": True, "collaboration_auth": True, "comment": True, + "content": True, "descendants": True, "cors_proxy": True, "destroy": False, @@ -700,8 +703,8 @@ def test_models_documents_get_abilities_commentator_user( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -761,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", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -1465,14 +1468,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", "reader", { - "public": ["reader", "commentator", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( "public", - "commentator", + "commenter", { - "public": ["commentator", "editor"], + "public": ["commenter", "editor"], }, ), ("public", "editor", {"public": ["editor"]}), @@ -1480,16 +1483,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "authenticated", "reader", { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( "authenticated", - "commentator", + "commenter", { - "authenticated": ["commentator", "editor"], - "public": ["commentator", "editor"], + "authenticated": ["commenter", "editor"], + "public": ["commenter", "editor"], }, ), ( @@ -1502,17 +1505,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "reader", { "restricted": None, - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( "restricted", - "commentator", + "commenter", { "restricted": None, - "authenticated": ["commentator", "editor"], - "public": ["commentator", "editor"], + "authenticated": ["commenter", "editor"], + "public": ["commenter", "editor"], }, ), ( @@ -1529,15 +1532,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", None, { - "public": ["reader", "commentator", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( None, "reader", { - "public": ["reader", "commentator", "editor"], - "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commenter", "editor"], + "authenticated": ["reader", "commenter", "editor"], "restricted": None, }, ), @@ -1545,8 +1548,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): None, None, { - "public": ["reader", "commentator", "editor"], - "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commenter", "editor"], + "authenticated": ["reader", "commenter", "editor"], "restricted": None, }, ), diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 2df79fcc4a..b843c89171 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -27,9 +27,9 @@ basename="invitations", ) document_related_router.register( - "comments", - viewsets.CommentViewSet, - basename="comments", + "threads", + viewsets.ThreadViewSet, + basename="threads", ) document_related_router.register( "ask-for-access", @@ -37,6 +37,13 @@ basename="ask_for_access", ) +thread_related_router = DefaultRouter() +thread_related_router.register( + "comments", + viewsets.CommentViewSet, + basename="comments", +) + # - Routes nested under a template template_related_router = DefaultRouter() @@ -58,6 +65,10 @@ r"^documents/(?P[0-9a-z-]*)/", include(document_related_router.urls), ), + re_path( + r"^documents/(?P[0-9a-z-]*)/threads/(?P[0-9a-z-]*)/", + include(thread_related_router.urls), + ), re_path( r"^templates/(?P[0-9a-z-]*)/", include(template_related_router.urls), From a52ced4f038daedf63a28da53f508ec9472a13ff Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 12 Sep 2025 15:44:27 +0200 Subject: [PATCH 05/14] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20comments=20fe?= =?UTF-8?q?ature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the comments feature for the document editor. We are now able to add, view, and manage comments within the document editor interface. --- CHANGELOG.md | 1 + .../app-impress/doc-comments.spec.ts | 289 +++++++++ .../e2e/__tests__/app-impress/utils-common.ts | 12 +- .../e2e/__tests__/app-impress/utils-share.ts | 33 +- .../impress/src/features/auth/api/types.ts | 2 + .../doc-editor/components/BlockNoteEditor.tsx | 55 +- .../docs/doc-editor/components/DocEditor.tsx | 1 + .../components/comments/DocsThreadStore.tsx | 569 ++++++++++++++++++ .../comments/DocsThreadStoreAuth.tsx | 94 +++ .../doc-editor/components/comments/index.ts | 2 + .../doc-editor/components/comments/styles.tsx | 202 +++++++ .../doc-editor/components/comments/types.ts | 55 ++ .../components/comments/useComments.ts | 33 + .../features/docs/doc-management/types.tsx | 1 + .../components/DocVersionEditor.tsx | 4 +- .../service-worker/plugins/ApiPlugin.ts | 1 + 16 files changed, 1335 insertions(+), 19 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a80fa5bc..ac0f26c15d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to - ✨(frontend) create skeleton component for DocEditor #1491 - ✨(frontend) add an EmojiPicker in the document tree and title #1381 - ✨(frontend) ajustable left panel #1456 +- ✨ Add comments feature to the editor #1330 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts new file mode 100644 index 0000000000..44ab6e7823 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts @@ -0,0 +1,289 @@ +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: 'Add 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 thread.getByRole('button', { name: '👍' }).click(); + + 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 otherThread.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.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: 'Add 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 thread.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(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index c82623679f..49038642d1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -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(); } diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts index d1305f2f38..b24dbaeb4e 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts @@ -1,8 +1,8 @@ import { Page, chromium, expect } from '@playwright/test'; import { - BROWSERS, BrowserName, + getOtherBrowserName, keyCloakSignIn, verifyDocName, } from './utils-common'; @@ -14,7 +14,7 @@ export type LinkRole = 'Reading' | 'Editing'; export const addNewMember = async ( page: Page, index: number, - role: 'Administrator' | 'Owner' | 'Editor' | 'Reader', + role: Role, fillText = 'user.test', ) => { const responsePromiseSearchUser = page.waitForResponse( @@ -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({ diff --git a/src/frontend/apps/impress/src/features/auth/api/types.ts b/src/frontend/apps/impress/src/features/auth/api/types.ts index 680329d1cb..75a46581cf 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -13,3 +13,5 @@ export interface User { short_name: string; language?: string; } + +export type UserLight = Pick; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index eee27c2241..3c0dd74ba5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -14,11 +14,13 @@ import { useCreateBlockNote } from '@blocknote/react'; import { HocuspocusProvider } from '@hocuspocus/provider'; import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; import { Doc, useProviderStore } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; +import { useResponsiveStore } from '@/stores'; import { useHeadings, @@ -34,6 +36,7 @@ import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; +import { cssComments, useComments } from './comments/'; import { AccessibleImageBlock, CalloutBlock, @@ -79,8 +82,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { user } = useAuth(); const { setEditor } = useEditorStore(); const { t } = useTranslation(); + const { isDesktop } = useResponsiveStore(); const { isSynced: isConnectedToCollabServer } = useProviderStore(); const refEditorContainer = useRef(null); + const canSeeComment = doc.abilities.comment && isDesktop; useSaveDoc(doc.id, provider.document, isConnectedToCollabServer); const { i18n } = useTranslation(); @@ -91,6 +96,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const collabName = user?.full_name || user?.email || t('Anonymous'); const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity'; + const threadStore = useComments(doc.id, canSeeComment, user); + const editor: DocsBlockNoteEditor = useCreateBlockNote( { collaboration: { @@ -138,11 +145,25 @@ 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: 'https://i.pravatar.cc/300', + }; + }), + ); + }, tables: { splitCells: true, cellBackgroundColor: true, @@ -152,7 +173,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, schema: blockNoteSchema, }, - [collabName, lang, provider, uploadFile], + [collabName, lang, provider, uploadFile, threadStore], ); useHeadings(editor); @@ -170,7 +191,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, [setEditor, editor]); return ( - + {errorAttachment && ( { /> )} - @@ -196,11 +224,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 +247,10 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => { provider: undefined, }, schema: blockNoteSchema, + comments: { threadStore }, + resolveUsers: async () => { + return Promise.resolve([]); + }, }, [initialContent], ); @@ -228,14 +266,21 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => { useHeadings(editor); return ( - + ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index a2d04ba298..56c4ea1fb2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -116,6 +116,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => { initialContent={provider.document.getXmlFragment( 'document-store', )} + docId={doc.id} /> ) : ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx new file mode 100644 index 0000000000..f09c20b255 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx @@ -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; + +export class DocsThreadStore extends ThreadStore { + protected static COMMENTS_PING = 'commentsPing'; + protected threads: Map = new Map(); + private subscribers = new Set< + (threads: Map) => void + >(); + private awareness?: Awareness; + private lastPingAt = 0; + private pingTimer?: ReturnType; + + 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) => 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 { + 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 { + 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(); + 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 }, +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx new file mode 100644 index 0000000000..57f614813f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx @@ -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), + ); + } +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts new file mode 100644 index 0000000000..28c0870b8e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts @@ -0,0 +1,2 @@ +export * from './styles'; +export * from './useComments'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx new file mode 100644 index 0000000000..a3051a5a05 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx @@ -0,0 +1,202 @@ +import { css } from 'styled-components'; + +export const cssComments = (canSeeComment: boolean) => 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--theme--colors--greyscale-700); + } + } + + // Thread modal + .bn-thread { + width: 400px; + padding: 8px; + box-shadow: 0px 6px 18px 0px #00001229; + margin-left: 20px; + gap: 0; + + .bn-default-styles { + font-family: var(--c--theme--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-comments { + overflow: auto; + max-height: 500px; + + // to allow popovers to escape the thread container + &:has(em-emoji-picker) { + max-height: none; + overflow: visible; + } + + em-emoji-picker { + box-shadow: 0px 6px 18px 0px #00001229; + } + } + + .bn-thread-comment { + padding: 8px; + + & .bn-editor { + padding-left: 32px; + .bn-inline-content { + color: var(--c--theme--colors--greyscale-700); + } + } + + // Emoji + & .bn-badge-group { + padding-left: 32px; + .bn-badge label { + padding: 0 4px; + background: none; + border: 1px solid var(--c--theme--colors--greyscale-300); + border-radius: 4px; + height: 24px; + } + } + + // 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; + } + } + + // 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--theme--colors--greyscale-100); + } + } + + button[role='menuitem'] svg { + color: var(--c--theme--colors--greyscale-600); + } + } + + & svg { + color: var(--c--theme--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: 24px; + padding-inline: 4px; + + &[data-test='save'] { + border: 1px solid var(--c--theme--colors--info-600); + background: var(--c--theme--colors--info-600); + color: white; + } + + &[data-test='cancel'] { + background: white; + border: 1px solid var(--c--theme--colors--greyscale-300); + color: var(--c--theme--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; + } + } + + // 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--theme--colors--info-600); + width: 24px; + height: 24px; + padding: 0; + + &:disabled { + background: var(--c--theme--colors--greyscale-300); + } + + & .mantine-Button-label::before { + content: '🡡'; + font-size: 13px; + color: var(--c--theme--colors--greyscale-100); + } + } + } + } + + // Input first comment + &:not(.selected) { + gap: 0.5rem; + + .bn-container.bn-comment-editor { + min-width: 0; + + .ProseMirror.bn-editor { + cursor: text; + } + } + } + } + } +`; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts new file mode 100644 index 0000000000..be47816572 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts @@ -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 & { + metadata: { abilities: CommentAbilities }; +}; + +export type ClientThreadData = Omit & { + metadata: { abilities: ThreadAbilities; metadata: unknown }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts new file mode 100644 index 0000000000..99be3acfea --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts @@ -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; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index d786366e2d..3160a1a77c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -80,6 +80,7 @@ export interface Doc { children_create: boolean; children_list: boolean; collaboration_auth: boolean; + comment: boolean; destroy: boolean; duplicate: boolean; favorite: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx index a7574a44d4..fc1f76f9c3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx @@ -77,7 +77,9 @@ export const DocVersionEditor = ({ return ( } - docEditor={} + docEditor={ + + } isDeletedDoc={false} readOnly={true} /> diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index e84415a72e..2fe688816b 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -188,6 +188,7 @@ export class ApiPlugin implements WorkboxPlugin { children_create: true, children_list: true, collaboration_auth: true, + comment: true, destroy: true, duplicate: true, favorite: true, From 7e4757dcbad5ebbf807623d56fb594cf5035c3ce Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 12 Sep 2025 15:45:39 +0200 Subject: [PATCH 06/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20add=20user?= =?UTF-8?q?=20avatar=20to=20thread=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app-impress/doc-comments.spec.ts | 6 ++ src/frontend/apps/impress/cunningham.ts | 4 +- .../src/cunningham/cunningham-tokens.css | 6 +- .../src/cunningham/cunningham-tokens.ts | 7 +- .../features/auth/components/AvatarSvg.tsx | 49 +++++++++++++ .../features/auth/components/UserAvatar.tsx | 70 +++++++++++++++++++ .../src/features/auth/components/index.ts | 1 + .../doc-editor/components/BlockNoteEditor.tsx | 16 +++-- .../doc-editor/components/comments/styles.tsx | 18 ++++- .../doc-share/components/SearchUserRow.tsx | 6 +- .../docs/doc-share/components/UserAvatar.tsx | 62 ---------------- 11 files changed, 168 insertions(+), 77 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx create mode 100644 src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/UserAvatar.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts index 44ab6e7823..4549828bd8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts @@ -51,6 +51,9 @@ test.describe('Doc Comments', () => { await thread.locator('[data-test="addreaction"]').first().click(); await thread.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'); @@ -88,6 +91,9 @@ test.describe('Doc Comments', () => { 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(); diff --git a/src/frontend/apps/impress/cunningham.ts b/src/frontend/apps/impress/cunningham.ts index 10b51205b2..5accadd33c 100644 --- a/src/frontend/apps/impress/cunningham.ts +++ b/src/frontend/apps/impress/cunningham.ts @@ -98,8 +98,8 @@ const dsfrTheme = { }, font: { families: { - base: 'Marianne', - accent: 'Marianne', + base: 'Marianne, Inter, Roboto Flex Variable, sans-serif', + accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif', }, }, }, diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css index 3b1c544def..8bfbc0977b 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css +++ b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css @@ -556,8 +556,10 @@ --c--theme--logo--widthHeader: 110px; --c--theme--logo--widthFooter: 220px; --c--theme--logo--alt: gouvernement logo; - --c--theme--font--families--base: marianne; - --c--theme--font--families--accent: marianne; + --c--theme--font--families--base: + marianne, inter, roboto flex variable, sans-serif; + --c--theme--font--families--accent: + marianne, inter, roboto flex variable, sans-serif; --c--components--la-gaufre: true; --c--components--home-proconnect: true; --c--components--favicon--ico: /assets/favicon-dsfr.ico; diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts index f0ebb07ea5..6261deb30a 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts +++ b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts @@ -436,7 +436,12 @@ export const tokens = { widthFooter: '220px', alt: 'Gouvernement Logo', }, - font: { families: { base: 'Marianne', accent: 'Marianne' } }, + font: { + families: { + base: 'Marianne, Inter, Roboto Flex Variable, sans-serif', + accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif', + }, + }, }, components: { 'la-gaufre': true, diff --git a/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx b/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx new file mode 100644 index 0000000000..44c183f0e8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx @@ -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 = ({ + initials, + background, + fontFamily, + ...props +}) => ( + + + + {initials} + + +); diff --git a/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx b/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx new file mode 100644 index 0000000000..9bf836c5de --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx @@ -0,0 +1,70 @@ +import { renderToStaticMarkup } from 'react-dom/server'; + +import { tokens } from '@/cunningham'; + +import { AvatarSvg } from './AvatarSvg'; + +const colors = tokens.themes.default.theme.colors; + +const avatarsColors = [ + colors['blue-500'], + colors['brown-500'], + colors['cyan-500'], + colors['gold-500'], + colors['green-500'], + colors['olive-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 ( + + ); +}; + +export const avatarUrlFromName = ( + fullName?: string, + fontFamily?: string, +): string => { + const name = fullName?.trim() || '?'; + const initials = getInitialFromName(name).toUpperCase(); + const background = getColorFromName(name); + + const svgMarkup = renderToStaticMarkup( + , + ); + + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgMarkup)}`; +}; diff --git a/src/frontend/apps/impress/src/features/auth/components/index.ts b/src/frontend/apps/impress/src/features/auth/components/index.ts index 17f3a9058a..26ebaf2e8b 100644 --- a/src/frontend/apps/impress/src/features/auth/components/index.ts +++ b/src/frontend/apps/impress/src/features/auth/components/index.ts @@ -1,2 +1,3 @@ export * from './Auth'; export * from './ButtonLogin'; +export * from './UserAvatar'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 3c0dd74ba5..193a43bc99 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -18,8 +18,9 @@ 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 { @@ -82,6 +83,7 @@ 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(null); @@ -93,7 +95,8 @@ 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); @@ -104,7 +107,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { provider: provider, fragment: provider.document.getXmlFragment('document-store'), user: { - name: collabName, + name: cursorName, color: randomColor(), }, /** @@ -159,7 +162,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { return { id: encodedURIUserId, username: fullName || t('Anonymous'), - avatarUrl: 'https://i.pravatar.cc/300', + avatarUrl: avatarUrlFromName( + fullName, + themeTokens?.font?.families?.base, + ), }; }), ); @@ -173,7 +179,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, schema: blockNoteSchema, }, - [collabName, lang, provider, uploadFile, threadStore], + [cursorName, lang, provider, uploadFile, threadStore], ); useHeadings(editor); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx index a3051a5a05..446e0ceec7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx @@ -1,6 +1,9 @@ import { css } from 'styled-components'; -export const cssComments = (canSeeComment: boolean) => css` +export const cssComments = ( + canSeeComment: boolean, + currentUserAvatarUrl?: string, +) => css` & .--docs--main-editor, & .--docs--main-editor .ProseMirror { // Comments marks in the editor @@ -155,6 +158,19 @@ export const cssComments = (canSeeComment: boolean) => css` .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 diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx index 529626109d..1ff6f67aec 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx @@ -4,9 +4,7 @@ import { QuickSearchItemContentProps, } from '@/components/quick-search'; import { useCunninghamTheme } from '@/cunningham'; -import { User } from '@/features/auth'; - -import { UserAvatar } from './UserAvatar'; +import { User, UserAvatar } from '@/features/auth'; type Props = { user: User; @@ -36,7 +34,7 @@ export const SearchUserRow = ({ className="--docs--search-user-row" > { - 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]; -}; - -type Props = { - user: User; - background?: string; -}; - -export const UserAvatar = ({ user, background }: Props) => { - const name = user.full_name || user.email || '?'; - const splitName = name?.split(' '); - - return ( - - {splitName[0]?.charAt(0)} - {splitName?.[1]?.charAt(0)} - - ); -}; From 416ef834851edb7bde7987d9fc94c69be94a1acb Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 12 Sep 2025 15:55:31 +0200 Subject: [PATCH 07/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20replace=20?= =?UTF-8?q?default=20comment=20toolbar=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the default comment toolbar button with a custom one to follow the design system. --- .../app-impress/doc-comments.spec.ts | 4 +- .../BlockNoteToolBar/BlockNoteToolbar.tsx | 17 +++- .../comments/CommentToolbarButton.tsx | 81 +++++++++++++++++++ .../doc-editor/components/comments/index.ts | 1 + 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentToolbarButton.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts index 4549828bd8..ec37100da7 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts @@ -36,7 +36,7 @@ test.describe('Doc Comments', () => { // 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: 'Add comment' }).click(); + await page.getByRole('button', { name: 'Comment' }).click(); const thread = page.locator('.bn-thread'); await thread.getByRole('paragraph').first().fill('This is a comment'); @@ -119,7 +119,7 @@ test.describe('Doc Comments', () => { 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: 'Add comment' }).click(); + await page.getByRole('button', { name: 'Comment' }).click(); const thread = page.locator('.bn-thread'); await thread.getByRole('paragraph').first().fill('This is a comment'); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx index 6b7a8181b5..b88e09c7a4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx @@ -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 ( + + {toolbarItems} {/* Extra button to do some AI powered actions */} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentToolbarButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentToolbarButton.tsx new file mode 100644 index 0000000000..3e81a1fff5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentToolbarButton.tsx @@ -0,0 +1,81 @@ +import { useBlockNoteEditor, useComponentsContext } from '@blocknote/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 hasActiveUnresolvedThread = editor._tiptapEditor.isActive('comment', { + orphan: false, + }); + + if ( + !editor.isEditable || + !Components || + !currentDoc?.abilities.comment || + hasActiveUnresolvedThread + ) { + return null; + } + + return ( + + { + editor.comments?.startPendingComment(); + }} + isDisabled={hasActiveUnresolvedThread} + > + + + {t('Comment')} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts index 28c0870b8e..99acd58df1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts @@ -1,2 +1,3 @@ +export * from './CommentToolbarButton'; export * from './styles'; export * from './useComments'; From b279da96b2f757b985a6ef02b78b483365dab0f0 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 1 Oct 2025 18:45:18 +0200 Subject: [PATCH 08/14] =?UTF-8?q?=F0=9F=90=9B(frontend)=20fix=20button=20m?= =?UTF-8?q?arkdown=20not=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On smaller screens, the markdown button in the toolbar was not every time visible. We fix this issue. --- .../BlockNoteToolBar/MarkdownButton.tsx | 19 ++++++++++++++++--- .../src/features/docs/doc-editor/styles.tsx | 4 ++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx index 75a7965d09..6555303cf6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx @@ -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 - + label="M" + icon={ + + M + + } + /> ); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx index 3bade52bc0..8315c425d9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -132,6 +132,10 @@ export const cssEditor = css` .bn-block-outer:not([data-prev-depth-changed]):before { border-left: none; } + + .bn-toolbar { + max-width: 95vw; + } } & .bn-editor { From b85f464a999107b2331c3c594e6c899e74ace84a Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 12 Nov 2025 13:25:21 +0100 Subject: [PATCH 09/14] =?UTF-8?q?fixup!=20=E2=99=BB=EF=B8=8F(frontend)=20a?= =?UTF-8?q?dd=20user=20avatar=20to=20thread=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/doc-editor/components/BlockNoteEditor.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 193a43bc99..525fbbefea 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -12,7 +12,7 @@ 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'; @@ -101,6 +101,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const threadStore = useComments(doc.id, canSeeComment, user); + const currentUserAvatarUrl = useMemo(() => { + if (!canSeeComment) { + return undefined; + } + return avatarUrlFromName(collabName, themeTokens?.font?.families?.base); + }, [canSeeComment, collabName, themeTokens?.font?.families?.base]); + const editor: DocsBlockNoteEditor = useCreateBlockNote( { collaboration: { @@ -201,7 +208,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { ref={refEditorContainer} $css={css` ${cssEditor}; - ${cssComments(canSeeComment)} + ${cssComments(canSeeComment, currentUserAvatarUrl)} `} > {errorAttachment && ( From 6ad856468f8769e39cf923192bdff518d365d03c Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 12 Nov 2025 16:11:38 +0100 Subject: [PATCH 10/14] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20add=20comm?= =?UTF-8?q?ents=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doc-editor/components/comments/styles.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx index 446e0ceec7..93055e9dc9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx @@ -15,6 +15,11 @@ export const cssComments = ( } } + em-emoji-picker { + box-shadow: 0px 6px 18px 0px #00001229; + min-height: 420px; + } + // Thread modal .bn-thread { width: 400px; @@ -44,16 +49,6 @@ export const cssComments = ( .bn-thread-comments { overflow: auto; max-height: 500px; - - // to allow popovers to escape the thread container - &:has(em-emoji-picker) { - max-height: none; - overflow: visible; - } - - em-emoji-picker { - box-shadow: 0px 6px 18px 0px #00001229; - } } .bn-thread-comment { From 0bfe580e7561b4856f80e96cee2add0891fe0a5c Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 12 Nov 2025 20:46:33 +0100 Subject: [PATCH 11/14] =?UTF-8?q?=E2=AC=86=EF=B8=8F(dependencies)=20upgrad?= =?UTF-8?q?e=20Blocknote=20to=20last=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last version of Blocknote includes fixes for some issues related to comments. It introduces as well a bug about "previewWidth", plus compatibility issues with "prosemirror-state". This commit fixes those issues. --- src/frontend/apps/impress/package.json | 15 +- .../doc-editor/components/BlockNoteEditor.tsx | 55 ++- .../components/custom-blocks/CalloutBlock.tsx | 1 - .../components/custom-blocks/PdfBlock.tsx | 4 +- src/frontend/package.json | 3 +- src/frontend/servers/y-provider/package.json | 4 +- src/frontend/yarn.lock | 395 +++++++++--------- 7 files changed, 272 insertions(+), 205 deletions(-) diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 7c56c1d2f3..228f18ccb8 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -19,13 +19,13 @@ }, "dependencies": { "@ag-media/react-pdf-table": "2.0.3", - "@blocknote/code-block": "0.41.1", - "@blocknote/core": "0.41.1", - "@blocknote/mantine": "0.41.1", - "@blocknote/react": "0.41.1", - "@blocknote/xl-docx-exporter": "0.41.1", - "@blocknote/xl-multi-column": "0.41.1", - "@blocknote/xl-pdf-exporter": "0.41.1", + "@blocknote/code-block": "0.42.0", + "@blocknote/core": "0.42.0", + "@blocknote/mantine": "0.42.0", + "@blocknote/react": "0.42.0", + "@blocknote/xl-docx-exporter": "0.42.0", + "@blocknote/xl-multi-column": "0.42.0", + "@blocknote/xl-pdf-exporter": "0.42.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@emoji-mart/data": "1.2.1", @@ -42,6 +42,7 @@ "@react-pdf/renderer": "4.3.1", "@sentry/nextjs": "10.22.0", "@tanstack/react-query": "5.90.6", + "@tiptap/core": "3.10.1", "@tiptap/extensions": "3.10.1", "canvg": "4.0.3", "clsx": "2.1.1", diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 525fbbefea..0a148cd852 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -53,15 +53,68 @@ import XLMultiColumn from './xl-multi-column'; const multiColumnLocales = XLMultiColumn?.locales; const withMultiColumn = XLMultiColumn?.withMultiColumn; +// Patch video/file/audio/image blocks to have a valid number default for previewWidth +// This fixes the ProseMirror error: "No value supplied for attribute previewWidth" +// BlockNote's default blocks use `undefined` which causes runtime errors +const patchedVideoBlock = { + ...defaultBlockSpecs.video, + config: { + ...defaultBlockSpecs.video.config, + propSchema: { + ...defaultBlockSpecs.video.config.propSchema, + previewWidth: { default: 512, type: 'number' as const }, + }, + }, +}; + +const patchedFileBlock = { + ...defaultBlockSpecs.file, + config: { + ...defaultBlockSpecs.file.config, + propSchema: { + ...defaultBlockSpecs.file.config.propSchema, + previewWidth: { default: 512, type: 'number' as const }, + }, + }, +}; + +const patchedAudioBlock = { + ...defaultBlockSpecs.audio, + config: { + ...defaultBlockSpecs.audio.config, + propSchema: { + ...defaultBlockSpecs.audio.config.propSchema, + previewWidth: { default: 512, type: 'number' as const }, + }, + }, +}; + +const patchedImageBlock = () => { + const imageSpec = AccessibleImageBlock(); + return { + ...imageSpec, + config: { + ...imageSpec.config, + propSchema: { + ...imageSpec.config.propSchema, + previewWidth: { default: 512, type: 'number' as const }, + }, + }, + }; +}; + const baseBlockNoteSchema = withPageBreak( BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, + audio: patchedAudioBlock, callout: CalloutBlock(), codeBlock: createCodeBlockSpec(codeBlockOptions), - image: AccessibleImageBlock(), + file: patchedFileBlock, + image: patchedImageBlock(), pdf: PdfBlock(), uploadLoader: UploadLoaderBlock(), + video: patchedVideoBlock, }, inlineContentSpecs: { ...defaultInlineContentSpecs, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx index 9027ec69dc..15ad01f341 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx @@ -173,5 +173,4 @@ export const getCalloutFormattingToolbarItems = ( name: t('Callout'), type: 'callout', icon: () => , - isSelected: (block) => block.type === 'callout', }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx index ace3e738d7..6948aa9e98 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx @@ -36,7 +36,7 @@ type CreatePDFBlockConfig = BlockConfig< backgroundColor: { default: 'default' }; caption: { default: '' }; name: { default: '' }; - previewWidth: { default: undefined; type: 'number' }; + previewWidth: { default: 512; type: 'number' }; showPreview: { default: true }; textAlignment: { default: 'left' }; url: { default: '' }; @@ -117,7 +117,7 @@ export const PdfBlock = createReactBlockSpec( backgroundColor: { default: 'default' as const }, caption: { default: '' as const }, name: { default: '' as const }, - previewWidth: { default: undefined, type: 'number' }, + previewWidth: { default: 512, type: 'number' }, showPreview: { default: true }, textAlignment: { default: 'left' as const }, url: { default: '' as const }, diff --git a/src/frontend/package.json b/src/frontend/package.json index f617eee575..c4cc029279 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -36,8 +36,9 @@ "@types/react-dom": "19.2.2", "@typescript-eslint/eslint-plugin": "8.46.2", "@typescript-eslint/parser": "8.46.2", - "docx": "9.5.0", + "docx": "9.5.1", "eslint": "9.39.0", + "prosemirror-state": "1.4.3", "react": "19.2.0", "react-dom": "19.2.0", "typescript": "5.9.3", diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index 38fd3b18f3..f90d11d7de 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -16,7 +16,7 @@ "node": ">=22" }, "dependencies": { - "@blocknote/server-util": "0.41.1", + "@blocknote/server-util": "0.42.0", "@hocuspocus/server": "3.4.0", "@sentry/node": "10.22.0", "@sentry/profiling-node": "10.22.0", @@ -30,7 +30,7 @@ "yjs": "*" }, "devDependencies": { - "@blocknote/core": "0.41.1", + "@blocknote/core": "0.42.0", "@hocuspocus/provider": "3.4.0", "@types/cors": "2.8.19", "@types/express": "5.0.5", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 58dc00a071..fbc7c71a56 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -1070,49 +1070,49 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@blocknote/code-block@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/code-block/-/code-block-0.41.1.tgz#bfe0597d3a995b8a2a08307753ec858306781a2d" - integrity sha512-GrvRW0Q9Y6HHCC7tbA76YoGafZPwFqbJgflOVl/kj0h142v8NMH5dgtO/FR/8DOSdnoe9dSUBqUott5ATQo/qg== - dependencies: - "@blocknote/core" "0.41.1" - "@shikijs/core" "^3.2.1" - "@shikijs/engine-javascript" "^3.2.1" - "@shikijs/langs" "^3.2.1" - "@shikijs/langs-precompiled" "^3.2.1" - "@shikijs/themes" "^3.2.1" +"@blocknote/code-block@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/code-block/-/code-block-0.42.0.tgz#19340fdb3cc23174bea38d9c36a7d6b83e8c7505" + integrity sha512-N8f6jPVS7m95Cpxf9SJGScPw4bq2cjwl5KtyZAmR7HWMo7K5s+/1jp8SQBV2oLLQ7SVWxSBB3elDq5Y3RYiPaw== + dependencies: + "@blocknote/core" "0.42.0" + "@shikijs/core" "^3.13.0" + "@shikijs/engine-javascript" "^3.13.0" + "@shikijs/langs" "^3.13.0" + "@shikijs/langs-precompiled" "^3.13.0" + "@shikijs/themes" "^3.13.0" "@shikijs/types" "^3.13.0" -"@blocknote/core@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.41.1.tgz#63959297ced039874e5661138bfdffa43605f6ad" - integrity sha512-p/wxXzpl0/c9QwqXWcZ4KXzI+OjVzQOzSNaO5KrtDPDi7M1Bj6sc9L0+/V/8Wyo+XTY+tZOrtu6qCXVYIEJ/Rw== +"@blocknote/core@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.42.0.tgz#eba21388af2fd5dfecefa60585dd70ff37b1d000" + integrity sha512-V478pyJFTJxdKmVo2ICqKNVzuRNgBOK3Zg3Ut1AKozrOGJ2mB4h1esDncK01P/DSCLDro6TmSobOrVAF55w1uA== dependencies: "@emoji-mart/data" "^1.2.1" "@shikijs/types" "3.13.0" - "@tiptap/core" "^3.4.3" - "@tiptap/extension-bold" "^3" - "@tiptap/extension-code" "^3" - "@tiptap/extension-gapcursor" "^3" - "@tiptap/extension-history" "^3" - "@tiptap/extension-horizontal-rule" "^3" - "@tiptap/extension-italic" "^3" - "@tiptap/extension-link" "^3" - "@tiptap/extension-paragraph" "^3" - "@tiptap/extension-strike" "^3" - "@tiptap/extension-text" "^3" - "@tiptap/extension-underline" "^3" - "@tiptap/pm" "^3.4.3" + "@tiptap/core" "^3.10.2" + "@tiptap/extension-bold" "^3.7.2" + "@tiptap/extension-code" "^3.7.2" + "@tiptap/extension-gapcursor" "^3.7.2" + "@tiptap/extension-history" "^3.7.2" + "@tiptap/extension-horizontal-rule" "^3.7.2" + "@tiptap/extension-italic" "^3.7.2" + "@tiptap/extension-link" "^3.7.2" + "@tiptap/extension-paragraph" "^3.7.2" + "@tiptap/extension-strike" "^3.7.2" + "@tiptap/extension-text" "^3.7.2" + "@tiptap/extension-underline" "^3.7.2" + "@tiptap/pm" "^3.10.2" emoji-mart "^5.6.0" - fast-deep-equal "^3" + fast-deep-equal "^3.1.3" hast-util-from-dom "^5.0.1" prosemirror-dropcursor "^1.8.2" prosemirror-highlight "^0.13.0" - prosemirror-model "^1.25.3" - prosemirror-state "^1.4.3" - prosemirror-tables "^1.6.4" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-tables "^1.8.1" prosemirror-transform "^1.10.4" - prosemirror-view "^1.41.2" + prosemirror-view "^1.41.3" rehype-format "^5.0.1" rehype-parse "^9.0.1" rehype-remark "^10.0.1" @@ -1128,81 +1128,81 @@ y-protocols "^1.0.6" yjs "^13.6.27" -"@blocknote/mantine@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.41.1.tgz#3bd2030d79376df5a3c44ec3cdf601eea50d9bdf" - integrity sha512-0geMa5zRd3d67xpDCGAclW4y0yQ8Hn0ldjzUz7ilB/9NpYt+f9Y5uuuaK4DwchwYuMmCLDrtjtAh/jfmdSnnjw== +"@blocknote/mantine@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.42.0.tgz#9b9bdb7463e4d1cb9d463d545691b36bdbd87e0f" + integrity sha512-N4sbfFm6mJaS9oRBOibyUUDdWDns1350m3CAUJXJzmTCp0ugOlVm+TPzMj2ENnALwkYEDtLWIDXrfen7XQ8r6Q== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/react" "0.41.1" - react-icons "^5.2.1" + "@blocknote/core" "0.42.0" + "@blocknote/react" "0.42.0" + react-icons "^5.5.0" -"@blocknote/react@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.41.1.tgz#4f35f8314ad001903cf9793e7dc9673b05ec3344" - integrity sha512-W1lRcyjpgNOZzASIbdX/fvEQ3ZML7FzHyS2xA9CskxOPrGvxHWREn+vper/hkchlTZ4I2dTx/IWwGAECT+2AvA== +"@blocknote/react@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.42.0.tgz#c2099574c21bb55744a91436aff77032d44a6788" + integrity sha512-6o2lUdzQAffe20mkb8q+KnNZLQ5MjAMztnMV14I0eraSpdFfNt11msASbL6uYXTwLL8mLSGaMUojcR8mLv3u1w== dependencies: - "@blocknote/core" "0.41.1" + "@blocknote/core" "0.42.0" "@emoji-mart/data" "^1.2.1" "@floating-ui/react" "^0.27.16" - "@tiptap/core" "^3.4.3" - "@tiptap/pm" "^3.4.3" - "@tiptap/react" "^3.4.3" + "@tiptap/core" "^3.10.2" + "@tiptap/pm" "^3.10.2" + "@tiptap/react" "^3.10.2" emoji-mart "^5.6.0" lodash.merge "^4.6.2" - react-icons "^5.2.1" + react-icons "^5.5.0" -"@blocknote/server-util@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/server-util/-/server-util-0.41.1.tgz#db738ea33039a9dd54c0a92e9c3ed72bfa354cb3" - integrity sha512-xZaj/jwKq4rVdOxaNyBmJIJTZ0c8++Ttvy6Zp9W7B2XLxT9baGbsAtXCTra+lBHCf/XyqvA12UuuyB4KrA5bnQ== +"@blocknote/server-util@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/server-util/-/server-util-0.42.0.tgz#5048735228a770bf674dfd61af041a477241c849" + integrity sha512-cV0bwaT1xqYujbyUbg0TodSm1+CBsWFdnOO5oXdmKH5Tl4T1Cl/nJ+SUAxXZuJK3onl/yCRE2WbDeGjMUkn7Jw== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/react" "0.41.1" - "@tiptap/core" "^3.4.3" - "@tiptap/pm" "^3.4.3" + "@blocknote/core" "0.42.0" + "@blocknote/react" "0.42.0" + "@tiptap/core" "^3.10.2" + "@tiptap/pm" "^3.10.2" jsdom "^25.0.1" y-prosemirror "^1.3.7" y-protocols "^1.0.6" yjs "^13.6.27" -"@blocknote/xl-docx-exporter@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.41.1.tgz#498f68c7eb9a42c2c3606f61d972c815f92cb0e5" - integrity sha512-dyMY/jcxTlZCKpV1ABve7me4gCZHQzkcY5sqzvZtbqmv0IslqD4xq06ZU9KYUoCLnrJTEuRXN9dxnaCp08ipRQ== +"@blocknote/xl-docx-exporter@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.42.0.tgz#d9155d56c42de747663f0eac0a01f16c71801f9d" + integrity sha512-CPsaI39LZMTZCwy1anUP4J7ClcZNyLp2lpwf2IunDq9XNBqOweRKrt9IBXceC3/VIf/t80k3sl58rOtbZjt5lQ== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/xl-multi-column" "0.41.1" + "@blocknote/core" "0.42.0" + "@blocknote/xl-multi-column" "0.42.0" buffer "^6.0.3" - docx "^9.0.2" - image-meta "^0.2.1" - -"@blocknote/xl-multi-column@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/xl-multi-column/-/xl-multi-column-0.41.1.tgz#c2ab05c5cb6c1666c28e405689d6f85ec0a2cd0b" - integrity sha512-SAYyKLpvdWfiuWPRAg7eR9/XfyX51f1o8tptcKWwfCk0FBND+VmYK7IJuxq1N789wdm+msuxUedSZWzKiSErpA== - dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/react" "0.41.1" - "@tiptap/core" "^3.4.3" - prosemirror-model "^1.25.3" - prosemirror-state "^1.4.3" - prosemirror-tables "^1.3.7" + docx "^9.5.1" + image-meta "^0.2.2" + +"@blocknote/xl-multi-column@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-multi-column/-/xl-multi-column-0.42.0.tgz#1225fa7a2fa91e40c7b71a88a457c7af2cfcd3d7" + integrity sha512-qnJbDCaQblAnHMR9H9BGMEulBpDUdWPqtrWTwM51NFlDpmA1IdoNksYzI/gPo1WU6IAeYSpdW9W3iMVHuoI5tQ== + dependencies: + "@blocknote/core" "0.42.0" + "@blocknote/react" "0.42.0" + "@tiptap/core" "^3.10.2" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-tables "^1.8.1" prosemirror-transform "^1.10.4" - prosemirror-view "^1.41.2" - react-icons "^5.2.1" + prosemirror-view "^1.41.3" + react-icons "^5.5.0" -"@blocknote/xl-pdf-exporter@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.41.1.tgz#b55c7e8c6a21ae069a42671b1391eab9d4119195" - integrity sha512-Ixhlm2iV9a82AroMvW7w2RTNp4H7aEkYO/ar6bcMTIZul2kmg06akkw+tQc3rH+CLYQTiFXr9iKwGdnLxoIDww== +"@blocknote/xl-pdf-exporter@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.42.0.tgz#ea9345e1b9c5c970ad3c1db142704d573743064a" + integrity sha512-h2iQNUptd3Pl79B8WRS+ZL73yhuDqQMXLW60GJq3nkPDw1osmWFZUN0Dg5UhYvflT8j0gS1wDNM1+2K52ou2lQ== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/react" "0.41.1" - "@blocknote/xl-multi-column" "0.41.1" + "@blocknote/core" "0.42.0" + "@blocknote/react" "0.42.0" + "@blocknote/xl-multi-column" "0.42.0" "@react-pdf/renderer" "^4.3.0" buffer "^6.0.3" - docx "^9.0.2" + docx "^9.5.1" "@cacheable/memoize@^2.0.3": version "2.0.3" @@ -4953,46 +4953,46 @@ unplugin "1.0.1" uuid "^9.0.0" -"@shikijs/core@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-3.13.0.tgz#73503364a1eb51b65cf904115c62fed7a47df596" - integrity sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA== +"@shikijs/core@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-3.15.0.tgz#eee251070b4e39b59e108266cbcd50c85d738d54" + integrity sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" "@shikijs/vscode-textmate" "^10.0.2" "@types/hast" "^3.0.4" hast-util-to-html "^9.0.5" -"@shikijs/engine-javascript@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-3.13.0.tgz#d25cefdac378216a95fefdf0b3a560550393ea65" - integrity sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg== +"@shikijs/engine-javascript@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-3.15.0.tgz#478dd4feb3b4b7e91f148cc9e7ebc0b7de5fbb18" + integrity sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" "@shikijs/vscode-textmate" "^10.0.2" oniguruma-to-es "^4.3.3" -"@shikijs/langs-precompiled@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/langs-precompiled/-/langs-precompiled-3.13.0.tgz#6ac03cc178fc246e0ddae386a8339b4bd5499c31" - integrity sha512-B2xmXar8IdCy2Gf+VtWmcv8tWpfeFPxPP3eKDa13dKshERbxHHVe0gCV+NrlcWbyVxBm22IUqqj7TIewJstNBQ== +"@shikijs/langs-precompiled@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/langs-precompiled/-/langs-precompiled-3.15.0.tgz#7ad88657a4658eba1c261f074d2eff12980f748e" + integrity sha512-APb/UJeT1FPttKYyi2qMsN9OtGSU14xXME9ecSjb9uNchxo5Kszw+BLufBS6I9/5SFaUDmKxunZV1OIm/Pe3ug== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" oniguruma-to-es "^4.3.3" -"@shikijs/langs@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-3.13.0.tgz#51a927c8089dffb2560ac8d7549297de9d081b91" - integrity sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ== +"@shikijs/langs@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-3.15.0.tgz#d8385a9ca66ce9923149c650336444b1d25fc248" + integrity sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" -"@shikijs/themes@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-3.13.0.tgz#ee92780f0580d4ffa8ed619b52c5eb4a95d012a3" - integrity sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg== +"@shikijs/themes@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-3.15.0.tgz#6093a90191b89654045c72636ddd35c04273658f" + integrity sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" "@shikijs/types@3.13.0", "@shikijs/types@^3.13.0": version "3.13.0" @@ -5002,6 +5002,14 @@ "@shikijs/vscode-textmate" "^10.0.2" "@types/hast" "^3.0.4" +"@shikijs/types@3.15.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-3.15.0.tgz#4e025b4dea98e1603243b1f00677854e07e5eda1" + integrity sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw== + dependencies: + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.4" + "@shikijs/vscode-textmate@^10.0.2": version "10.0.2" resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224" @@ -5242,79 +5250,84 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== -"@tiptap/core@^3.4.3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.7.2.tgz#f9333a9dce628d72c05f919abca1148896161338" - integrity sha512-fJwNpTx0aq4UU0HNkxPvPYfNBcTHQ/q5xBUdOB5Mgu6clwGES38jVsNNSudB8g53APUmJIS+2fJbkxl3V+0jww== +"@tiptap/core@3.10.1": + version "3.10.1" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.10.1.tgz#89fec750db261ee569f64926b3b9f9a509b81ea3" + integrity sha512-YY/u+RsjLVhcUaIn+wv6vjMx8kldO7SzFFnRu0iuC+QW57VrlqUzqz5PR6CenphwJHuqGM5b3SCr4K2ZPjN8jQ== -"@tiptap/extension-bold@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.7.2.tgz#cbc94eb7239ef36e1f5581f93e0001bac013c4c6" - integrity sha512-bwCn9lQEXnEi7LfIx3G/oaH4I0ZapAgrHzLCNJH/tNgRKVWym1H1Oa8PlkiFDbalWOdUkbgeAUqUaIB13k408Q== +"@tiptap/core@^3.10.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.10.5.tgz#5695b357498c9fd510b1d77df5d889955178e049" + integrity sha512-JvvgWrQMP+yEhw20Q2+N62k+G8tspko7oLQxBktnN3PLlP67nKb1qOBzcrnEGsaiASjSu25myUmxY+ZpOmP+MQ== -"@tiptap/extension-bubble-menu@^3.7.2": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.7.2.tgz#c6cac8a2db5ef12d1fb12538413ec2ae578a0dad" - integrity sha512-rCJu/X7sZEYWkOwLO342JP06f4giVBECPzr/SzG/fQdAidPW96eilPk3L82w5j24kS9odTlxSLlFlIf6UZ2b9w== +"@tiptap/extension-bold@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.10.5.tgz#3163bcd9a6a14aae3ecd11e92008b3cd274c974a" + integrity sha512-YvslsSF4oSwBXMc9tDAUfqr8VmmQ8W/TJVdfdai+gwHSkCWtaqsojUmnAFSiMIyHg5ACVPYMdoThP3aqScNrWQ== + +"@tiptap/extension-bubble-menu@^3.10.5": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.10.5.tgz#6287af38e03dbca9232576684488d312a5901c22" + integrity sha512-Kc71uAonSNCYoY+0oxLkxqk846ZgDYy8D28iKUp8Fc+0s6efpwbXLNVma5ooLlOKEz8vKeZNYVOKphTg/0xdZw== dependencies: "@floating-ui/dom" "^1.0.0" -"@tiptap/extension-code@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.7.2.tgz#80b43d1590cff592c25b7552c3706b4f4fbbece3" - integrity sha512-J8FaCiKJJnHvQiPcbfbUtc5RNmGx/Gui/K5CDMPc17jhCiQ9JhR9idRPREV24Z2t7GujWX7LG6ZDDR82pSns+g== +"@tiptap/extension-code@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.10.5.tgz#6315e8e9364ea280297505dcecd9a8241d3127ec" + integrity sha512-zt7w+AQ5El5rERY8QsiDlpCmaw951hLORUeI6p7HjgnA6KQoBJXxz750W8bfqEYnLFJ9uOXQka/Ds37/IyijGA== -"@tiptap/extension-floating-menu@^3.7.2": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.7.2.tgz#927217d238b45695621be75398795974c3fa3737" - integrity sha512-g19ratrXlplYDS29VLQa1y/IM/ro0UFhSS4fQokiQKkazwnA1ZVnebjw8ERYg5lkMm/hiImqstpgdO0LtoivvQ== +"@tiptap/extension-floating-menu@^3.10.5": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.10.5.tgz#5dc1d63e5fb10208adcc5bcefd0ff713ad53dfd9" + integrity sha512-N9qxP37s+gpOCBBrwoVaK1e61wuYY/KnEkhhodmYxMS/zuRHQ79ics7i1B18CJFfcT6+USHB6HzNxLLrlmpt4w== -"@tiptap/extension-gapcursor@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.7.2.tgz#2e2a673a42c87854ecb989d2aebf213514e04d63" - integrity sha512-vCLo2dL2SfeWjh/gJKDiu0/fz6OF7obGTJvHg/yStkoUqlAEiwKoyHP/NXeTGYJMzZzUi0kY9DtTEJdGFvphuQ== +"@tiptap/extension-gapcursor@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.10.5.tgz#1627a54930428ac90549e95dd5f25de982678175" + integrity sha512-/C13mvMGmQ3/8T9jg5KiT1/kMM9rFOXzsI2x0KO6IImTMBke6v259qEJyUCfG+Q90ttf95Muymp4I5FbFqJPww== -"@tiptap/extension-history@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-3.7.2.tgz#4210b78a726ad6763b1cf1a506bac56e5dfafcff" - integrity sha512-iAGTUxAr7r+tQ/PtIG94jqTJLy/S/VwE43USfWzXCHvbLn60cPJJG7MTCZxYbd+ZuivZVhEhp3EbzCNmHxjp8A== +"@tiptap/extension-history@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-3.10.5.tgz#5d65ba5e0b043f71c58996c2534a542aea0ecd22" + integrity sha512-c/tm6pG+dQMjgKahpZaIsw6BhqC+QZiYrCNkl3hWWwK5zJkDRWVxMNPHB+uDFQkQER03k4VeqwjD4YXX9MuVWg== -"@tiptap/extension-horizontal-rule@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.7.2.tgz#5d8e9d70e7c6ba91b3e4d9a7e1693232c37ce161" - integrity sha512-pN+1hJAVVP3uqtpZ5Rm7z5XUB/NGprK6wExJ04xG117E4rTVcaEb1FnMILY3J3A5XbdC3vHX+cblR8mOl1PAMw== +"@tiptap/extension-horizontal-rule@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.10.5.tgz#67ffd126b3b946ff8a99f37cf93b430cc523246c" + integrity sha512-okzBJVm68QMb9mwYM74az5bzHuXV6NSuoSb32dRwt3zx8zVdNUK0z585SxlQGDJ3ksB+1UR5tGlcst6sT05esw== -"@tiptap/extension-italic@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.7.2.tgz#8425d27a67e1d70172ebebcc0cfa7fd8afe5b50b" - integrity sha512-1tfF37LvKgA5hg09UBgOjdMLNRb1C6keIOBF0r5oHKeWPYOf4z3j5IU9PsFUoOn53XRMb1aiD/TNbGPyoT3Fyw== +"@tiptap/extension-italic@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.10.5.tgz#9f6d48d3a0d62196d0554a2c0904cca09dc02450" + integrity sha512-f4RI0KGmtkZgVQlthrSm0dEIwx6Vw1ppbQevJecaaXLQTBoS9jAhqRrZbWkqWnof2fTyO2KK23wAMHAjXOKvmg== -"@tiptap/extension-link@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.7.2.tgz#6a2f6b979eb86905c8776df12a71838fb5e2007c" - integrity sha512-9K54PxBiDSWAMfICqkb8jcQ6cL7vDAtjTk0zqBw4d+XuaUy0FC9QUdbx7r1Pkbf36K1/ApbvM9a7qpOirWk8Xw== +"@tiptap/extension-link@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.10.5.tgz#65c290f41f2263d754cea26c487f96a2775187eb" + integrity sha512-6lb8SZSi2+N37/BzkOwFaHHSbloZ3CG5OT0J+h9MULdH0/X8d8fvK/Kxtfa0NgxYWP446u62HJkjvAk234k/Jw== dependencies: linkifyjs "^4.3.2" -"@tiptap/extension-paragraph@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.7.2.tgz#4e9a84d36c96a92cccc7c6bec9f4f744beef413c" - integrity sha512-HmDuAixTcvP4A/v6OLkh/C6nB86i7/DRNswBf/Udak8TgWUIcSUK0iActxxm5+B3MZTSf3U87JzyI6IeuElLIQ== +"@tiptap/extension-paragraph@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.10.5.tgz#b95a07be1b47a797ecfed8e343f4acaffa44a4dd" + integrity sha512-YCXFJiWC2w3Q62Q+nAf0SYWNysbQYpBA+fNyczaz+9vJYUBPcuu1WB0kdIGg/07RsUsg0U0txBnx+GuDuWXh2A== -"@tiptap/extension-strike@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.7.2.tgz#fd82bc48e47413bb80caa53b6fb9d90c4edeff3c" - integrity sha512-I1G+4vZbCBTpAMmyVwaO8cLBJgXEf1DyEzc0B+HhTJiBa9qA9OKgRQEGFgisxu1kggjbzB6+d0+taHfjsZC1SQ== +"@tiptap/extension-strike@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.10.5.tgz#b028635cf62163aca9b234c3b7b80ffd04930264" + integrity sha512-tyJs3ArYGaYi/cy79NWKfkb7nIcWocFmou3JW229mZ+lFH1jNy9jGnIITN8BG8aizC6QkXBaeEy8+uBUJ+5Tkw== -"@tiptap/extension-text@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.7.2.tgz#fbaf60768a2d3ef77f51e5e070614bdebf371be2" - integrity sha512-sKaeGYNP1+bAe2rvmzWLW5qH9DsSFOJlOUEOFchR0OX0rC7bbGS6/KuyAq0w6UkL+cMJnDyAbv3KeD2WEA192w== +"@tiptap/extension-text@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.10.5.tgz#73bec4470cb195cf57c6fdc1574fb3373ae39e70" + integrity sha512-anLb/bJ9TfjVjOAdqQGQTDYsvI5Tgid1RgRokpK6vOxVhukBW7q8RnIsyWYj/uV6UefS6YtVOl5H9DAnpCVfvg== -"@tiptap/extension-underline@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.7.2.tgz#e3831c7211501ba4b8bbd1f588c6b86f351fa13a" - integrity sha512-GDpUZllTD7uIdHjTzYJ6i4jUgCeviW40SCpLVVv1xH0gj1t1xu0Rnxmk+bXkF2XNe8jPXkMCgYNr6DR6eO8roQ== +"@tiptap/extension-underline@^3.7.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.10.5.tgz#e519ba81fcbb92e34fec984c0ebc07a645981b75" + integrity sha512-2SUQTsD5CYwTAdEFIifGHW/N363B9x5qxdzz2wrzn1mbJB4yG6QKyYY0uAGHmSOeC8iHS8scWDFiU09NlAhoew== "@tiptap/extensions@*": version "3.7.2" @@ -5326,10 +5339,10 @@ resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.10.1.tgz#7faab67917a779a77ec89f588ad5fb7670b4dbff" integrity sha512-tZZ1IGIcch4ezuoid3iPSirh0s2GQuSKY6ceWRJCVeZ2gT2LsN3i10tqfidcYrsmyQRMuM7QUfRmH5HOKJZ73Q== -"@tiptap/pm@^3.4.3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.7.2.tgz#28953dc6e310445250b5b858286186c5ada14a2f" - integrity sha512-i2fvXDapwo/TWfHM6STYEbkYyF3qyfN6KEBKPrleX/Z80G5bLxom0gB79TsjLNxTLi6mdf0vTHgAcXMG1avc2g== +"@tiptap/pm@^3.10.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.10.5.tgz#25413a6127714e6f8e89b6f5cf7b4e203898afda" + integrity sha512-yILFuY8nyZbfbJQh1aZfwT/E4o5dHrKXnWsGDiljdr+6NryaU+hcmlwlbz6Q+0550Ik0B2oQoRxAnhzz7qH3Hg== dependencies: prosemirror-changeset "^2.3.0" prosemirror-collab "^1.3.1" @@ -5350,17 +5363,17 @@ prosemirror-transform "^1.10.2" prosemirror-view "^1.38.1" -"@tiptap/react@^3.4.3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.7.2.tgz#bf12f0f33f76407abf06b8530cb9461335a1e659" - integrity sha512-tka4ioSmsGI4TyGZ7jAUoIw8t8DVjr1It0B38vZVLqg8M/ZFgR1NkF50TJ6qAkhy8Uz12AO50so0v79tV2pmEA== +"@tiptap/react@^3.10.2": + version "3.10.5" + resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.10.5.tgz#2eeecfe529b7a0411343c9eb8d80e0acf495b71f" + integrity sha512-HP5qn4tMNlAGieTO4ChzbJ/QrGZPrEvF31vRqDQYMre0aSwIHsClQ0fOhOs2YM2wYQgX5dKwymoE7n+9MxJ6Pw== dependencies: "@types/use-sync-external-store" "^0.0.6" fast-deep-equal "^3.1.3" use-sync-external-store "^1.4.0" optionalDependencies: - "@tiptap/extension-bubble-menu" "^3.7.2" - "@tiptap/extension-floating-menu" "^3.7.2" + "@tiptap/extension-bubble-menu" "^3.10.5" + "@tiptap/extension-floating-menu" "^3.10.5" "@trysound/sax@0.2.0": version "0.2.0" @@ -5653,7 +5666,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@22.10.7", "@types/node@24.10.0", "@types/node@^22.7.5": +"@types/node@*", "@types/node@22.10.7", "@types/node@24.10.0", "@types/node@^24.0.1": version "24.10.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.0.tgz#6b79086b0dfc54e775a34ba8114dcc4e0221f31f" integrity sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A== @@ -7665,12 +7678,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -docx@*, docx@9.5.0, docx@^9.0.2: - version "9.5.0" - resolved "https://registry.yarnpkg.com/docx/-/docx-9.5.0.tgz#586990c4ecf1c7e83290529997b33f2c029bbe68" - integrity sha512-WZggg9vVujFcTyyzfIVBBIxlCk51QvhLWl87wtI2zuBdz8C8C0mpRhEVwA2DZd7dXyY0AVejcEVDT9vn7Xm9FA== +docx@*, docx@9.5.1, docx@^9.5.1: + version "9.5.1" + resolved "https://registry.yarnpkg.com/docx/-/docx-9.5.1.tgz#325c9c45dccf052e5780515d6068e80fdee81960" + integrity sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ== dependencies: - "@types/node" "^22.7.5" + "@types/node" "^24.0.1" hash.js "^1.1.7" jszip "^3.10.1" nanoid "^5.1.3" @@ -8450,7 +8463,7 @@ extend@^3.0.0: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -fast-deep-equal@^3, fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== @@ -9485,7 +9498,7 @@ ignore@^7.0.0, ignore@^7.0.5: resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== -image-meta@^0.2.1: +image-meta@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/image-meta/-/image-meta-0.2.2.tgz#a88dbdf1983d7c23a80c3e71d3b8acdb5379f5e0" integrity sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA== @@ -12127,7 +12140,7 @@ prosemirror-menu@^1.2.4: prosemirror-history "^1.0.0" prosemirror-state "^1.0.0" -prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.3: +prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.4: version "1.25.4" resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.4.tgz#8ebfbe29ecbee9e5e2e4048c4fe8e363fcd56e7c" integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA== @@ -12150,7 +12163,7 @@ prosemirror-schema-list@^1.5.0: prosemirror-state "^1.0.0" prosemirror-transform "^1.7.3" -prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3: +prosemirror-state@1.4.3, prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3, prosemirror-state@^1.4.4: version "1.4.3" resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz#94aecf3ffd54ec37e87aa7179d13508da181a080" integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q== @@ -12159,7 +12172,7 @@ prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3: prosemirror-transform "^1.0.0" prosemirror-view "^1.27.0" -prosemirror-tables@^1.3.7, prosemirror-tables@^1.6.4: +prosemirror-tables@^1.6.4, prosemirror-tables@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz#896a234e3e18240b629b747a871369dae78c8a9a" integrity sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug== @@ -12185,7 +12198,7 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor dependencies: prosemirror-model "^1.21.0" -prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1, prosemirror-view@^1.41.2: +prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1, prosemirror-view@^1.41.3: version "1.41.3" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.3.tgz#753a37ebe172a3e313ad2c3d85496f9ed1b2c256" integrity sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ== @@ -12511,7 +12524,7 @@ react-i18next@16.2.3: html-parse-stringify "^3.0.1" use-sync-external-store "^1.6.0" -react-icons@^5.2.1: +react-icons@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.5.0.tgz#8aa25d3543ff84231685d3331164c00299cdfaf2" integrity sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw== From e2d3fcf737e9549119ddc8651dbc4f90ea51318d Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 12 Nov 2025 21:03:08 +0100 Subject: [PATCH 12/14] =?UTF-8?q?=F0=9F=90=9B(export)=20fix=20export=20uns?= =?UTF-8?q?upported=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some colors bind to a text style are not supported. If it is the case, we fallback to the default color. --- .../blocks-mapping/paragraphPDF.tsx | 3 ++- .../features/docs/doc-export/mappingDocx.tsx | 21 +++++++++++++++++++ .../features/docs/doc-export/mappingPDF.tsx | 20 ++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx index 89ebb5ea7d..70ee7b137f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx @@ -23,8 +23,9 @@ export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping }); } } + return ( - + {exporter.transformInlineContent(block.content)} ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx index 1d1f607cec..890d874d29 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx @@ -39,5 +39,26 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { shading: { fill: 'DCDCDC' }, } : {}, + // If the color is not defined, we fall back to default colors + backgroundColor: (val, exporter) => { + if (!val) { + return {}; + } + return { + shading: { + fill: + exporter.options.colors?.[val]?.background?.slice(1) || '#ffffff', + }, + }; + }, + // If the color is not defined, we fall back to default colors + textColor: (val, exporter) => { + if (!val) { + return {}; + } + return { + color: exporter.options.colors?.[val]?.text?.slice(1) || '#3c3b38', + }; + }, }, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx index 0d0c7cbd10..b18401034e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx @@ -39,5 +39,25 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = { // that is not available in italics code: (enabled?: boolean) => enabled ? { fontFamily: 'Courier', backgroundColor: '#dcdcdc' } : {}, + // If the color is not defined, we fall back to default colors + textColor: (val, exporter) => { + if (!val) { + return {}; + } + + return { + color: exporter.options.colors?.[val]?.text || '#3c3b38', + }; + }, + // If the color is not defined, we fall back to default colors + backgroundColor: (val, exporter) => { + if (!val) { + return {}; + } + return { + backgroundColor: + exporter.options.colors?.[val]?.background || '#ffffff', + }; + }, }, }; From 91b0416b39842af3cfc8828df013f5425301dda8 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 12 Nov 2025 21:05:38 +0100 Subject: [PATCH 13/14] =?UTF-8?q?fixup!=20fixup!=20=E2=99=BB=EF=B8=8F(fron?= =?UTF-8?q?tend)=20add=20user=20avatar=20to=20thread=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/docs/doc-editor/components/BlockNoteEditor.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 0a148cd852..bef777abbc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -155,10 +155,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const threadStore = useComments(doc.id, canSeeComment, user); const currentUserAvatarUrl = useMemo(() => { - if (!canSeeComment) { - return undefined; + if (canSeeComment) { + return avatarUrlFromName(collabName, themeTokens?.font?.families?.base); } - return avatarUrlFromName(collabName, themeTokens?.font?.families?.base); }, [canSeeComment, collabName, themeTokens?.font?.families?.base]); const editor: DocsBlockNoteEditor = useCreateBlockNote( From ce57c73d11996d03393033ac0c57d6d10fd1fc4f Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 12 Nov 2025 21:14:14 +0100 Subject: [PATCH 14/14] =?UTF-8?q?fixup!=20=E2=AC=86=EF=B8=8F(dependencies)?= =?UTF-8?q?=20upgrade=20Blocknote=20to=20last=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/apps/impress/package.json | 1 - src/frontend/yarn.lock | 5 ----- 2 files changed, 6 deletions(-) diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 228f18ccb8..ed9e6f5994 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -42,7 +42,6 @@ "@react-pdf/renderer": "4.3.1", "@sentry/nextjs": "10.22.0", "@tanstack/react-query": "5.90.6", - "@tiptap/core": "3.10.1", "@tiptap/extensions": "3.10.1", "canvg": "4.0.3", "clsx": "2.1.1", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index fbc7c71a56..4ed04ae824 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -5250,11 +5250,6 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== -"@tiptap/core@3.10.1": - version "3.10.1" - resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.10.1.tgz#89fec750db261ee569f64926b3b9f9a509b81ea3" - integrity sha512-YY/u+RsjLVhcUaIn+wv6vjMx8kldO7SzFFnRu0iuC+QW57VrlqUzqz5PR6CenphwJHuqGM5b3SCr4K2ZPjN8jQ== - "@tiptap/core@^3.10.2": version "3.10.5" resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.10.5.tgz#5695b357498c9fd510b1d77df5d889955178e049"