diff --git a/CHANGELOG.md b/CHANGELOG.md index 6efc1e228e..f1b2ea6d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to ### Added - ✨(frontend) add pdf block to the editor #1293 +- ✨List and restore deleted docs #1450 ### Changed diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index fe94cd5f43..6c09cf18ff 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -90,6 +90,7 @@ class ListDocumentSerializer(serializers.ModelSerializer): nb_accesses_direct = serializers.IntegerField(read_only=True) user_role = serializers.SerializerMethodField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True) + deleted_at = serializers.SerializerMethodField(read_only=True) class Meta: model = models.Document @@ -102,6 +103,7 @@ class Meta: "computed_link_role", "created_at", "creator", + "deleted_at", "depth", "excerpt", "is_favorite", @@ -124,6 +126,7 @@ class Meta: "computed_link_role", "created_at", "creator", + "deleted_at", "depth", "excerpt", "is_favorite", @@ -165,6 +168,10 @@ def get_user_role(self, instance): request = self.context.get("request") return instance.get_role(request.user) if request else None + def get_deleted_at(self, instance): + """Return the deleted_at of the current document.""" + return instance.ancestors_deleted_at + class DocumentLightSerializer(serializers.ModelSerializer): """Minial document serializer for nesting in document accesses.""" @@ -193,6 +200,7 @@ class Meta: "content", "created_at", "creator", + "deleted_at", "depth", "excerpt", "is_favorite", @@ -216,6 +224,7 @@ class Meta: "computed_link_role", "created_at", "creator", + "deleted_at", "depth", "is_favorite", "link_role", diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1fb95c4eb6..2cd45e6233 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -851,33 +851,47 @@ def tree(self, request, pk, *args, **kwargs): try: current_document = ( - self.queryset.select_related(None).only("depth", "path").get(pk=pk) + self.queryset.select_related(None) + .only("depth", "path", "ancestors_deleted_at") + .get(pk=pk) ) except models.Document.DoesNotExist as excpt: raise drf.exceptions.NotFound() from excpt - ancestors = ( - ( - current_document.get_ancestors() - | self.queryset.select_related(None).filter(pk=pk) - ) - .filter(ancestors_deleted_at__isnull=True) - .order_by("path") - ) + is_deleted = current_document.ancestors_deleted_at is not None - # Get the highest readable ancestor - highest_readable = ( - ancestors.select_related(None) - .readable_per_se(request.user) - .only("depth", "path") - .first() - ) - if highest_readable is None: - raise ( - drf.exceptions.PermissionDenied() - if request.user.is_authenticated - else drf.exceptions.NotAuthenticated() + if is_deleted: + if current_document.get_role(user) != models.RoleChoices.OWNER: + raise ( + drf.exceptions.PermissionDenied() + if request.user.is_authenticated + else drf.exceptions.NotAuthenticated() + ) + highest_readable = current_document + ancestors = self.queryset.select_related(None).filter(pk=pk) + else: + ancestors = ( + ( + current_document.get_ancestors() + | self.queryset.select_related(None).filter(pk=pk) + ) + .filter(ancestors_deleted_at__isnull=True) + .order_by("path") ) + # Get the highest readable ancestor + highest_readable = ( + ancestors.select_related(None) + .readable_per_se(request.user) + .only("depth", "path") + .first() + ) + + if highest_readable is None: + raise ( + drf.exceptions.PermissionDenied() + if request.user.is_authenticated + else drf.exceptions.NotAuthenticated() + ) paths_links_mapping = {} ancestors_links = [] children_clause = db.Q() @@ -2157,6 +2171,7 @@ def get(self, request): "LANGUAGES", "LANGUAGE_CODE", "SENTRY_DSN", + "TRASHBIN_CUTOFF_DAYS", ] dict_settings = {} for setting in array_settings: diff --git a/src/backend/core/models.py b/src/backend/core/models.py index d5e8cf9a8f..941c72ec08 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -721,7 +721,7 @@ def get_abilities(self, user): # Characteristics that are based only on specific access is_owner = role == RoleChoices.OWNER - is_deleted = self.ancestors_deleted_at and not is_owner + is_deleted = self.ancestors_deleted_at is_owner_or_admin = (is_owner or role == RoleChoices.ADMIN) and not is_deleted # Compute access roles before adding link roles because we don't @@ -750,6 +750,7 @@ def get_abilities(self, user): role = RoleChoices.max(role, link_definition["link_role"]) can_get = bool(role) and not is_deleted + retrieve = can_get or is_owner can_update = ( is_owner_or_admin or role == RoleChoices.EDITOR ) and not is_deleted @@ -758,7 +759,7 @@ def get_abilities(self, user): is_owner if self.is_root() else (is_owner_or_admin or (user.is_authenticated and self.creator == user)) - ) + ) and not is_deleted ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM ai_access = any( @@ -790,15 +791,15 @@ def get_abilities(self, user): "duplicate": can_get and user.is_authenticated, "favorite": can_get and user.is_authenticated, "link_configuration": is_owner_or_admin, - "invite_owner": is_owner, + "invite_owner": is_owner and not is_deleted, "mask": can_get and user.is_authenticated, - "move": is_owner_or_admin and not self.ancestors_deleted_at, + "move": is_owner_or_admin and not is_deleted, "partial_update": can_update, "restore": is_owner, - "retrieve": can_get, + "retrieve": retrieve, "media_auth": can_get, "link_select_options": link_select_options, - "tree": can_get, + "tree": retrieve, "update": can_update, "versions_destroy": is_owner_or_admin, "versions_list": has_access_role, diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py index 19bcfd1920..e9a5cff311 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_list.py +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -41,6 +41,7 @@ def test_api_documents_children_list_anonymous_public_standalone( "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, "id": str(child1.id), @@ -63,6 +64,7 @@ def test_api_documents_children_list_anonymous_public_standalone( "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, "id": str(child2.id), @@ -115,6 +117,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, "id": str(child1.id), @@ -137,6 +140,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, "id": str(child2.id), @@ -208,6 +212,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, "id": str(child1.id), @@ -230,6 +235,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, "id": str(child2.id), @@ -287,6 +293,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, "id": str(child1.id), @@ -309,6 +316,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, "id": str(child2.id), @@ -393,6 +401,7 @@ def test_api_documents_children_list_authenticated_related_direct( "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, "id": str(child1.id), @@ -415,6 +424,7 @@ def test_api_documents_children_list_authenticated_related_direct( "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, "id": str(child2.id), @@ -475,6 +485,7 @@ def test_api_documents_children_list_authenticated_related_parent( "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, "id": str(child1.id), @@ -497,6 +508,7 @@ def test_api_documents_children_list_authenticated_related_parent( "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, "id": str(child2.id), @@ -609,6 +621,7 @@ def test_api_documents_children_list_authenticated_related_team_members( "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, "id": str(child1.id), @@ -631,6 +644,7 @@ def test_api_documents_children_list_authenticated_related_team_members( "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, "id": str(child2.id), diff --git a/src/backend/core/tests/documents/test_api_documents_descendants.py b/src/backend/core/tests/documents/test_api_documents_descendants.py index bd2785a7f6..f320b0707a 100644 --- a/src/backend/core/tests/documents/test_api_documents_descendants.py +++ b/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -38,6 +38,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, "id": str(child1.id), @@ -62,6 +63,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), + "deleted_at": None, "depth": 3, "excerpt": grand_child.excerpt, "id": str(grand_child.id), @@ -84,6 +86,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, "id": str(child2.id), @@ -135,6 +138,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, "id": str(child1.id), @@ -157,6 +161,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), + "deleted_at": None, "depth": 5, "excerpt": grand_child.excerpt, "id": str(grand_child.id), @@ -179,6 +184,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, "id": str(child2.id), @@ -251,6 +257,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, "id": str(child1.id), @@ -273,6 +280,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), + "deleted_at": None, "depth": 3, "excerpt": grand_child.excerpt, "id": str(grand_child.id), @@ -295,6 +303,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, "id": str(child2.id), @@ -352,6 +361,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, "id": str(child1.id), @@ -374,6 +384,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), + "deleted_at": None, "depth": 5, "excerpt": grand_child.excerpt, "id": str(grand_child.id), @@ -396,6 +407,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, "id": str(child2.id), @@ -474,6 +486,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, "id": str(child1.id), @@ -496,6 +509,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), + "deleted_at": None, "depth": 3, "excerpt": grand_child.excerpt, "id": str(grand_child.id), @@ -518,6 +532,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, "id": str(child2.id), @@ -576,6 +591,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, "id": str(child1.id), @@ -598,6 +614,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), + "deleted_at": None, "depth": 5, "excerpt": grand_child.excerpt, "id": str(grand_child.id), @@ -620,6 +637,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, "id": str(child2.id), @@ -724,6 +742,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, "id": str(child1.id), @@ -746,6 +765,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), + "deleted_at": None, "depth": 3, "excerpt": grand_child.excerpt, "id": str(grand_child.id), @@ -768,6 +788,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), + "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, "id": str(child2.id), diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index 3ac9170aba..f93e95e088 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -65,6 +65,7 @@ def test_api_document_favorite_list_authenticated_with_favorite(): "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), + "deleted_at": None, "content": document.content, "depth": document.depth, "excerpt": document.excerpt, diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py index 1fe2359409..bb422a0ce6 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -69,6 +69,7 @@ def test_api_documents_list_format(): "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), + "deleted_at": None, "depth": 1, "excerpt": document.excerpt, "is_favorite": True, 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 d1f8e1f031..fa8b1e2eb6 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -70,6 +70,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), + "deleted_at": None, "depth": 1, "excerpt": document.excerpt, "is_favorite": False, @@ -144,6 +145,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), + "deleted_at": None, "depth": 3, "excerpt": document.excerpt, "is_favorite": False, @@ -252,6 +254,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 1, + "deleted_at": None, "excerpt": document.excerpt, "is_favorite": False, "link_reach": reach, @@ -333,6 +336,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 3, + "deleted_at": None, "excerpt": document.excerpt, "is_favorite": False, "link_reach": document.link_reach, @@ -446,6 +450,7 @@ def test_api_documents_retrieve_authenticated_related_direct(): "content": document.content, "creator": str(document.creator.id), "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "deleted_at": None, "depth": 1, "excerpt": document.excerpt, "is_favorite": False, @@ -528,6 +533,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "creator": str(document.creator.id), "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "depth": 3, + "deleted_at": None, "excerpt": document.excerpt, "is_favorite": False, "link_reach": "restricted", @@ -683,6 +689,7 @@ def test_api_documents_retrieve_authenticated_related_team_members( "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), + "deleted_at": None, "depth": 1, "excerpt": document.excerpt, "is_favorite": False, @@ -749,6 +756,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators( "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), + "deleted_at": None, "depth": 1, "excerpt": document.excerpt, "is_favorite": False, @@ -815,6 +823,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners( "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), + "deleted_at": None, "depth": 1, "excerpt": document.excerpt, "is_favorite": False, 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 28ea6a01af..fbcc23175c 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -48,11 +48,11 @@ def test_api_documents_trashbin_format(): other_users = factories.UserFactory.create_batch(3) document = factories.DocumentFactory( - deleted_at=timezone.now(), users=factories.UserFactory.create_batch(2), favorited_by=[user, *other_users], link_traces=other_users, ) + document.soft_delete() factories.UserDocumentAccessFactory(document=document, user=user, role="owner") response = client.get("/api/v1.0/documents/trashbin/") @@ -70,40 +70,40 @@ def test_api_documents_trashbin_format(): assert results[0] == { "id": str(document.id), "abilities": { - "accesses_manage": True, - "accesses_view": True, - "ai_transform": True, - "ai_translate": True, - "attachment_upload": True, - "can_edit": True, - "children_create": True, - "children_list": True, - "collaboration_auth": True, - "descendants": True, - "cors_proxy": True, - "content": True, - "destroy": True, - "duplicate": True, - "favorite": True, - "invite_owner": True, - "link_configuration": True, + "accesses_manage": False, + "accesses_view": False, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": False, + "can_edit": False, + "children_create": False, + "children_list": False, + "collaboration_auth": False, + "descendants": False, + "cors_proxy": False, + "content": False, + "destroy": False, + "duplicate": False, + "favorite": False, + "invite_owner": False, + "link_configuration": False, "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], "restricted": None, }, - "mask": True, - "media_auth": True, - "media_check": True, + "mask": False, + "media_auth": False, + "media_check": False, "move": False, # Can't move a deleted document - "partial_update": True, + "partial_update": False, "restore": True, "retrieve": True, "tree": True, - "update": True, - "versions_destroy": True, - "versions_list": True, - "versions_retrieve": True, + "update": False, + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, }, "ancestors_link_reach": None, "ancestors_link_role": None, @@ -113,6 +113,7 @@ def test_api_documents_trashbin_format(): "creator": str(document.creator.id), "depth": 1, "excerpt": document.excerpt, + "deleted_at": document.ancestors_deleted_at.isoformat().replace("+00:00", "Z"), "link_reach": document.link_reach, "link_role": document.link_role, "nb_accesses_ancestors": 0, diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py index 0124b50755..c86eebc169 100644 --- a/src/backend/core/tests/documents/test_api_documents_tree.py +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -50,6 +50,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q ), "creator": str(child.creator.id), "depth": 3, + "deleted_at": None, "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, @@ -73,6 +74,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, + "deleted_at": None, "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, @@ -96,6 +98,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling1.creator.id), "depth": 2, + "deleted_at": None, "excerpt": sibling1.excerpt, "id": str(sibling1.id), "is_favorite": False, @@ -119,6 +122,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling2.creator.id), "depth": 2, + "deleted_at": None, "excerpt": sibling2.excerpt, "id": str(sibling2.id), "is_favorite": False, @@ -138,6 +142,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 1, + "deleted_at": None, "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, @@ -210,6 +215,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): ), "creator": str(child.creator.id), "depth": 5, + "deleted_at": None, "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, @@ -233,6 +239,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): ), "creator": str(document.creator.id), "depth": 4, + "deleted_at": None, "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, @@ -260,6 +267,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): ), "creator": str(document_sibling.creator.id), "depth": 4, + "deleted_at": None, "excerpt": document_sibling.excerpt, "id": str(document_sibling.id), "is_favorite": False, @@ -281,6 +289,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 3, + "deleted_at": None, "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, @@ -306,6 +315,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): ), "creator": str(parent_sibling.creator.id), "depth": 3, + "deleted_at": None, "excerpt": parent_sibling.excerpt, "id": str(parent_sibling.id), "is_favorite": False, @@ -327,6 +337,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_parent.creator.id), "depth": 2, + "deleted_at": None, "excerpt": grand_parent.excerpt, "id": str(grand_parent.id), "is_favorite": False, @@ -406,6 +417,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated ), "creator": str(child.creator.id), "depth": 3, + "deleted_at": None, "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, @@ -427,6 +439,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, + "deleted_at": None, "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, @@ -450,6 +463,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), "depth": 2, + "deleted_at": None, "excerpt": sibling.excerpt, "id": str(sibling.id), "is_favorite": False, @@ -469,6 +483,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 1, + "deleted_at": None, "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, @@ -546,6 +561,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( ), "creator": str(child.creator.id), "depth": 5, + "deleted_at": None, "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, @@ -569,6 +585,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( ), "creator": str(document.creator.id), "depth": 4, + "deleted_at": None, "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, @@ -596,6 +613,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( ), "creator": str(document_sibling.creator.id), "depth": 4, + "deleted_at": None, "excerpt": document_sibling.excerpt, "id": str(document_sibling.id), "is_favorite": False, @@ -617,6 +635,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 3, + "deleted_at": None, "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, @@ -642,6 +661,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( ), "creator": str(parent_sibling.creator.id), "depth": 3, + "deleted_at": None, "excerpt": parent_sibling.excerpt, "id": str(parent_sibling.id), "is_favorite": False, @@ -663,6 +683,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_parent.creator.id), "depth": 2, + "deleted_at": None, "excerpt": grand_parent.excerpt, "id": str(grand_parent.id), "is_favorite": False, @@ -744,6 +765,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): ), "creator": str(child.creator.id), "depth": 3, + "deleted_at": None, "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, @@ -765,6 +787,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, + "deleted_at": None, "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, @@ -788,6 +811,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), "depth": 2, + "deleted_at": None, "excerpt": sibling.excerpt, "id": str(sibling.id), "is_favorite": False, @@ -807,6 +831,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 1, + "deleted_at": None, "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, @@ -888,6 +913,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): ), "creator": str(child.creator.id), "depth": 5, + "deleted_at": None, "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, @@ -911,6 +937,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): ), "creator": str(document.creator.id), "depth": 4, + "deleted_at": None, "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, @@ -938,6 +965,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): ), "creator": str(document_sibling.creator.id), "depth": 4, + "deleted_at": None, "excerpt": document_sibling.excerpt, "id": str(document_sibling.id), "is_favorite": False, @@ -959,6 +987,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 3, + "deleted_at": None, "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, @@ -984,6 +1013,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): ), "creator": str(parent_sibling.creator.id), "depth": 3, + "deleted_at": None, "excerpt": parent_sibling.excerpt, "id": str(parent_sibling.id), "is_favorite": False, @@ -1005,6 +1035,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_parent.creator.id), "depth": 2, + "deleted_at": None, "excerpt": grand_parent.excerpt, "id": str(grand_parent.id), "is_favorite": False, @@ -1094,6 +1125,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( ), "creator": str(child.creator.id), "depth": 3, + "deleted_at": None, "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, @@ -1115,6 +1147,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, + "deleted_at": None, "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, @@ -1138,6 +1171,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), "depth": 2, + "deleted_at": None, "excerpt": sibling.excerpt, "id": str(sibling.id), "is_favorite": False, @@ -1157,6 +1191,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 1, + "deleted_at": None, "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, @@ -1170,3 +1205,56 @@ def test_api_documents_tree_list_authenticated_related_team_members( "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "user_role": access.role, } + + +def test_api_documents_tree_list_deleted_document(): + """ + Tree of a deleted document should only be accessible to the owner. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="public") + document, _ = factories.DocumentFactory.create_batch(2, parent=parent) + factories.DocumentFactory(link_reach="public", parent=document) + + document.soft_delete() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + assert response.status_code == 403 + + +def test_api_documents_tree_list_deleted_document_owner(django_assert_num_queries): + """ + Tree of a deleted document should only be accessible to the owner. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="public", users=[(user, "owner")]) + document, _ = factories.DocumentFactory.create_batch(2, parent=parent) + child = factories.DocumentFactory(parent=document) + + document.soft_delete() + document.refresh_from_db() + child.refresh_from_db() + + with django_assert_num_queries(9): + client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + with django_assert_num_queries(5): + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + content = response.json() + assert content["id"] == str(document.id) + assert content["deleted_at"] == document.deleted_at.isoformat().replace( + "+00:00", "Z" + ) + assert len(content["children"]) == 1 + assert content["children"][0]["id"] == str(child.id) + assert content["children"][0][ + "deleted_at" + ] == child.ancestors_deleted_at.isoformat().replace("+00:00", "Z") diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index cac6bc0770..0261125e89 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -42,6 +42,7 @@ def test_api_config(is_authenticated): response = client.get("/api/v1.0/config/") assert response.status_code == HTTP_200_OK assert response.json() == { + "AI_FEATURE_ENABLED": False, "COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, "CRISP_WEBSITE_ID": "123", @@ -60,7 +61,7 @@ def test_api_config(is_authenticated): "MEDIA_BASE_URL": "http://testserver/", "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, "SENTRY_DSN": "https://sentry.test/123", - "AI_FEATURE_ENABLED": False, + "TRASHBIN_CUTOFF_DAYS": 30, "theme_customization": {}, } policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index cc760aff33..69236b6e96 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -375,8 +375,42 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): document.soft_delete() document.refresh_from_db() - expected_abilities["move"] = False - assert document.get_abilities(user) == expected_abilities + assert document.get_abilities(user) == { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": False, + "can_edit": False, + "children_create": False, + "children_list": False, + "collaboration_auth": False, + "descendants": False, + "cors_proxy": False, + "content": False, + "destroy": False, + "duplicate": False, + "favorite": False, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": None, + }, + "mask": False, + "media_auth": False, + "media_check": False, + "move": False, + "partial_update": False, + "restore": True, + "retrieve": True, + "tree": True, + "update": False, + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + } @override_settings( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts index 0347d804a4..1e12421fdc 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -139,7 +139,7 @@ test.describe('Document grid item options', () => { const row = await getGridRow(page, docTitle); await row.getByText(`more_horiz`).click(); - await page.getByRole('menuitem', { name: 'Remove' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); await expect( page.getByRole('heading', { name: 'Delete a doc' }), diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts new file mode 100644 index 0000000000..ed653bc3a4 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from '@playwright/test'; + +import { + clickInEditorMenu, + clickInGridMenu, + createDoc, + getGridRow, + verifyDocName, +} from './utils-common'; +import { addNewMember } from './utils-share'; +import { + addChild, + createRootSubPage, + navigateToPageFromTree, +} from './utils-sub-pages'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Trashbin', () => { + test('it controls UI and interaction from the grid page', async ({ + page, + browserName, + }) => { + const [title1] = await createDoc(page, 'my-trash-doc-1', browserName, 1); + const [title2] = await createDoc(page, 'my-trash-doc-2', browserName, 1); + await verifyDocName(page, title2); + await page.getByRole('button', { name: 'Share' }).click(); + await addNewMember(page, 0, 'Editor'); + await page.getByRole('button', { name: 'close' }).click(); + + await page.getByRole('button', { name: 'Back to homepage' }).click(); + + const row1 = await getGridRow(page, title1); + await clickInGridMenu(page, row1, 'Delete'); + await page.getByRole('button', { name: 'Delete document' }).click(); + await expect(row1.getByText(title1)).toBeHidden(); + + const row2 = await getGridRow(page, title2); + await clickInGridMenu(page, row2, 'Delete'); + await page.getByRole('button', { name: 'Delete document' }).click(); + await expect(row2.getByText(title2)).toBeHidden(); + + await page.getByRole('link', { name: 'Trashbin' }).click(); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid.getByText('Days remaining')).toBeVisible(); + await expect(row1.getByText(title1)).toBeVisible(); + await expect(row1.getByText('30 days')).toBeVisible(); + await expect(row2.getByText(title2)).toBeVisible(); + await expect( + row2.getByRole('button', { + name: 'Open the sharing settings for the document', + }), + ).toBeVisible(); + await expect( + row2.getByRole('button', { + name: 'Open the sharing settings for the document', + }), + ).toBeDisabled(); + + await clickInGridMenu(page, row2, 'Restore'); + await verifyDocName(page, title2); + await page.getByRole('button', { name: 'Back to homepage' }).click(); + await expect(row2.getByText(title2)).toBeVisible(); + await expect( + row2.getByRole('button', { + name: 'Open the sharing settings for the document', + }), + ).toBeEnabled(); + + await page.getByRole('link', { name: 'Trashbin' }).click(); + await expect(row2.getByText(title2)).toBeHidden(); + }); + + test('it controls UI and interaction from the doc page', async ({ + page, + browserName, + }) => { + const [topParent] = await createDoc( + page, + 'my-trash-editor-doc', + browserName, + 1, + ); + await verifyDocName(page, topParent); + const { name: subDocName } = await createRootSubPage( + page, + browserName, + 'my-trash-editor-subdoc', + ); + + const subsubDocName = await addChild({ + page, + browserName, + docParent: subDocName, + }); + await verifyDocName(page, subsubDocName); + + await navigateToPageFromTree({ page, title: subDocName }); + await verifyDocName(page, subDocName); + + await clickInEditorMenu(page, 'Delete document'); + await page.getByRole('button', { name: 'Delete document' }).click(); + + await page.getByRole('link', { name: 'Trashbin' }).click(); + const row = await getGridRow(page, subDocName); + await row.getByText(subDocName).click(); + await verifyDocName(page, subDocName); + + await expect(page.getByLabel('Alert deleted document')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Share' })).toBeDisabled(); + await expect(page.locator('.bn-editor')).toHaveAttribute( + 'contenteditable', + 'false', + ); + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(topParent)).toBeHidden(); + await expect( + docTree.getByText(subDocName, { + exact: true, + }), + ).toBeVisible(); + await expect(docTree.getByText(subsubDocName)).toBeVisible(); + await expect( + docTree + .locator(".--docs-sub-page-item[aria-disabled='true']") + .getByText(subsubDocName), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Restore' }).click(); + await expect(page.getByLabel('Alert deleted document')).toBeHidden(); + await expect(page.locator('.bn-editor')).toHaveAttribute( + 'contenteditable', + 'true', + ); + await expect(page.getByRole('button', { name: 'Share' })).toBeEnabled(); + await expect(docTree.getByText(topParent)).toBeVisible(); + }); +}); 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 d7387378a5..70e75d1c9a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -1,4 +1,4 @@ -import { Page, expect } from '@playwright/test'; +import { Locator, Page, expect } from '@playwright/test'; export type BrowserName = 'chromium' | 'firefox' | 'webkit'; export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox']; @@ -23,6 +23,7 @@ export const CONFIG = { LANGUAGE_CODE: 'en-us', POSTHOG_KEY: {}, SENTRY_DSN: null, + TRASHBIN_CUTOFF_DAYS: 30, theme_customization: {}, } as const; @@ -325,3 +326,19 @@ export async function waitForLanguageSwitch( await page.getByRole('menuitem', { name: lang.label }).click(); } + +export const clickInEditorMenu = async (page: Page, textButton: string) => { + await page.getByRole('button', { name: 'Open the document options' }).click(); + await page.getByRole('menuitem', { name: textButton }).click(); +}; + +export const clickInGridMenu = async ( + page: Page, + row: Locator, + textButton: string, +) => { + await row + .getByRole('button', { name: /Open the menu of actions for the document/ }) + .click(); + await page.getByRole('menuitem', { name: textButton }).click(); +}; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts index 16bf849dfb..5a0229ffb6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts @@ -1,6 +1,7 @@ import { Page, expect } from '@playwright/test'; import { + BrowserName, randomName, updateDocTitle, verifyDocName, @@ -9,7 +10,7 @@ import { export const createRootSubPage = async ( page: Page, - browserName: string, + browserName: BrowserName, docName: string, isMobile: boolean = false, ) => { @@ -67,6 +68,47 @@ export const clickOnAddRootSubPage = async (page: Page) => { await rootItem.getByTestId('doc-tree-item-actions-add-child').click(); }; +export const addChild = async ({ + page, + browserName, + docParent, +}: { + page: Page; + browserName: BrowserName; + docParent: string; +}) => { + let item = page.getByTestId('doc-tree-root-item'); + + const isParent = await item + .filter({ + hasText: docParent, + }) + .first() + .count(); + + if (!isParent) { + const items = page.getByRole('treeitem'); + + item = items + .filter({ + hasText: docParent, + }) + .first(); + } + + await item.hover(); + await item.getByTestId('doc-tree-item-actions-add-child').click(); + + const [name] = randomName(docParent, browserName, 1); + await updateDocTitle(page, name); + + return name; +}; + +export const navigateToTopParentFromTree = async ({ page }: { page: Page }) => { + await page.getByRole('link', { name: /Open root document/ }).click(); +}; + export const navigateToPageFromTree = async ({ page, title, diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 69dc0045c6..837da970c0 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -31,6 +31,7 @@ "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/material-symbols-outlined": "5.2.25", "@fontsource/material-icons": "5.2.5", "@gouvfr-lasuite/integration": "1.0.3", "@gouvfr-lasuite/ui-kit": "0.16.1", diff --git a/src/frontend/apps/impress/src/components/Box.tsx b/src/frontend/apps/impress/src/components/Box.tsx index 618a1d291d..29c00f7875 100644 --- a/src/frontend/apps/impress/src/components/Box.tsx +++ b/src/frontend/apps/impress/src/components/Box.tsx @@ -36,7 +36,30 @@ export interface BoxProps { $position?: CSSProperties['position']; $radius?: CSSProperties['borderRadius']; $shrink?: CSSProperties['flexShrink']; + $theme?: + | 'primary' + | 'primary-text' + | 'secondary' + | 'secondary-text' + | 'info' + | 'success' + | 'warning' + | 'danger' + | 'greyscale'; $transition?: CSSProperties['transition']; + $variation?: + | 'text' + | '000' + | '100' + | '200' + | '300' + | '400' + | '500' + | '600' + | '700' + | '800' + | '900' + | '1000'; $width?: CSSProperties['width']; $wrap?: CSSProperties['flexWrap']; $zIndex?: CSSProperties['zIndex']; @@ -73,6 +96,8 @@ export const Box = styled('div')` ${({ $position }) => $position && `position: ${$position};`} ${({ $radius }) => $radius && `border-radius: ${$radius};`} ${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`} + ${({ $theme, $variation }) => + `color: var(--c--theme--colors--${$theme}-${$variation});`} ${({ $transition }) => $transition && `transition: ${$transition};`} ${({ $width }) => $width && `width: ${$width};`} ${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`} diff --git a/src/frontend/apps/impress/src/components/BoxButton.tsx b/src/frontend/apps/impress/src/components/BoxButton.tsx index 15d1b34030..087151164f 100644 --- a/src/frontend/apps/impress/src/components/BoxButton.tsx +++ b/src/frontend/apps/impress/src/components/BoxButton.tsx @@ -24,6 +24,9 @@ export type BoxButtonType = BoxType & { */ const BoxButton = forwardRef( ({ $css, ...props }, ref) => { + const theme = props.$theme || 'greyscale'; + const variation = props.$variation || '400'; + return ( ( border: none; outline: none; font-family: inherit; - color: ${props.disabled - ? 'var(--c--theme--colors--greyscale-400) !important' - : 'inherit'}; + ? `var(--c--theme--colors--${theme}-400) !important` + : `inherit`}; + + &:focus-visible { + transition: none; + outline: 2px solid var(--c--theme--colors--${theme}-${variation}); + border-radius: 1px; + outline-offset: 4px; + } ${$css || ''} `} {...props} diff --git a/src/frontend/apps/impress/src/components/Card.tsx b/src/frontend/apps/impress/src/components/Card.tsx index 9e884bf477..4bc35a7f92 100644 --- a/src/frontend/apps/impress/src/components/Card.tsx +++ b/src/frontend/apps/impress/src/components/Card.tsx @@ -7,6 +7,7 @@ import { Box, BoxType } from '.'; export const Card = ({ children, + className, $css, ...props }: PropsWithChildren) => { @@ -14,7 +15,7 @@ export const Card = ({ return ( { const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps; @@ -18,12 +22,15 @@ export const Icon = ({ return ( {iconName} diff --git a/src/frontend/apps/impress/src/components/Overlayer.tsx b/src/frontend/apps/impress/src/components/Overlayer.tsx new file mode 100644 index 0000000000..f732481604 --- /dev/null +++ b/src/frontend/apps/impress/src/components/Overlayer.tsx @@ -0,0 +1,36 @@ +import { PropsWithChildren } from 'react'; +import { css } from 'styled-components'; + +import { Box, BoxType } from '.'; + +type OverlayerProps = PropsWithChildren<{ + isOverlay: boolean; +}> & + Partial; + +export const Overlayer = ({ + children, + className, + $css, + isOverlay, + ...props +}: OverlayerProps) => { + if (!isOverlay) { + return children; + } + + return ( + + {children} + + ); +}; diff --git a/src/frontend/apps/impress/src/components/Text.tsx b/src/frontend/apps/impress/src/components/Text.tsx index 80f9da8385..6b0f761ce8 100644 --- a/src/frontend/apps/impress/src/components/Text.tsx +++ b/src/frontend/apps/impress/src/components/Text.tsx @@ -15,29 +15,6 @@ export interface TextProps extends BoxProps { $textAlign?: CSSProperties['textAlign']; $textTransform?: CSSProperties['textTransform']; $size?: TextSizes | (string & {}); - $theme?: - | 'primary' - | 'primary-text' - | 'secondary' - | 'secondary-text' - | 'info' - | 'success' - | 'warning' - | 'danger' - | 'greyscale'; - $variation?: - | 'text' - | '000' - | '100' - | '200' - | '300' - | '400' - | '500' - | '600' - | '700' - | '800' - | '900' - | '1000'; } export type TextType = ComponentPropsWithRef; @@ -50,8 +27,6 @@ export const TextStyled = styled(Box)` ${({ $size }) => $size && `font-size: ${$size in sizes ? sizes[$size as TextSizes] : $size};`} - ${({ $theme, $variation }) => - `color: var(--c--theme--colors--${$theme}-${$variation});`} ${({ $color }) => $color && `color: ${$color};`} ${({ $ellipsis }) => $ellipsis && diff --git a/src/frontend/apps/impress/src/components/TextErrors.tsx b/src/frontend/apps/impress/src/components/TextErrors.tsx index baf22a2d53..9bfa645377 100644 --- a/src/frontend/apps/impress/src/components/TextErrors.tsx +++ b/src/frontend/apps/impress/src/components/TextErrors.tsx @@ -25,8 +25,6 @@ export const TextErrors = ({ canClose = false, ...textProps }: TextErrorsProps) => { - const { t } = useTranslation(); - return ( - - {causes && - causes.map((cause, i) => ( - - {cause} - - ))} + + + ); +}; + +export const TextOnlyErrors = ({ + causes, + defaultMessage, + ...textProps +}: TextErrorsProps) => { + const { t } = useTranslation(); - {!causes && ( + return ( + + {causes && + causes.map((cause, i) => ( - {defaultMessage || t('Something bad happens, please retry.')} + {cause} - )} - - + ))} + + {!causes && ( + + {defaultMessage || t('Something bad happens, please retry.')} + + )} + ); }; diff --git a/src/frontend/apps/impress/src/components/dropdown-menu/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/dropdown-menu/DropdownMenu.tsx index 4df80cc703..f0af724dd7 100644 --- a/src/frontend/apps/impress/src/components/dropdown-menu/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/dropdown-menu/DropdownMenu.tsx @@ -2,6 +2,7 @@ import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit'; import { Fragment, PropsWithChildren, + ReactNode, useCallback, useEffect, useRef, @@ -15,7 +16,7 @@ import { useCunninghamTheme } from '@/cunningham'; import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav'; export type DropdownMenuOption = { - icon?: string; + icon?: ReactNode; label: string; testId?: string; value?: string; @@ -220,7 +221,7 @@ export const DropdownMenu = ({ $align="center" $gap={spacingsTokens['base']} > - {option.icon && ( + {option.icon && typeof option.icon === 'string' && ( + ); + } + + return ( + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index 7074ad67c5..90a2a8034a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -1,7 +1,7 @@ -import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { Box, HorizontalSeparator, Text } from '@/components'; +import { useConfig } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; import { Doc, @@ -11,10 +11,13 @@ import { useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; +import { useDate } from '@/hook'; import { useResponsiveStore } from '@/stores'; import { AlertNetwork } from './AlertNetwork'; import { AlertPublic } from './AlertPublic'; +import { AlertRestore } from './AlertRestore'; +import { BoutonShare } from './BoutonShare'; import { DocTitle } from './DocTitle'; import { DocToolBox } from './DocToolBox'; @@ -30,6 +33,22 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { const { isEditable } = useIsCollaborativeEditable(doc); const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC; const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED; + const { relativeDate, calculateDaysLeft } = useDate(); + const { data: config } = useConfig(); + const isDeletedDoc = !!doc.deleted_at; + + let dateToDisplay = t('Last update: {{update}}', { + update: relativeDate(doc.updated_at), + }); + + if (config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) { + const daysLeft = calculateDaysLeft( + doc.deleted_at, + config.TRASHBIN_CUTOFF_DAYS, + ); + + dateToDisplay = `${t('Days remaining:')} ${daysLeft} ${t('days', { count: daysLeft })}`; + } return ( <> @@ -40,6 +59,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { aria-label={t('It is the card information about the document.')} className="--docs--doc-header" > + {isDeletedDoc && } {!isEditable && } {(docIsPublic || docIsAuth) && ( @@ -78,20 +98,26 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {  ·  - {t('Last update: {{update}}', { - update: DateTime.fromISO(doc.updated_at).toRelative(), - })} + {dateToDisplay} )} {!isDesktop && ( - {DateTime.fromISO(doc.updated_at).toRelative()} + {dateToDisplay} )} - + {!isDeletedDoc && } + {isDeletedDoc && ( + {}} + displayNbAccess={true} + isDisabled + /> + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 7d5eacfedc..c8a2388320 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -2,7 +2,7 @@ import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { Button, useModal } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -35,6 +35,8 @@ import { useResponsiveStore } from '@/stores'; import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard'; +import { BoutonShare } from './BoutonShare'; + const ModalExport = Export?.ModalExport; interface DocToolBoxProps { @@ -44,19 +46,6 @@ interface DocToolBoxProps { export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); const treeContext = useTreeContext(); - - /** - * Following the change where there is no default owner when adding a sub-page, - * we need to handle both the case where the doc is the root and the case of sub-pages. - */ - const hasAccesses = useMemo(() => { - if (treeContext?.root?.id === doc.id) { - return doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; - } - - return doc.nb_accesses_direct >= 1 && doc.abilities.accesses_view; - }, [doc, treeContext?.root]); - const queryClient = useQueryClient(); const router = useRouter(); @@ -164,7 +153,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }, }, { - label: t('Delete document'), + label: t('Delete'), icon: 'delete', disabled: !doc.abilities.destroy, callback: () => { @@ -190,46 +179,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { $margin={{ left: 'auto' }} $gap={spacingsTokens['2xs']} > - {!isSmallMobile && ( - <> - {!hasAccesses && ( - - )} - {hasAccesses && ( - - - - )} - - )} + {!isSmallMobile && ModalExport && (