diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d27b25d..5747e8317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to ## Added +- ✨(backend) limit link reach/role select options depending on ancestors #645 +- ✨(backend) add new "descendants" action to document API endpoint #645 +- ✨(backend) new "tree" action on document detail endpoint #645 +- ✨(backend) allow forcing page size within limits #645 - 💄(frontend) add error pages #643 ## Changed @@ -22,6 +26,7 @@ and this project adheres to ## Fixed - ♻️(frontend) improve table pdf rendering +- 🐛(backend) refactor to fix filtering on children and descendants views #645 ## [2.2.0] - 2025-02-10 diff --git a/src/backend/core/api/filters.py b/src/backend/core/api/filters.py index 2f3b9a9ed..731deb155 100644 --- a/src/backend/core/api/filters.py +++ b/src/backend/core/api/filters.py @@ -12,15 +12,26 @@ class DocumentFilter(django_filters.FilterSet): Custom filter for filtering documents. """ + title = django_filters.CharFilter( + field_name="title", lookup_expr="icontains", label=_("Title") + ) + + class Meta: + model = models.Document + fields = ["title"] + + +class ListDocumentFilter(DocumentFilter): + """ + Custom filter for filtering documents. + """ + is_creator_me = django_filters.BooleanFilter( method="filter_is_creator_me", label=_("Creator is me") ) is_favorite = django_filters.BooleanFilter( method="filter_is_favorite", label=_("Favorite") ) - title = django_filters.CharFilter( - field_name="title", lookup_expr="icontains", label=_("Title") - ) class Meta: model = models.Document diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index c16ecd4d9..b10c27963 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -128,26 +128,14 @@ class Meta: read_only_fields = ["id", "abilities"] -class BaseResourceSerializer(serializers.ModelSerializer): - """Serialize documents.""" - - abilities = serializers.SerializerMethodField(read_only=True) - accesses = TemplateAccessSerializer(many=True, read_only=True) - - def get_abilities(self, document) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return document.get_abilities(request.user) - return {} - - -class ListDocumentSerializer(BaseResourceSerializer): +class ListDocumentSerializer(serializers.ModelSerializer): """Serialize documents with limited fields for display in lists.""" is_favorite = serializers.BooleanField(read_only=True) - nb_accesses = serializers.IntegerField(read_only=True) + nb_accesses_ancestors = serializers.IntegerField(read_only=True) + nb_accesses_direct = serializers.IntegerField(read_only=True) user_roles = serializers.SerializerMethodField(read_only=True) + abilities = serializers.SerializerMethodField(read_only=True) class Meta: model = models.Document @@ -161,7 +149,8 @@ class Meta: "is_favorite", "link_role", "link_reach", - "nb_accesses", + "nb_accesses_ancestors", + "nb_accesses_direct", "numchild", "path", "title", @@ -178,13 +167,30 @@ class Meta: "is_favorite", "link_role", "link_reach", - "nb_accesses", + "nb_accesses_ancestors", + "nb_accesses_direct", "numchild", "path", "updated_at", "user_roles", ] + def get_abilities(self, document) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + + if request: + paths_links_mapping = self.context.get("paths_links_mapping", None) + # Retrieve ancestor links from paths_links_mapping (if provided) + ancestors_links = ( + paths_links_mapping.get(document.path[: -document.steplen]) + if paths_links_mapping + else None + ) + return document.get_abilities(request.user, ancestors_links=ancestors_links) + + return {} + def get_user_roles(self, document): """ Return roles of the logged-in user for the current document, @@ -214,7 +220,8 @@ class Meta: "is_favorite", "link_role", "link_reach", - "nb_accesses", + "nb_accesses_ancestors", + "nb_accesses_direct", "numchild", "path", "title", @@ -230,7 +237,8 @@ class Meta: "is_favorite", "link_role", "link_reach", - "nb_accesses", + "nb_accesses_ancestors", + "nb_accesses_direct", "numchild", "path", "updated_at", @@ -359,7 +367,7 @@ def update(self, instance, validated_data): raise NotImplementedError("Update is not supported for this serializer.") -class LinkDocumentSerializer(BaseResourceSerializer): +class LinkDocumentSerializer(serializers.ModelSerializer): """ Serialize link configuration for documents. We expose it separately from document in order to simplify and secure access control. @@ -429,9 +437,12 @@ def validate(self, attrs): return attrs -class TemplateSerializer(BaseResourceSerializer): +class TemplateSerializer(serializers.ModelSerializer): """Serialize templates.""" + abilities = serializers.SerializerMethodField(read_only=True) + accesses = TemplateAccessSerializer(many=True, read_only=True) + class Meta: model = models.Template fields = [ @@ -445,6 +456,13 @@ class Meta: ] read_only_fields = ["id", "accesses", "abilities"] + def get_abilities(self, document) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return document.get_abilities(request.user) + return {} + # pylint: disable=abstract-method class DocumentGenerationSerializer(serializers.Serializer): diff --git a/src/backend/core/api/utils.py b/src/backend/core/api/utils.py index 9fd059e17..98dc65482 100644 --- a/src/backend/core/api/utils.py +++ b/src/backend/core/api/utils.py @@ -11,6 +11,35 @@ from rest_framework.throttling import BaseThrottle +def nest_tree(flat_list, steplen): + """ + Convert a flat list of serialized documents into a nested tree making advantage + of the`path` field and its step length. + """ + node_dict = {} + roots = [] + + # Sort the flat list by path to ensure parent nodes are processed first + flat_list.sort(key=lambda x: x["path"]) + + for node in flat_list: + node["children"] = [] # Initialize children list + node_dict[node["path"]] = node + + # Determine parent path + parent_path = node["path"][:-steplen] + + if parent_path in node_dict: + node_dict[parent_path]["children"].append(node) + else: + roots.append(node) # Collect root nodes + + if len(roots) > 1: + raise ValueError("More than one root element detected.") + + return roots[0] if roots else None + + def filter_root_paths(paths, skip_sorting=False): """ Filters root paths from a list of paths representing a tree structure. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index e9616affc..05d42e193 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -20,7 +20,6 @@ import rest_framework as drf from botocore.exceptions import ClientError -from django_filters import rest_framework as drf_filters from rest_framework import filters, status, viewsets from rest_framework import response as drf_response from rest_framework.permissions import AllowAny @@ -30,7 +29,7 @@ from core.services.collaboration_services import CollaborationService from . import permissions, serializers, utils -from .filters import DocumentFilter +from .filters import DocumentFilter, ListDocumentFilter logger = logging.getLogger(__name__) @@ -315,7 +314,6 @@ class DocumentViewSet( SerializerPerActionMixin, drf.mixins.CreateModelMixin, drf.mixins.DestroyModelMixin, - drf.mixins.ListModelMixin, drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, ): @@ -413,20 +411,21 @@ class DocumentViewSet( - Implements soft delete logic to retain document tree structures. """ - filter_backends = [drf_filters.DjangoFilterBackend] - filterset_class = DocumentFilter metadata_class = DocumentMetadata ordering = ["-updated_at"] ordering_fields = ["created_at", "updated_at", "title"] + pagination_class = Pagination permission_classes = [ permissions.DocumentAccessPermission, ] queryset = models.Document.objects.all() serializer_class = serializers.DocumentSerializer + ai_translate_serializer_class = serializers.AITranslateSerializer + children_serializer_class = serializers.ListDocumentSerializer + descendants_serializer_class = serializers.ListDocumentSerializer list_serializer_class = serializers.ListDocumentSerializer trashbin_serializer_class = serializers.ListDocumentSerializer - children_serializer_class = serializers.ListDocumentSerializer - ai_translate_serializer_class = serializers.AITranslateSerializer + tree_serializer_class = serializers.ListDocumentSerializer def annotate_is_favorite(self, queryset): """ @@ -499,8 +498,38 @@ def get_queryset(self): ) def filter_queryset(self, queryset): - """Apply annotations and filters sequentially.""" - filterset = DocumentFilter( + """Override to apply annotations to generic views.""" + queryset = super().filter_queryset(queryset) + queryset = self.annotate_is_favorite(queryset) + queryset = self.annotate_user_roles(queryset) + return queryset + + def get_response_for_queryset(self, queryset): + """Return paginated response for the queryset if requested.""" + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return drf.response.Response(serializer.data) + + def list(self, request, *args, **kwargs): + """ + Returns a DRF response containing the filtered, annotated and ordered document list. + + This method applies filtering based on request parameters using `ListDocumentFilter`. + It performs early filtering on model fields, annotates user roles, and removes + descendant documents to keep only the highest ancestors readable by the current user. + + Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are + applied before ordering and returning the response. + """ + queryset = ( + self.get_queryset() + ) # Not calling filter_queryset. We do our own cooking. + + filterset = ListDocumentFilter( self.request.GET, queryset=queryset, request=self.request ) filterset.is_valid() @@ -512,22 +541,19 @@ def filter_queryset(self, queryset): queryset = self.annotate_user_roles(queryset) - if self.action == "list": - # Among the results, we may have documents that are ancestors/descendants - # of each other. In this case we want to keep only the highest ancestors. - root_paths = utils.filter_root_paths( - queryset.order_by("path").values_list("path", flat=True), - skip_sorting=True, - ) - queryset = queryset.filter(path__in=root_paths) + # Among the results, we may have documents that are ancestors/descendants + # of each other. In this case we want to keep only the highest ancestors. + root_paths = utils.filter_root_paths( + queryset.order_by("path").values_list("path", flat=True), + skip_sorting=True, + ) + queryset = queryset.filter(path__in=root_paths) - # Annotate the queryset with an attribute marking instances as highest ancestor - # in order to save some time while computing abilities in the instance - queryset = queryset.annotate( - is_highest_ancestor_for_user=db.Value( - True, output_field=db.BooleanField() - ) - ) + # Annotate the queryset with an attribute marking instances as highest ancestor + # in order to save some time while computing abilities on the instance + queryset = queryset.annotate( + is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField()) + ) # Annotate favorite status and filter if applicable as late as possible queryset = self.annotate_is_favorite(queryset) @@ -536,18 +562,11 @@ def filter_queryset(self, queryset): ) # Apply ordering only now that everyting is filtered and annotated - return filters.OrderingFilter().filter_queryset(self.request, queryset, self) - - def get_response_for_queryset(self, queryset): - """Return paginated response for the queryset if requested.""" - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - result = self.get_paginated_response(serializer.data) - return result + queryset = filters.OrderingFilter().filter_queryset( + self.request, queryset, self + ) - serializer = self.get_serializer(queryset, many=True) - return drf.response.Response(serializer.data) + return self.get_response_for_queryset(queryset) def retrieve(self, request, *args, **kwargs): """ @@ -600,7 +619,7 @@ def favorite_list(self, request, *args, **kwargs): user=user ).values_list("document_id", flat=True) - queryset = self.get_queryset() + queryset = self.filter_queryset(self.get_queryset()) queryset = queryset.filter(id__in=favorite_documents_ids) return self.get_response_for_queryset(queryset) @@ -727,7 +746,6 @@ def restore(self, request, *args, **kwargs): detail=True, methods=["get", "post"], ordering=["path"], - url_path="children", ) def children(self, request, *args, **kwargs): """Handle listing and creating children of a document""" @@ -759,12 +777,102 @@ def children(self, request, *args, **kwargs): ) # GET: List children - queryset = document.get_children().filter(deleted_at__isnull=True) + queryset = document.get_children().filter(ancestors_deleted_at__isnull=True) queryset = self.filter_queryset(queryset) - queryset = self.annotate_is_favorite(queryset) - queryset = self.annotate_user_roles(queryset) + + filterset = DocumentFilter(request.GET, queryset=queryset) + if filterset.is_valid(): + queryset = filterset.qs + return self.get_response_for_queryset(queryset) + @drf.decorators.action( + detail=True, + methods=["get"], + ordering=["path"], + ) + def descendants(self, request, *args, **kwargs): + """Handle listing descendants of a document""" + document = self.get_object() + + queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True) + queryset = self.filter_queryset(queryset) + + filterset = DocumentFilter(request.GET, queryset=queryset) + if filterset.is_valid(): + queryset = filterset.qs + + return self.get_response_for_queryset(queryset) + + @drf.decorators.action( + detail=True, + methods=["get"], + ordering=["path"], + ) + def tree(self, request, pk, *args, **kwargs): + """ + List ancestors tree above the document. + What we need to display is the tree structure opened for the current document. + """ + try: + current_document = self.queryset.only("depth", "path").get(pk=pk) + except models.Document.DoesNotExist as excpt: + raise drf.exceptions.NotFound from excpt + + ancestors = ( + (current_document.get_ancestors() | self.queryset.filter(pk=pk)) + .filter(ancestors_deleted_at__isnull=True) + .order_by("path") + ) + + # Get the highest readable ancestor + highest_readable = ancestors.readable_per_se(request.user).only("depth").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() + for ancestor in ancestors: + if ancestor.depth < highest_readable.depth: + continue + + children_clause |= db.Q( + path__startswith=ancestor.path, depth=ancestor.depth + 1 + ) + + # Compute cache for ancestors links to avoid many queries while computing + # abilties for his documents in the tree! + ancestors_links.append( + {"link_reach": ancestor.link_reach, "link_role": ancestor.link_role} + ) + paths_links_mapping[ancestor.path] = ancestors_links.copy() + + children = self.queryset.filter(children_clause, deleted_at__isnull=True) + + queryset = ancestors.filter(depth__gte=highest_readable.depth) | children + queryset = queryset.order_by("path") + queryset = self.annotate_user_roles(queryset) + queryset = self.annotate_is_favorite(queryset) + + # Pass ancestors' links definitions to the serializer as a context variable + # in order to allow saving time while computing abilities on the instance + serializer = self.get_serializer( + queryset, + many=True, + context={ + "request": request, + "paths_links_mapping": paths_links_mapping, + }, + ) + return drf.response.Response( + utils.nest_tree(serializer.data, self.queryset.model.steplen) + ) + @drf.decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): """ diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 7ff3dabab..8889fb5e7 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -6,6 +6,7 @@ import hashlib import smtplib import uuid +from collections import defaultdict from datetime import timedelta from logging import getLogger @@ -29,7 +30,7 @@ from botocore.exceptions import ClientError from rest_framework.exceptions import ValidationError from timezone_field import TimeZoneField -from treebeard.mp_tree import MP_Node +from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet logger = getLogger(__name__) @@ -80,6 +81,55 @@ class LinkReachChoices(models.TextChoices): ) # Any authenticated user can access the document PUBLIC = "public", _("Public") # Even anonymous users can access the document + @classmethod + def get_select_options(cls, ancestors_links): + """ + Determines the valid select options for link reach and link role depending on the + list of ancestors' link reach/role. + + Args: + ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys + representing the reach and role of ancestors links. + + Returns: + Dictionary mapping possible reach levels to their corresponding possible roles. + """ + # If no ancestors, return all options + if not ancestors_links: + return {reach: LinkRoleChoices.values for reach in cls.values} + + # Initialize result with all possible reaches and role options as sets + result = {reach: set(LinkRoleChoices.values) for reach in cls.values} + + # Group roles by reach level + reach_roles = defaultdict(set) + for link in ancestors_links: + reach_roles[link["link_reach"]].add(link["link_role"]) + + # Apply constraints based on ancestor links + if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]: + result[cls.RESTRICTED].discard(LinkRoleChoices.READER) + + if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]: + result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) + result.pop(cls.RESTRICTED, None) + elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]: + result[cls.RESTRICTED].discard(LinkRoleChoices.READER) + + if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]: + result[cls.PUBLIC].discard(LinkRoleChoices.READER) + result.pop(cls.AUTHENTICATED, None) + result.pop(cls.RESTRICTED, None) + elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]: + result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) + result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER) + + # Convert roles sets to lists while maintaining the order from LinkRoleChoices + for reach, roles in result.items(): + result[reach] = [role for role in LinkRoleChoices.values if role in roles] + + return result + class DuplicateEmailError(Exception): """Raised when an email is already associated with a pre-existing user.""" @@ -367,6 +417,51 @@ def _get_abilities(self, resource, user): } +class DocumentQuerySet(MP_NodeQuerySet): + """ + Custom queryset for the Document model, providing additional methods + to filter documents based on user permissions. + """ + + def readable_per_se(self, user): + """ + Filters the queryset to return documents that the given user has + permission to read. + :param user: The user for whom readable documents are to be fetched. + :return: A queryset of documents readable by the user. + """ + if user.is_authenticated: + return self.filter( + models.Q(accesses__user=user) + | models.Q(accesses__team__in=user.teams) + | ~models.Q(link_reach=LinkReachChoices.RESTRICTED) + ) + + return self.filter(link_reach=LinkReachChoices.PUBLIC) + + +class DocumentManager(MP_NodeManager): + """ + Custom manager for the Document model, enabling the use of the custom + queryset methods directly from the model manager. + """ + + def get_queryset(self): + """ + Overrides the default get_queryset method to return a custom queryset. + :return: An instance of DocumentQuerySet. + """ + return DocumentQuerySet(self.model, using=self._db) + + def readable_per_se(self, user): + """ + Filters documents based on user permissions using the custom queryset. + :param user: The user for whom readable documents are to be fetched. + :return: A queryset of documents readable by the user. + """ + return self.get_queryset().readable_per_se(user) + + class Document(MP_Node, BaseModel): """Pad document carrying the content.""" @@ -399,6 +494,8 @@ class Document(MP_Node, BaseModel): path = models.CharField(max_length=7 * 36, unique=True, db_collation="C") + objects = DocumentManager() + class Meta: db_table = "impress_document" ordering = ("path",) @@ -555,24 +652,47 @@ def get_nb_accesses_cache_key(self): """Generate a unique cache key for each document.""" return f"document_{self.id!s}_nb_accesses" - @property - def nb_accesses(self): - """Calculate the number of accesses.""" + def get_nb_accesses(self): + """ + Calculate the number of accesses: + - directly attached to the document + - attached to any of the document's ancestors + """ cache_key = self.get_nb_accesses_cache_key() nb_accesses = cache.get(cache_key) if nb_accesses is None: - nb_accesses = DocumentAccess.objects.filter( - document__path=Left(models.Value(self.path), Length("document__path")), - ).count() + nb_accesses = ( + DocumentAccess.objects.filter(document=self).count(), + DocumentAccess.objects.filter( + document__path=Left( + models.Value(self.path), Length("document__path") + ), + document__ancestors_deleted_at__isnull=True, + ).count(), + ) cache.set(cache_key, nb_accesses) return nb_accesses + @property + def nb_accesses_direct(self): + """Returns the number of accesses related to the document or one of its ancestors.""" + return self.get_nb_accesses()[0] + + @property + def nb_accesses_ancestors(self): + """Returns the number of accesses related to the document or one of its ancestors.""" + return self.get_nb_accesses()[1] + def invalidate_nb_accesses_cache(self): """ Invalidate the cache for number of accesses, including on affected descendants. + Args: + path: can optionally be passed as argument (useful when invalidating cache for a + document we just deleted) """ + for document in Document.objects.filter(path__startswith=self.path).only("id"): cache_key = document.get_nb_accesses_cache_key() cache.delete(cache_key) @@ -596,25 +716,27 @@ def get_roles(self, user): roles = [] return roles - @cached_property - def links_definitions(self): + def get_links_definitions(self, ancestors_links): """Get links reach/role definitions for the current document and its ancestors.""" - links_definitions = {self.link_reach: {self.link_role}} - - # Ancestors links definitions are only interesting if the document is not the highest - # ancestor to which the current user has access. Look for the annotation: - if self.depth > 1 and not getattr(self, "is_highest_ancestor_for_user", False): - for ancestor in self.get_ancestors().values("link_reach", "link_role"): - links_definitions.setdefault(ancestor["link_reach"], set()).add( - ancestor["link_role"] - ) - return links_definitions + links_definitions = defaultdict(set) + links_definitions[self.link_reach].add(self.link_role) - def get_abilities(self, user): + # Merge ancestor link definitions + for ancestor in ancestors_links: + links_definitions[ancestor["link_reach"]].add(ancestor["link_role"]) + + return dict(links_definitions) # Convert defaultdict back to a normal dict + + def get_abilities(self, user, ancestors_links=None): """ Compute and return abilities for a given user on the document. """ + if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False): + ancestors_links = [] + elif ancestors_links is None: + ancestors_links = self.get_ancestors().values("link_reach", "link_role") + roles = set( self.get_roles(user) ) # at this point only roles based on specific access @@ -634,9 +756,7 @@ def get_abilities(self, user): ) and not is_deleted # Add roles provided by the document link, taking into account its ancestors - - # Add roles provided by the document link - links_definitions = self.links_definitions + links_definitions = self.get_links_definitions(ancestors_links) public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set()) authenticated_roles = ( links_definitions.get(LinkReachChoices.AUTHENTICATED, set()) @@ -671,6 +791,7 @@ def get_abilities(self, user): "children_list": can_get, "children_create": can_update and user.is_authenticated, "collaboration_auth": can_get, + "descendants": can_get, "destroy": is_owner, "favorite": can_get and user.is_authenticated, "link_configuration": is_owner_or_admin, @@ -680,6 +801,8 @@ def get_abilities(self, user): "restore": is_owner, "retrieve": can_get, "media_auth": can_get, + "link_select_options": LinkReachChoices.get_select_options(ancestors_links), + "tree": can_get, "update": can_update, "versions_destroy": is_owner_or_admin, "versions_list": has_access_role, @@ -750,19 +873,26 @@ def soft_delete(self): Soft delete the document, marking the deletion on descendants. We still keep the .delete() method untouched for programmatic purposes. """ - if self.deleted_at or self.ancestors_deleted_at: - raise RuntimeError( - "This document is already deleted or has deleted ancestors." - ) - - # Check if any ancestors are deleted - if self.get_ancestors().filter(deleted_at__isnull=False).exists(): + if ( + self._meta.model.objects.filter( + models.Q(deleted_at__isnull=False) + | models.Q(ancestors_deleted_at__isnull=False), + pk=self.pk, + ).exists() + or self.get_ancestors().filter(deleted_at__isnull=False).exists() + ): raise RuntimeError( - "Cannot delete this document because one or more ancestors are already deleted." + _("This document is already deleted or has deleted ancestors.") ) self.ancestors_deleted_at = self.deleted_at = timezone.now() self.save() + self.invalidate_nb_accesses_cache() + + if self.depth > 1: + self._meta.model.objects.filter(pk=self.get_parent().pk).update( + numchild=models.F("numchild") - 1 + ) # Mark all descendants as soft deleted self.get_descendants().filter(ancestors_deleted_at__isnull=True).update( @@ -773,18 +903,14 @@ def soft_delete(self): def restore(self): """Cancelling a soft delete with checks.""" # This should not happen - if self.deleted_at is None: - raise ValidationError({"deleted_at": [_("This document is not deleted.")]}) + if self._meta.model.objects.filter( + pk=self.pk, deleted_at__isnull=True + ).exists(): + raise RuntimeError(_("This document is not deleted.")) if self.deleted_at < get_trashbin_cutoff(): - raise ValidationError( - { - "deleted_at": [ - _( - "This document was permanently deleted and cannot be restored." - ) - ] - } + raise RuntimeError( + _("This document was permanently deleted and cannot be restored.") ) # Restore the current document @@ -798,9 +924,15 @@ def restore(self): ) self.ancestors_deleted_at = min(ancestors_deleted_at, default=None) self.save() + self.invalidate_nb_accesses_cache() + + if self.depth > 1: + self._meta.model.objects.filter(pk=self.get_parent().pk).update( + numchild=models.F("numchild") + 1 + ) # Update descendants excluding those who were deleted prior to the deletion of the - # current document (the ancestor_deleted_at date for those should already by good) + # current document (the ancestor_deleted_at date for those should already be good) # The number of deleted descendants should not be too big so we can handcraft a union # clause for them: deleted_descendants_paths = ( diff --git a/src/backend/core/tests/documents/test_api_documents_children_create.py b/src/backend/core/tests/documents/test_api_documents_children_create.py index 6cfb3764b..3a3c3ff9e 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_create.py +++ b/src/backend/core/tests/documents/test_api_documents_children_create.py @@ -1,5 +1,5 @@ """ -Tests for Documents API endpoint in impress's core app: create +Tests for Documents API endpoint in impress's core app: children create """ from uuid import uuid4 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 846fc9dc5..96e1d9b43 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 @@ -1,5 +1,5 @@ """ -Tests for Documents API endpoint in impress's core app: retrieve +Tests for Documents API endpoint in impress's core app: children list """ import random @@ -15,7 +15,7 @@ def test_api_documents_children_list_anonymous_public_standalone(): - """Anonymous users should be allowed to retrieve the children of a public documents.""" + """Anonymous users should be allowed to retrieve the children of a public document.""" document = factories.DocumentFactory(link_reach="public") child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) @@ -39,7 +39,8 @@ def test_api_documents_children_list_anonymous_public_standalone(): "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, - "nb_accesses": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), @@ -56,7 +57,8 @@ def test_api_documents_children_list_anonymous_public_standalone(): "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, - "nb_accesses": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), @@ -100,7 +102,8 @@ def test_api_documents_children_list_anonymous_public_parent(): "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, - "nb_accesses": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), @@ -117,7 +120,8 @@ def test_api_documents_children_list_anonymous_public_parent(): "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, - "nb_accesses": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), @@ -179,7 +183,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, - "nb_accesses": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), @@ -196,7 +201,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, - "nb_accesses": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), @@ -244,7 +250,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, - "nb_accesses": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), @@ -261,7 +268,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, - "nb_accesses": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), @@ -331,7 +339,8 @@ def test_api_documents_children_list_authenticated_related_direct(): "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, - "nb_accesses": 3, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 1, "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), @@ -348,7 +357,8 @@ def test_api_documents_children_list_authenticated_related_direct(): "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, - "nb_accesses": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), @@ -399,7 +409,8 @@ def test_api_documents_children_list_authenticated_related_parent(): "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, - "nb_accesses": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 1, "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), @@ -416,7 +427,8 @@ def test_api_documents_children_list_authenticated_related_parent(): "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, - "nb_accesses": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), @@ -514,7 +526,8 @@ def test_api_documents_children_list_authenticated_related_team_members( "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, - "nb_accesses": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), @@ -531,7 +544,8 @@ def test_api_documents_children_list_authenticated_related_team_members( "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, - "nb_accesses": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), diff --git a/src/backend/core/tests/documents/test_api_documents_descendants.py b/src/backend/core/tests/documents/test_api_documents_descendants.py new file mode 100644 index 000000000..302af2318 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -0,0 +1,696 @@ +""" +Tests for Documents API endpoint in impress's core app: descendants +""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_descendants_list_anonymous_public_standalone(): + """Anonymous users should be allowed to retrieve the descendants of a public document.""" + document = factories.DocumentFactory(link_reach="public") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(AnonymousUser()), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +def test_api_documents_descendants_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the descendants of a document who + has a public ancestor. + """ + grand_parent = factories.DocumentFactory(link_reach="public") + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(AnonymousUser()), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(reach): + """ + Anonymous users should not be able to retrieve descendants of a document that is not public. + """ + document = factories.DocumentFactory(link_reach=reach) + child = factories.DocumentFactory(parent=document) + _grand_child = factories.DocumentFactory(parent=child) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_descendants_list_authenticated_unrelated_public_or_authenticated( + reach, +): + """ + Authenticated users should be able to retrieve the descendants of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_descendants_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the descendants of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach=reach) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the descendants of a document that is + restricted and to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + _grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the descendants of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = factories.UserDocumentAccessFactory(document=document, user=user) + factories.UserDocumentAccessFactory(document=document) + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + grand_child = factories.DocumentFactory(parent=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the descendants of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach="restricted") + grand_parent_access = factories.UserDocumentAccessFactory( + document=grand_parent, user=user + ) + + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + grand_child = factories.DocumentFactory(parent=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_related_child(): + """ + Authenticated users should not be allowed to retrieve all the descendants of a document + as a result of being related to one of its children. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + _grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1, user=user) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_team_none( + mock_user_teams, +): + """ + Authenticated users should not be able to retrieve the descendants of a restricted document + related to teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + factories.DocumentFactory.create_batch(2, parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the descendants of a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = ["myteam"] + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + access = factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + } diff --git a/src/backend/core/tests/documents/test_api_documents_descendants_filters.py b/src/backend/core/tests/documents/test_api_documents_descendants_filters.py new file mode 100644 index 000000000..dec34895d --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_descendants_filters.py @@ -0,0 +1,88 @@ +""" +Tests for Documents API endpoint in impress's core app: list +""" + +import pytest +from faker import Faker +from rest_framework.test import APIClient + +from core import factories + +fake = Faker() +pytestmark = pytest.mark.django_db + + +# Filters: unknown field + + +def test_api_documents_descendants_filter_unknown_field(): + """ + Trying to filter by an unknown field should be ignored. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory() + + document = factories.DocumentFactory(users=[user]) + expected_ids = { + str(document.id) + for document in factories.DocumentFactory.create_batch(2, parent=document) + } + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true" + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + assert {result["id"] for result in results} == expected_ids + + +# Filters: title + + +@pytest.mark.parametrize( + "query,nb_results", + [ + ("Project Alpha", 1), # Exact match + ("project", 2), # Partial match (case-insensitive) + ("Guide", 1), # Word match within a title + ("Special", 0), # No match (nonexistent keyword) + ("2024", 2), # Match by numeric keyword + ("", 5), # Empty string + ], +) +def test_api_documents_descendants_filter_title(query, nb_results): + """Authenticated users should be able to search documents by their title.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[user]) + + # Create documents with predefined titles + titles = [ + "Project Alpha Documentation", + "Project Beta Overview", + "User Guide", + "Financial Report 2024", + "Annual Review 2024", + ] + for title in titles: + factories.DocumentFactory(title=title, parent=document) + + # Perform the search query + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}" + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == nb_results + + # Ensure all results contain the query in their title + for result in results: + assert query.lower().strip() in result["title"].lower() 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 f09ca58ce..1120123e1 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -70,7 +70,8 @@ def test_api_documents_list_format(): "is_favorite": True, "link_reach": document.link_reach, "link_role": document.link_role, - "nb_accesses": 3, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 3, "numchild": 0, "path": document.path, "title": document.title, @@ -147,7 +148,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries): str(child4_with_access.id), } - with django_assert_num_queries(8): + with django_assert_num_queries(12): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached @@ -185,7 +186,7 @@ def test_api_documents_list_authenticated_via_team( expected_ids = {str(document.id) for document in documents_team1 + documents_team2} - with django_assert_num_queries(9): + with django_assert_num_queries(14): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached @@ -218,7 +219,7 @@ def test_api_documents_list_authenticated_link_reach_restricted( other_document = factories.DocumentFactory(link_reach="public") models.LinkTrace.objects.create(document=other_document, user=user) - with django_assert_num_queries(5): + with django_assert_num_queries(6): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached @@ -267,7 +268,7 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated( expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)} - with django_assert_num_queries(7): + with django_assert_num_queries(10): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached @@ -328,6 +329,35 @@ def test_api_documents_list_pagination( assert document_ids == [] +def test_api_documents_list_pagination_force_page_size(): + """Page size can be set via querystring.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document_ids = [ + str(access.document_id) + for access in factories.UserDocumentAccessFactory.create_batch(3, user=user) + ] + + # Force page size + response = client.get( + "/api/v1.0/documents/?page_size=2", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/documents/?page=2&page_size=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + document_ids.remove(item["id"]) + + def test_api_documents_list_authenticated_distinct(): """A document with several related users should only be listed once.""" user = factories.UserFactory() @@ -362,7 +392,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries factories.DocumentFactory.create_batch(2, users=[user]) url = "/api/v1.0/documents/" - with django_assert_num_queries(9): + with django_assert_num_queries(14): response = client.get(url) # nb_accesses should now be cached 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 cc7ebfe5c..8b587f068 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -34,16 +34,23 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "children_create": False, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, # Anonymous user can't favorite a document even with read access "favorite": False, "invite_owner": False, "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": False, "partial_update": document.link_role == "editor", "restore": False, "retrieve": True, + "tree": True, "update": document.link_role == "editor", "versions_destroy": False, "versions_list": False, @@ -57,7 +64,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "is_favorite": False, "link_reach": "public", "link_role": document.link_role, - "nb_accesses": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, "numchild": 0, "path": document.path, "title": document.title, @@ -79,6 +87,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/") assert response.status_code == 200 + links = document.get_ancestors().values("link_reach", "link_role") assert response.json() == { "id": str(document.id), "abilities": { @@ -90,16 +99,19 @@ def test_api_documents_retrieve_anonymous_public_parent(): "children_create": False, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, # Anonymous user can't favorite a document even with read access "favorite": False, "invite_owner": False, "link_configuration": False, + "link_select_options": models.LinkReachChoices.get_select_options(links), "media_auth": True, "move": False, "partial_update": grand_parent.link_role == "editor", "restore": False, "retrieve": True, + "tree": True, "update": grand_parent.link_role == "editor", "versions_destroy": False, "versions_list": False, @@ -113,7 +125,8 @@ def test_api_documents_retrieve_anonymous_public_parent(): "is_favorite": False, "link_reach": document.link_reach, "link_role": document.link_role, - "nb_accesses": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, "numchild": 0, "path": document.path, "title": document.title, @@ -180,15 +193,22 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "children_create": document.link_role == "editor", "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": False, "partial_update": document.link_role == "editor", "restore": False, "retrieve": True, + "tree": True, "update": document.link_role == "editor", "versions_destroy": False, "versions_list": False, @@ -202,7 +222,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "is_favorite": False, "link_reach": reach, "link_role": document.link_role, - "nb_accesses": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, "numchild": 0, "path": document.path, "title": document.title, @@ -232,6 +253,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea response = client.get(f"/api/v1.0/documents/{document.id!s}/") assert response.status_code == 200 + links = document.get_ancestors().values("link_reach", "link_role") assert response.json() == { "id": str(document.id), "abilities": { @@ -243,15 +265,18 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "children_create": grand_parent.link_role == "editor", "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, "link_configuration": False, + "link_select_options": models.LinkReachChoices.get_select_options(links), "move": False, "media_auth": True, "partial_update": grand_parent.link_role == "editor", "restore": False, "retrieve": True, + "tree": True, "update": grand_parent.link_role == "editor", "versions_destroy": False, "versions_list": False, @@ -265,7 +290,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "is_favorite": False, "link_reach": document.link_reach, "link_role": document.link_role, - "nb_accesses": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, "numchild": 0, "path": document.path, "title": document.title, @@ -374,7 +400,8 @@ def test_api_documents_retrieve_authenticated_related_direct(): "is_favorite": False, "link_reach": document.link_reach, "link_role": document.link_role, - "nb_accesses": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, "numchild": 0, "path": document.path, "title": document.title, @@ -404,6 +431,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): f"/api/v1.0/documents/{document.id!s}/", ) assert response.status_code == 200 + links = document.get_ancestors().values("link_reach", "link_role") assert response.json() == { "id": str(document.id), "abilities": { @@ -415,15 +443,18 @@ def test_api_documents_retrieve_authenticated_related_parent(): "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": access.role == "owner", "favorite": True, "invite_owner": access.role == "owner", "link_configuration": access.role in ["administrator", "owner"], + "link_select_options": models.LinkReachChoices.get_select_options(links), "media_auth": True, "move": access.role in ["administrator", "owner"], "partial_update": access.role != "reader", "restore": access.role == "owner", "retrieve": True, + "tree": True, "update": access.role != "reader", "versions_destroy": access.role in ["administrator", "owner"], "versions_list": True, @@ -437,7 +468,8 @@ def test_api_documents_retrieve_authenticated_related_parent(): "is_favorite": False, "link_reach": "restricted", "link_role": document.link_role, - "nb_accesses": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, "numchild": 0, "path": document.path, "title": document.title, @@ -465,7 +497,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses(): f"/api/v1.0/documents/{document.id!s}/", ) assert response.status_code == 200 - assert response.json()["nb_accesses"] == 3 + assert response.json()["nb_accesses_ancestors"] == 3 + assert response.json()["nb_accesses_direct"] == 1 factories.UserDocumentAccessFactory(document=grand_parent) @@ -473,7 +506,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses(): f"/api/v1.0/documents/{document.id!s}/", ) assert response.status_code == 200 - assert response.json()["nb_accesses"] == 4 + assert response.json()["nb_accesses_ancestors"] == 4 + assert response.json()["nb_accesses_direct"] == 1 def test_api_documents_retrieve_authenticated_related_child(): @@ -554,12 +588,10 @@ def test_api_documents_retrieve_authenticated_related_team_members( mock_user_teams.return_value = teams user = factories.UserFactory() - client = APIClient() client.force_login(user) document = factories.DocumentFactory(link_reach="restricted") - factories.TeamDocumentAccessFactory( document=document, team="readers", role="reader" ) @@ -588,7 +620,8 @@ def test_api_documents_retrieve_authenticated_related_team_members( "is_favorite": False, "link_reach": "restricted", "link_role": document.link_role, - "nb_accesses": 5, + "nb_accesses_ancestors": 5, + "nb_accesses_direct": 5, "numchild": 0, "path": document.path, "title": document.title, @@ -649,7 +682,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators( "is_favorite": False, "link_reach": "restricted", "link_role": document.link_role, - "nb_accesses": 5, + "nb_accesses_ancestors": 5, + "nb_accesses_direct": 5, "numchild": 0, "path": document.path, "title": document.title, @@ -710,7 +744,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners( "is_favorite": False, "link_reach": "restricted", "link_role": document.link_role, - "nb_accesses": 5, + "nb_accesses_ancestors": 5, + "nb_accesses_direct": 5, "numchild": 0, "path": document.path, "title": document.title, @@ -719,7 +754,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners( } -def test_api_documents_retrieve_user_roles(django_assert_num_queries): +def test_api_documents_retrieve_user_roles(django_assert_max_num_queries): """ Roles should be annotated on querysets taking into account all documents ancestors. """ @@ -744,7 +779,7 @@ def test_api_documents_retrieve_user_roles(django_assert_num_queries): ) expected_roles = {access.role for access in accesses} - with django_assert_num_queries(10): + with django_assert_max_num_queries(12): response = client.get(f"/api/v1.0/documents/{document.id!s}/") assert response.status_code == 200 @@ -761,7 +796,7 @@ def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_que document = factories.DocumentFactory(users=[user], link_traces=[user]) - with django_assert_num_queries(4): + with django_assert_num_queries(5): response = client.get(f"/api/v1.0/documents/{document.id!s}/") with django_assert_num_queries(3): 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 b1a8b3b56..6e78f17b7 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -78,15 +78,22 @@ def test_api_documents_trashbin_format(): "children_create": True, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": True, "favorite": True, "invite_owner": True, "link_configuration": True, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": False, # Can't move a deleted document "partial_update": True, "restore": True, "retrieve": True, + "tree": True, "update": True, "versions_destroy": True, "versions_list": True, @@ -98,7 +105,8 @@ def test_api_documents_trashbin_format(): "excerpt": document.excerpt, "link_reach": document.link_reach, "link_role": document.link_role, - "nb_accesses": 3, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 3, "numchild": 0, "path": document.path, "title": document.title, @@ -147,7 +155,7 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries): expected_ids = {str(document1.id), str(document2.id), str(document3.id)} - with django_assert_num_queries(7): + with django_assert_num_queries(10): response = client.get("/api/v1.0/documents/trashbin/") with django_assert_num_queries(4): @@ -189,7 +197,7 @@ def test_api_documents_trashbin_authenticated_via_team( expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)} - with django_assert_num_queries(5): + with django_assert_num_queries(7): response = client.get("/api/v1.0/documents/trashbin/") with django_assert_num_queries(3): diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py new file mode 100644 index 000000000..33fa614b8 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -0,0 +1,1031 @@ +""" +Tests for Documents API endpoint in impress's core app: retrieve +""" +# pylint: disable=too-many-lines + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_queries): + """Anonymous users should be allowed to retrieve the tree of a public document.""" + parent = factories.DocumentFactory(link_reach="public") + document, sibling1, sibling2 = factories.DocumentFactory.create_batch( + 3, parent=parent + ) + child = factories.DocumentFactory(link_reach="public", parent=document) + + with django_assert_num_queries(14): + APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + with django_assert_num_queries(4): + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": document.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": child.get_abilities(AnonymousUser()), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling1.get_abilities(AnonymousUser()), + "children": [], + "created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling1.creator.id), + "depth": 2, + "excerpt": sibling1.excerpt, + "id": str(sibling1.id), + "is_favorite": False, + "link_reach": sibling1.link_reach, + "link_role": sibling1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling1.path, + "title": sibling1.title, + "updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling2.get_abilities(AnonymousUser()), + "children": [], + "created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling2.creator.id), + "depth": 2, + "excerpt": sibling2.excerpt, + "id": str(sibling2.id), + "is_favorite": False, + "link_reach": sibling2.link_reach, + "link_role": sibling2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling2.path, + "title": sibling2.title, + "updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 3, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +def test_api_documents_tree_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the tree of a document who + has a public ancestor but only up to the highest public ancestor. + """ + great_grand_parent = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]) + ) + grand_parent = factories.DocumentFactory( + link_reach="public", parent=great_grand_parent + ) + factories.DocumentFactory(link_reach="public", parent=great_grand_parent) + factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), + parent=great_grand_parent, + ) + + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + parent_sibling = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + document_sibling = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": document.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": child.get_abilities(AnonymousUser()), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + { + "abilities": document_sibling.get_abilities(AnonymousUser()), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": parent_sibling.get_abilities(AnonymousUser()), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_tree_list_anonymous_restricted_or_authenticated(reach): + """ + Anonymous users should not be able to retrieve the tree of a document that is not public. + """ + parent = factories.DocumentFactory(link_reach=reach) + document = factories.DocumentFactory(parent=parent, link_reach=reach) + factories.DocumentFactory(parent=parent) + factories.DocumentFactory(link_reach="public", parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated( + reach, django_assert_num_queries +): + """ + Authenticated users should be able to retrieve the tree of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach=reach) + document, sibling = factories.DocumentFactory.create_batch(2, parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + with django_assert_num_queries(13): + 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 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the tree of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + great_grand_parent = factories.DocumentFactory(link_reach="restricted") + grand_parent = factories.DocumentFactory( + link_reach=reach, parent=great_grand_parent + ) + factories.DocumentFactory( + link_reach=random.choice(["public", "authenticated"]), parent=great_grand_parent + ) + factories.DocumentFactory( + link_reach="restricted", + parent=great_grand_parent, + ) + + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + parent_sibling = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + document_sibling = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory( + link_reach=random.choice(["public", "authenticated"]), parent=document + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(user), + "children": [ + { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + { + "abilities": document_sibling.get_abilities(user), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": parent_sibling.get_abilities(user), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +def test_api_documents_tree_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the tree of a document that is + restricted and to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, _sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + factories.DocumentFactory(link_reach="public", parent=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/tree/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_tree_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the tree of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + access = factories.UserDocumentAccessFactory(document=parent, user=user) + factories.UserDocumentAccessFactory(document=parent) + + document, sibling = factories.DocumentFactory.create_batch(2, parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/tree/", + ) + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +def test_api_documents_tree_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the tree of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + great_grand_parent = factories.DocumentFactory( + link_reach="restricted", link_role="reader" + ) + grand_parent = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=great_grand_parent + ) + access = factories.UserDocumentAccessFactory(document=grand_parent, user=user) + factories.UserDocumentAccessFactory(document=grand_parent) + factories.DocumentFactory(link_reach="restricted", parent=great_grand_parent) + factories.DocumentFactory(link_reach="public", parent=great_grand_parent) + + parent = factories.DocumentFactory( + parent=grand_parent, link_reach="restricted", link_role="reader" + ) + parent_sibling = factories.DocumentFactory( + parent=grand_parent, link_reach="restricted", link_role="reader" + ) + document = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=parent + ) + document_sibling = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=parent + ) + child = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=document + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(user), + "children": [ + { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + { + "abilities": document_sibling.get_abilities(user), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": parent_sibling.get_abilities(user), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +def test_api_documents_tree_list_authenticated_related_team_none(mock_user_teams): + """ + Authenticated users should not be able to retrieve the tree of a restricted document + related to teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, _sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + factories.DocumentFactory(link_reach="public", parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_tree_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the tree of a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = ["myteam"] + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + child = factories.DocumentFactory(link_reach="public", parent=document) + + access = factories.TeamDocumentAccessFactory(document=parent, team="myteam") + factories.TeamDocumentAccessFactory(document=parent, team="another-team") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 8eaba224f..fefa0ae1d 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -275,7 +275,8 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( "depth", "link_reach", "link_role", - "nb_accesses", + "nb_accesses_ancestors", + "nb_accesses_direct", "numchild", "path", ]: diff --git a/src/backend/core/tests/test_api_utils_nest_tree.py b/src/backend/core/tests/test_api_utils_nest_tree.py new file mode 100644 index 000000000..11d2d2f03 --- /dev/null +++ b/src/backend/core/tests/test_api_utils_nest_tree.py @@ -0,0 +1,107 @@ +"""Unit tests for the nest_tree utility function.""" + +import pytest + +from core.api.utils import nest_tree + + +def test_api_utils_nest_tree_empty_list(): + """Test that an empty list returns an empty nested structure.""" + # pylint: disable=use-implicit-booleaness-not-comparison + assert nest_tree([], 4) is None + + +def test_api_utils_nest_tree_single_document(): + """Test that a single document is returned as the only root element.""" + documents = [{"id": "1", "path": "0001"}] + expected = {"id": "1", "path": "0001", "children": []} + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_multiple_root_documents(): + """Test that multiple root-level documents are correctly added to the root.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "0002"}, + ] + with pytest.raises( + ValueError, + match="More than one root element detected.", + ): + nest_tree(documents, 4) + + +def test_api_utils_nest_tree_nested_structure(): + """Test that documents are correctly nested based on path levels.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "00010001"}, + {"id": "3", "path": "000100010001"}, + {"id": "4", "path": "00010002"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + { + "id": "2", + "path": "00010001", + "children": [{"id": "3", "path": "000100010001", "children": []}], + }, + {"id": "4", "path": "00010002", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_siblings_at_same_path(): + """ + Test that sibling documents with the same path are correctly grouped under the same parent. + """ + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "00010001"}, + {"id": "3", "path": "00010002"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + {"id": "2", "path": "00010001", "children": []}, + {"id": "3", "path": "00010002", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_decreasing_path_resets_parent(): + """Test that a document at a lower path resets the parent assignment correctly.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "6", "path": "00010001"}, + {"id": "2", "path": "00010002"}, # unordered + {"id": "5", "path": "000100010001"}, + {"id": "3", "path": "000100010002"}, + {"id": "4", "path": "00010003"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + { + "id": "6", + "path": "00010001", + "children": [ + {"id": "5", "path": "000100010001", "children": []}, + {"id": "3", "path": "000100010002", "children": []}, + ], + }, + { + "id": "2", + "path": "00010002", + "children": [], + }, + {"id": "4", "path": "00010003", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index ca8852566..cbcae5342 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -1,6 +1,7 @@ """ Unit tests for the Document model """ +# pylint: disable=too-many-lines import random import smtplib @@ -157,15 +158,22 @@ def test_models_documents_get_abilities_forbidden( "children_create": False, "children_list": False, "collaboration_auth": False, + "descendants": False, "destroy": False, "favorite": False, "invite_owner": False, "media_auth": False, "move": False, "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "partial_update": False, "restore": False, "retrieve": False, + "tree": False, "update": False, "versions_destroy": False, "versions_list": False, @@ -208,15 +216,22 @@ def test_models_documents_get_abilities_reader( "children_create": False, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": is_authenticated, "invite_owner": False, "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": False, "partial_update": False, "restore": False, "retrieve": True, + "tree": True, "update": False, "versions_destroy": False, "versions_list": False, @@ -225,9 +240,14 @@ def test_models_documents_get_abilities_reader( 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 value in document.get_abilities(user).values()) + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) @pytest.mark.parametrize( @@ -256,15 +276,22 @@ def test_models_documents_get_abilities_editor( "children_create": is_authenticated, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": is_authenticated, "invite_owner": False, "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": False, "partial_update": True, "restore": False, "retrieve": True, + "tree": True, "update": True, "versions_destroy": False, "versions_list": False, @@ -275,7 +302,11 @@ def test_models_documents_get_abilities_editor( assert document.get_abilities(user) == expected_abilities document.soft_delete() document.refresh_from_db() - assert all(value is False for value in document.get_abilities(user).values()) + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) @override_settings( @@ -294,15 +325,22 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "children_create": True, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": True, "favorite": True, "invite_owner": True, "link_configuration": True, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": True, "partial_update": True, "restore": True, "retrieve": True, + "tree": True, "update": True, "versions_destroy": True, "versions_list": True, @@ -333,15 +371,22 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "children_create": True, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, "link_configuration": True, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": True, "partial_update": True, "restore": False, "retrieve": True, + "tree": True, "update": True, "versions_destroy": True, "versions_list": True, @@ -352,7 +397,11 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) document.soft_delete() document.refresh_from_db() - assert all(value is False for value in document.get_abilities(user).values()) + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) @override_settings( @@ -371,15 +420,22 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "children_create": True, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": False, "partial_update": True, "restore": False, "retrieve": True, + "tree": True, "update": True, "versions_destroy": False, "versions_list": True, @@ -390,7 +446,11 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): document.soft_delete() document.refresh_from_db() - assert all(value is False for value in document.get_abilities(user).values()) + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) @pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"]) @@ -416,15 +476,22 @@ def test_models_documents_get_abilities_reader_user( "children_create": access_from_link, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": False, "partial_update": access_from_link, "restore": False, "retrieve": True, + "tree": True, "update": access_from_link, "versions_destroy": False, "versions_list": True, @@ -437,7 +504,11 @@ def test_models_documents_get_abilities_reader_user( document.soft_delete() document.refresh_from_db() - assert all(value is False for value in document.get_abilities(user).values()) + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key != "link_select_options" + ) def test_models_documents_get_abilities_preset_role(django_assert_num_queries): @@ -459,15 +530,22 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "children_create": False, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, "media_auth": True, "move": False, "partial_update": False, "restore": False, "retrieve": True, + "tree": True, "update": False, "versions_destroy": False, "versions_list": True, @@ -711,40 +789,89 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail # Document number of accesses -def test_models_documents_nb_accesses_cache_is_set_and_retrieved( +def test_models_documents_nb_accesses_cache_is_set_and_retrieved_ancestors( django_assert_num_queries, ): - """Test that nb_accesses is cached after the first computation.""" - document = factories.DocumentFactory() + """Test that nb_accesses is cached when calling nb_accesses_ancestors.""" + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) key = f"document_{document.id!s}_nb_accesses" - nb_accesses = random.randint(1, 4) - factories.UserDocumentAccessFactory.create_batch(nb_accesses, document=document) + nb_accesses_parent = random.randint(1, 4) + factories.UserDocumentAccessFactory.create_batch( + nb_accesses_parent, document=parent + ) + nb_accesses_direct = random.randint(1, 4) + factories.UserDocumentAccessFactory.create_batch( + nb_accesses_direct, document=document + ) factories.UserDocumentAccessFactory() # An unrelated access should not be counted # Initially, the nb_accesses should not be cached assert cache.get(key) is None # Compute the nb_accesses for the first time (this should set the cache) - with django_assert_num_queries(1): - assert document.nb_accesses == nb_accesses + nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct + with django_assert_num_queries(2): + assert document.nb_accesses_ancestors == nb_accesses_ancestors # Ensure that the nb_accesses is now cached with django_assert_num_queries(0): - assert document.nb_accesses == nb_accesses - assert cache.get(key) == nb_accesses + assert document.nb_accesses_ancestors == nb_accesses_ancestors + assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors) # The cache value should be invalidated when a document access is created models.DocumentAccess.objects.create( document=document, user=factories.UserFactory(), role="reader" ) assert cache.get(key) is None # Cache should be invalidated - with django_assert_num_queries(1): - new_nb_accesses = document.nb_accesses - assert new_nb_accesses == nb_accesses + 1 - assert cache.get(key) == new_nb_accesses # Cache should now contain the new value + with django_assert_num_queries(2): + assert document.nb_accesses_ancestors == nb_accesses_ancestors + 1 + assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1) +def test_models_documents_nb_accesses_cache_is_set_and_retrieved_direct( + django_assert_num_queries, +): + """Test that nb_accesses is cached when calling nb_accesses_direct.""" + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + key = f"document_{document.id!s}_nb_accesses" + nb_accesses_parent = random.randint(1, 4) + factories.UserDocumentAccessFactory.create_batch( + nb_accesses_parent, document=parent + ) + nb_accesses_direct = random.randint(1, 4) + factories.UserDocumentAccessFactory.create_batch( + nb_accesses_direct, document=document + ) + factories.UserDocumentAccessFactory() # An unrelated access should not be counted + + # Initially, the nb_accesses should not be cached + assert cache.get(key) is None + + # Compute the nb_accesses for the first time (this should set the cache) + nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct + with django_assert_num_queries(2): + assert document.nb_accesses_direct == nb_accesses_direct + + # Ensure that the nb_accesses is now cached + with django_assert_num_queries(0): + assert document.nb_accesses_direct == nb_accesses_direct + assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors) + + # The cache value should be invalidated when a document access is created + models.DocumentAccess.objects.create( + document=document, user=factories.UserFactory(), role="reader" + ) + assert cache.get(key) is None # Cache should be invalidated + with django_assert_num_queries(2): + assert document.nb_accesses_direct == nb_accesses_direct + 1 + assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1) + + +@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"]) def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal( + field, django_assert_num_queries, ): """Test that the cache is invalidated when a document access is deleted.""" @@ -753,15 +880,262 @@ def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal( access = factories.UserDocumentAccessFactory(document=document) # Initially, the nb_accesses should be cached - assert document.nb_accesses == 1 - assert cache.get(key) == 1 + assert getattr(document, field) == 1 + assert cache.get(key) == (1, 1) # Remove the access and check if cache is invalidated access.delete() assert cache.get(key) is None # Cache should be invalidated # Recompute the nb_accesses (this should trigger a cache set) - with django_assert_num_queries(1): - new_nb_accesses = document.nb_accesses + with django_assert_num_queries(2): + new_nb_accesses = getattr(document, field) assert new_nb_accesses == 0 - assert cache.get(key) == 0 # Cache should now contain the new value + assert cache.get(key) == (0, 0) # Cache should now contain the new value + + +@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"]) +def test_models_documents_nb_accesses_cache_is_invalidated_on_document_soft_delete_restore( + field, + django_assert_num_queries, +): + """Test that the cache is invalidated when a document access is deleted.""" + document = factories.DocumentFactory() + key = f"document_{document.id!s}_nb_accesses" + factories.UserDocumentAccessFactory(document=document) + + # Initially, the nb_accesses should be cached + assert getattr(document, field) == 1 + assert cache.get(key) == (1, 1) + + # Soft delete the document and check if cache is invalidated + document.soft_delete() + assert cache.get(key) is None # Cache should be invalidated + + # Recompute the nb_accesses (this should trigger a cache set) + with django_assert_num_queries(2): + new_nb_accesses = getattr(document, field) + assert new_nb_accesses == (1 if field == "nb_accesses_direct" else 0) + assert cache.get(key) == (1, 0) # Cache should now contain the new value + + document.restore() + + # Recompute the nb_accesses (this should trigger a cache set) + with django_assert_num_queries(2): + new_nb_accesses = getattr(document, field) + assert new_nb_accesses == 1 + assert cache.get(key) == (1, 1) # Cache should now contain the new value + + +def test_models_documents_numchild_deleted_from_instance(): + """the "numchild" field should not include documents deleted from the instance.""" + document = factories.DocumentFactory() + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + assert document.numchild == 2 + + child1.delete() + + document.refresh_from_db() + assert document.numchild == 1 + + +def test_models_documents_numchild_deleted_from_queryset(): + """the "numchild" field should not include documents deleted from a queryset.""" + document = factories.DocumentFactory() + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + assert document.numchild == 2 + + models.Document.objects.filter(pk=child1.pk).delete() + + document.refresh_from_db() + assert document.numchild == 1 + + +def test_models_documents_numchild_soft_deleted_and_restore(): + """the "numchild" field should not include soft deleted documents.""" + document = factories.DocumentFactory() + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + + assert document.numchild == 2 + + child1.soft_delete() + + document.refresh_from_db() + assert document.numchild == 1 + + child1.restore() + + document.refresh_from_db() + assert document.numchild == 2 + + +def test_models_documents_soft_delete_tempering_with_instance(): + """ + Soft deleting should fail if the document is already deleted in database even though the + instance "deleted_at" attributes where tempered with. + """ + document = factories.DocumentFactory() + document.soft_delete() + + document.deleted_at = None + document.ancestors_deleted_at = None + with pytest.raises( + RuntimeError, match="This document is already deleted or has deleted ancestors." + ): + document.soft_delete() + + +def test_models_documents_restore_tempering_with_instance(): + """ + Soft deleting should fail if the document is already deleted in database even though the + instance "deleted_at" attributes where tempered with. + """ + document = factories.DocumentFactory() + + if random.choice([False, True]): + document.deleted_at = timezone.now() + else: + document.ancestors_deleted_at = timezone.now() + + with pytest.raises(RuntimeError, match="This document is not deleted."): + document.restore() + + +@pytest.mark.parametrize( + "ancestors_links, select_options", + [ + # One ancestor + ( + [{"link_reach": "public", "link_role": "reader"}], + { + "restricted": ["editor"], + "authenticated": ["editor"], + "public": ["reader", "editor"], + }, + ), + ([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}), + ( + [{"link_reach": "authenticated", "link_role": "reader"}], + { + "restricted": ["editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + ( + [{"link_reach": "authenticated", "link_role": "editor"}], + {"authenticated": ["editor"], "public": ["reader", "editor"]}, + ), + ( + [{"link_reach": "restricted", "link_role": "reader"}], + { + "restricted": ["reader", "editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + ( + [{"link_reach": "restricted", "link_role": "editor"}], + { + "restricted": ["editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + # Multiple ancestors with different roles + ( + [ + {"link_reach": "public", "link_role": "reader"}, + {"link_reach": "public", "link_role": "editor"}, + ], + {"public": ["editor"]}, + ), + ( + [ + {"link_reach": "authenticated", "link_role": "reader"}, + {"link_reach": "authenticated", "link_role": "editor"}, + ], + {"authenticated": ["editor"], "public": ["reader", "editor"]}, + ), + ( + [ + {"link_reach": "restricted", "link_role": "reader"}, + {"link_reach": "restricted", "link_role": "editor"}, + ], + { + "restricted": ["editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + # Multiple ancestors with different reaches + ( + [ + {"link_reach": "authenticated", "link_role": "reader"}, + {"link_reach": "public", "link_role": "reader"}, + ], + { + "restricted": ["editor"], + "authenticated": ["editor"], + "public": ["reader", "editor"], + }, + ), + ( + [ + {"link_reach": "restricted", "link_role": "reader"}, + {"link_reach": "authenticated", "link_role": "reader"}, + {"link_reach": "public", "link_role": "reader"}, + ], + { + "restricted": ["editor"], + "authenticated": ["editor"], + "public": ["reader", "editor"], + }, + ), + # Multiple ancestors with mixed reaches and roles + ( + [ + {"link_reach": "authenticated", "link_role": "editor"}, + {"link_reach": "public", "link_role": "reader"}, + ], + {"authenticated": ["editor"], "public": ["reader", "editor"]}, + ), + ( + [ + {"link_reach": "authenticated", "link_role": "reader"}, + {"link_reach": "public", "link_role": "editor"}, + ], + {"public": ["editor"]}, + ), + ( + [ + {"link_reach": "restricted", "link_role": "editor"}, + {"link_reach": "authenticated", "link_role": "reader"}, + ], + { + "restricted": ["editor"], + "authenticated": ["reader", "editor"], + "public": ["reader", "editor"], + }, + ), + ( + [ + {"link_reach": "restricted", "link_role": "reader"}, + {"link_reach": "authenticated", "link_role": "editor"}, + ], + {"authenticated": ["editor"], "public": ["reader", "editor"]}, + ), + # No ancestors (edge case) + ( + [], + { + "public": ["reader", "editor"], + "authenticated": ["reader", "editor"], + "restricted": ["reader", "editor"], + }, + ), + ], +) +def test_models_documents_get_select_options(ancestors_links, select_options): + """Validate that the "get_select_options" method operates as expected.""" + assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options diff --git a/src/frontend/apps/impress/cunningham.ts b/src/frontend/apps/impress/cunningham.ts index b92c12b13..e74092224 100644 --- a/src/frontend/apps/impress/cunningham.ts +++ b/src/frontend/apps/impress/cunningham.ts @@ -1,3 +1,4 @@ + const config = { themes: { default: { diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 290c80a89..fdcbf210b 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -21,12 +21,14 @@ "@blocknote/react": "0.23.2", "@blocknote/xl-docx-exporter": "0.23.2", "@blocknote/xl-pdf-exporter": "0.23.2", + "@fontsource/material-icons": "^5.1.1", "@gouvfr-lasuite/integration": "1.0.2", "@hocuspocus/provider": "2.15.2", "@openfun/cunningham-react": "2.9.4", "@react-pdf/renderer": "4.1.6", "@sentry/nextjs": "8.54.0", "@tanstack/react-query": "5.66.0", + "clsx": "2.1.1", "cmdk": "1.0.4", "crisp-sdk-web": "1.0.25", "docx": "9.1.1", @@ -38,11 +40,14 @@ "next": "15.1.6", "posthog-js": "1.215.6", "react": "*", + "react-arborist": "3.4.0", "react-aria-components": "1.6.0", "react-dom": "*", "react-i18next": "15.4.0", "react-intersection-observer": "9.15.1", + "react-resizable-panels": "^2.1.7", "react-select": "5.10.0", + "sass": "1.83.4", "styled-components": "6.1.15", "use-debounce": "10.0.4", "y-protocols": "1.0.6", @@ -74,6 +79,7 @@ "stylelint-config-standard": "37.0.0", "stylelint-prettier": "5.0.3", "typescript": "*", + "use-resize-observer": "9.1.0", "webpack": "5.97.1", "workbox-webpack-plugin": "7.1.0" } diff --git a/src/frontend/apps/impress/src/components/DropButton.tsx b/src/frontend/apps/impress/src/components/DropButton.tsx index 2dbb26f86..2ba39557c 100644 --- a/src/frontend/apps/impress/src/components/DropButton.tsx +++ b/src/frontend/apps/impress/src/components/DropButton.tsx @@ -64,11 +64,22 @@ export const DropButton = ({ onOpenChange?.(isOpen); }; + const props = { + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onOpenChangeHandler(!isLocalOpen); + }, + }; + return ( <> onOpenChangeHandler(true)} + onPress={(e) => { + onOpenChangeHandler(!isLocalOpen); + }} aria-label={label} $css={buttonCss} > diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 2664e928a..96f77a7bb 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -8,6 +8,7 @@ export type DropdownMenuOption = { icon?: string; label: string; testId?: string; + value?: string; callback?: () => void | Promise; danger?: boolean; isSelected?: boolean; @@ -23,6 +24,8 @@ export type DropdownMenuProps = { buttonCss?: BoxProps['$css']; disabled?: boolean; topMessage?: string; + selectedValues?: string[]; + afterOpenChange?: (isOpen: boolean) => void; }; export const DropdownMenu = ({ @@ -34,6 +37,8 @@ export const DropdownMenu = ({ buttonCss, label, topMessage, + afterOpenChange, + selectedValues, }: PropsWithChildren) => { const theme = useCunninghamTheme(); const spacings = theme.spacingsTokens(); @@ -43,6 +48,7 @@ export const DropdownMenu = ({ const onOpenChange = (isOpen: boolean) => { setIsOpen(isOpen); + afterOpenChange?.(isOpen); }; if (disabled) { @@ -161,7 +167,8 @@ export const DropdownMenu = ({ {option.label} - {option.isSelected && ( + {(option.isSelected || + selectedValues?.includes(option.value ?? '')) && ( )} diff --git a/src/frontend/apps/impress/src/components/Icon.tsx b/src/frontend/apps/impress/src/components/Icon.tsx index 224f87b6b..199d00984 100644 --- a/src/frontend/apps/impress/src/components/Icon.tsx +++ b/src/frontend/apps/impress/src/components/Icon.tsx @@ -5,10 +5,19 @@ import { useCunninghamTheme } from '@/cunningham'; type IconProps = TextType & { iconName: string; + isFilled?: boolean; }; -export const Icon = ({ iconName, ...textProps }: IconProps) => { +export const Icon = ({ iconName, isFilled, ...textProps }: IconProps) => { return ( - + {iconName} ); diff --git a/src/frontend/apps/impress/src/components/common/loader/Loader.tsx b/src/frontend/apps/impress/src/components/common/loader/Loader.tsx new file mode 100644 index 000000000..6cadbc71d --- /dev/null +++ b/src/frontend/apps/impress/src/components/common/loader/Loader.tsx @@ -0,0 +1,9 @@ +import styles from './loader.module.scss'; + +interface LoaderProps { + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +export const Loader = ({ size = 'sm' }: LoaderProps) => { + return
; +}; diff --git a/src/frontend/apps/impress/src/components/common/loader/loader.module.scss b/src/frontend/apps/impress/src/components/common/loader/loader.module.scss new file mode 100644 index 000000000..75fc0fca3 --- /dev/null +++ b/src/frontend/apps/impress/src/components/common/loader/loader.module.scss @@ -0,0 +1,40 @@ +.loader { + border-radius: 50%; + background: + radial-gradient(farthest-side, #cecece 94%, #0000) top/3.8px 3.8px no-repeat, + conic-gradient(#0000 30%, #cecece); + -webkit-mask: radial-gradient( + farthest-side, + #0000 calc(100% - 3.8px), + #000 0 + ); + animation: spinner-c7wet2 1s infinite linear; + &.sm { + width: 16px; + height: 16px; + } + + &.md { + width: 24px; + height: 24px; + } + + &.lg { + width: 32px; + height: 32px; + } + + &.xl { + width: 40px; + height: 40px; + } +} + +.spinner { +} + +@keyframes spinner-c7wet2 { + 100% { + transform: rotate(1turn); + } +} diff --git a/src/frontend/apps/impress/src/components/common/tree/TreeView.tsx b/src/frontend/apps/impress/src/components/common/tree/TreeView.tsx new file mode 100644 index 000000000..1008f82fa --- /dev/null +++ b/src/frontend/apps/impress/src/components/common/tree/TreeView.tsx @@ -0,0 +1,352 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import { clsx } from 'clsx'; +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { + CursorProps, + MoveHandler, + NodeApi, + NodeRendererProps, + Tree, +} from 'react-arborist'; +import { OpenMap } from 'react-arborist/dist/module/state/open-slice'; + +import { + BaseType, + TreeViewDataType, + TreeViewMoveModeEnum, + TreeViewMoveResult, +} from '@/features/docs/doc-tree/types/tree'; + +import { Box } from '../../Box'; +import { Icon } from '../../Icon'; +import { Loader } from '../loader/Loader'; + +import styles from './treeview.module.scss'; + +export type TreeViewProps = { + treeData: TreeViewDataType[]; + width?: number | string; + selectedNodeId?: string; + rootNodeId: string; + initialOpenState?: OpenMap; + renderNode: ( + props: NodeRendererProps>, + ) => React.ReactNode; + afterMove?: ( + result: TreeViewMoveResult, + newTreeData: TreeViewDataType[], + ) => void; +}; + +export const TreeView = ({ + treeData, + width, + rootNodeId, + renderNode, + afterMove, + selectedNodeId, + initialOpenState, +}: TreeViewProps) => { + const onMove3 = (args: { + dragIds: string[]; + dragNodes: NodeApi>[]; + parentId: string | null; + parentNode: NodeApi> | null; + index: number; + }): TreeViewMoveResult | null => { + const newData = treeData.map((rootItem) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...rest } = rootItem; + return rest; + }); + + const sourceNodeId = args.dragNodes[0].data.id; + const sourceNode = args.dragNodes[0].data; + const oldParentId = sourceNode.parentId ?? rootNodeId; + const newIndex = args.index; + const targetNodeId = args.parentId ?? rootNodeId; + + const children = args.parentId + ? (args.parentNode?.children ?? []) + : newData; + + if (newIndex === 0) { + return { + targetNodeId: targetNodeId ?? rootNodeId, + mode: TreeViewMoveModeEnum.FIRST_CHILD, + sourceNodeId, + oldParentId, + }; + } + if (newIndex === children.length) { + return { + targetNodeId: targetNodeId ?? rootNodeId, + mode: TreeViewMoveModeEnum.LAST_CHILD, + sourceNodeId, + oldParentId, + }; + } + + const siblingIndex = newIndex - 1; + const sibling = children[siblingIndex]; + + if (sibling) { + return { + targetNodeId: sibling.id, + mode: TreeViewMoveModeEnum.RIGHT, + sourceNodeId, + oldParentId, + }; + } + + const nextSiblingIndex = newIndex + 1; + const nextSibling = children[nextSiblingIndex]; + if (nextSibling) { + return { + targetNodeId: nextSibling.id, + mode: TreeViewMoveModeEnum.LEFT, + sourceNodeId, + oldParentId, + }; + } + + return null; + }; + + const onMove = (args: { + dragIds: string[]; + dragNodes: NodeApi>[]; + parentId: string | null; + parentNode: NodeApi> | null; + index: number; + }) => { + // Création d'une copie profonde pour éviter les mutations directes + const newData = JSON.parse( + JSON.stringify(treeData), + ) as TreeViewDataType[]; + const draggedId = args.dragIds[0]; + + // Fonction helper pour trouver et supprimer un nœud dans l'arbre + const findAndRemoveNode = ( + items: TreeViewDataType[], + parentId?: string, + ): { + currentIndex: number; + newIndex: number; + parentId?: string; + draggedNode: TreeViewDataType; + } | null => { + items.forEach((item, index) => { + if (item.id === draggedId) { + return { + currentIndex: index, + }; + } + }); + + for (let i = 0; i < items.length; i++) { + if (items[i].id === draggedId) { + const currentIndex = i; + let newIndex = args.index; + if (currentIndex < newIndex) { + newIndex -= 1; + } + return { + currentIndex: i, + parentId, + newIndex, + draggedNode: items.splice(i, 1)[0], + }; + } + if (items[i].children?.length) { + const found = findAndRemoveNode( + items[i]?.children ?? [], + items[i].id, + ); + if (found) { + return found; + } + } + } + return null; + }; + + // Trouver et supprimer le nœud déplacé + const r = findAndRemoveNode(newData); + const draggedNode = r?.draggedNode; + const currentIndex = r?.currentIndex ?? -1; + const newIndex = r?.newIndex ?? -1; + if (!draggedNode || currentIndex < 0 || newIndex < 0) { + return; + } + + // Cas 1: Déplacement à la racine + if (!args.parentNode) { + draggedNode.parentId = rootNodeId; + newData.splice(newIndex, 0, draggedNode); + } + // Cas 2: Déplacement dans un dossier + else { + const targetParent = args.parentNode.data; + draggedNode.parentId = targetParent.id; + const findParentAndInsert = (items: TreeViewDataType[]) => { + for (const item of items) { + if (item.id === targetParent.id) { + item.children = item.children || []; + item.children.splice( + r.parentId === targetParent.id ? r.newIndex : args.index, + 0, + draggedNode, + ); + + return true; + } + if (item.children?.length) { + if (findParentAndInsert(item.children)) { + return true; + } + } + } + return false; + }; + + findParentAndInsert(newData); + } + + const moveResult = onMove3(args); + if (moveResult) { + afterMove?.(moveResult, newData); + } + }; + + return ( + >} + > + {(props) => renderNode(props)} + + ); +}; + +function Cursor({ top, left }: CursorProps) { + return ( +
+ ); +} + +export type TreeViewNodeProps = NodeRendererProps> & { + children: ReactNode; + onClick?: () => void; + loadChildren?: (node?: TreeViewDataType) => Promise[]>; +}; + +export const TreeViewNode = ({ + children, + onClick, + node, + dragHandle, + style, + loadChildren, +}: TreeViewNodeProps) => { + /* This node instance can do many things. See the API reference. */ + const timeoutRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const hasChildren = + (node.data.childrenCount !== undefined && node.data.childrenCount > 0) || + (node.data.children?.length ?? 0) > 0; + + const isLeaf = node.isLeaf || !hasChildren; + + const hasLoadedChildren = node.children?.length ?? 0 > 0; + + const handleClick = useCallback(async () => { + if (isLeaf) { + return; + } + + if (hasLoadedChildren) { + node.toggle(); + return; + } + + setIsLoading(true); + await loadChildren?.(node.data); + setIsLoading(false); + node.open(); + }, [hasLoadedChildren, loadChildren, node, isLeaf]); + + useEffect(() => { + if (node.willReceiveDrop && !node.isOpen) { + timeoutRef.current = setTimeout(() => { + void handleClick(); + }, 200); + } + + if (timeoutRef.current && !node.willReceiveDrop) { + clearTimeout(timeoutRef.current); + } + }, [node, handleClick]); + return ( +
+ {isLeaf ? ( + + ) : ( + <> + {isLoading ? ( + + + + ) : ( + { + e.stopPropagation(); + e.preventDefault(); + void handleClick(); + }} + $variation="500" + $size="16px" + iconName={ + node.isOpen ? 'keyboard_arrow_down' : 'keyboard_arrow_right' + } + /> + )} + + )} + {children} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/components/common/tree/treeStore.ts b/src/frontend/apps/impress/src/components/common/tree/treeStore.ts new file mode 100644 index 000000000..a3abc976c --- /dev/null +++ b/src/frontend/apps/impress/src/components/common/tree/treeStore.ts @@ -0,0 +1,186 @@ +import { create } from 'zustand'; + +import { Doc, getDoc } from '@/features/docs'; +import { TreeViewDataType } from '@/features/docs/doc-tree/types/tree'; + +interface TreeStore { + treeData: TreeViewDataType[]; + selectedNode: TreeViewDataType | null; + rootId: string | undefined; + initialNode: TreeViewDataType | undefined; + setInitialNode: (node: TreeViewDataType | undefined) => void; + setSelectedNode: (node: TreeViewDataType | null) => void; + setTreeData: (data: TreeViewDataType[]) => void; + updateNode: (nodeId: string, newData: Partial>) => void; + addRootNode: (node: TreeViewDataType) => void; + removeNode: (nodeId: string) => void; + addChildNode: (parentId: string, newNode: TreeViewDataType) => void; + refreshNode: (nodeId: string) => void; + findNode: (nodeId: string) => TreeViewDataType | null; + setRootId: (id?: string) => void; + reset: ( + rootId?: string, + treeData?: TreeViewDataType[], + selectedNode?: TreeViewDataType | null, + ) => void; +} + +export const createTreeStore = ( + refreshCallback?: (id: string) => Promise>>, +) => + create>((set, get) => ({ + treeData: [], + selectedNode: null, + rootId: undefined, + initialNode: undefined, + setSelectedNode: (node) => { + set({ selectedNode: node }); + }, + setInitialNode: (node) => { + set({ initialNode: node }); + }, + setTreeData: (data) => { + set({ treeData: data }); + }, + setRootId: (id) => { + set({ rootId: id }); + }, + refreshNode: (nodeId) => { + set((state) => { + const node = state.findNode(nodeId); + if (!node) { + return state; + } + refreshCallback?.(nodeId) + .then((data) => { + console.log('data', data); + state.updateNode(nodeId, { ...node, ...data }); + }) + .catch((error) => { + console.error(error); + }); + return state; + }); + }, + updateNode: (nodeId, newData) => { + set((state) => { + const updateNodeInTree = ( + nodes: TreeViewDataType[], + ): TreeViewDataType[] => { + return nodes.map((node) => { + if (node.id === nodeId) { + return { ...node, ...newData }; + } + if (node.children) { + return { + ...node, + children: updateNodeInTree(node.children), + }; + } + return node; + }); + }; + + return { + treeData: updateNodeInTree(state.treeData), + }; + }); + }, + + addRootNode: (node) => { + set((state) => ({ + treeData: [...state.treeData, node], + })); + }, + + removeNode: (nodeId) => { + set((state) => { + const removeNodeFromTree = ( + nodes: TreeViewDataType[], + ): TreeViewDataType[] => { + const filteredNodes = nodes.filter((node) => node.id !== nodeId); + + return filteredNodes.map((node) => { + if (node.children) { + const children = removeNodeFromTree(node.children); + return { + ...node, + children: children, + childrenCount: children.length, + }; + } + return node; + }); + }; + + return { + treeData: removeNodeFromTree(state.treeData), + }; + }); + }, + + addChildNode: (parentId, newNode) => { + set((state) => { + const addChildToNode = ( + nodes: TreeViewDataType[], + ): TreeViewDataType[] => { + return nodes.map((node) => { + if (node.id === parentId) { + return { + ...node, + children: [...(node.children || []), newNode], + }; + } + if (node.children) { + return { + ...node, + children: addChildToNode(node.children), + }; + } + return node; + }); + }; + + return { + treeData: addChildToNode(state.treeData), + }; + }); + }, + + findNode: (nodeId) => { + const findNodeInTree = ( + nodes: TreeViewDataType[], + ): TreeViewDataType | null => { + for (const node of nodes) { + if (node.id === nodeId) { + return node; + } + if (node.children) { + const found = findNodeInTree(node.children); + if (found) { + return found; + } + } + } + return null; + }; + + return findNodeInTree(get().treeData); + }, + reset: (rootId, treeData, selectedNode) => { + console.log('reset', rootId, treeData, selectedNode); + set({ + treeData: treeData ?? [], + selectedNode: selectedNode ?? null, + rootId: rootId, + initialNode: selectedNode ?? undefined, + }); + }, + })); + +// Créer une instance du store +// export const useTreeStore = createTreeStore(); +export const useTreeStore = createTreeStore(async (docId) => { + const doc = await getDoc({ id: docId }); + return { ...doc, childrenCount: doc.numchild }; +}); diff --git a/src/frontend/apps/impress/src/components/common/tree/treeview.module.scss b/src/frontend/apps/impress/src/components/common/tree/treeview.module.scss new file mode 100644 index 000000000..03a0f7662 --- /dev/null +++ b/src/frontend/apps/impress/src/components/common/tree/treeview.module.scss @@ -0,0 +1,46 @@ +.container { + [role='treeitem'] { + display: flex; + align-items: center; + } +} + +.node { + width: 100%; + // padding-left: 0px !important; + // margin-left: 180px; + // min-width: 300px; + height: calc(100% - 2px); + overflow: hidden; + display: flex; + align-items: center; + flex-wrap: nowrap; + border-radius: 4px; + border: 1.5px solid rgba(0, 0, 0, 0); + cursor: pointer; + + padding: var(--c--theme--spacings--4xs) 0; + gap: var(--c--theme--spacings--3xs); + + &:not(.willReceiveDrop, .selected):hover { + background-color: var(--c--theme--colors--greyscale-100); + } + + &.selected { + background-color: var(--c--theme--colors--greyscale-100); + + font-weight: 700; + } + + &.willReceiveDrop { + background-color: var(--c--theme--colors--primary-100); + border: 1.5px solid var(--c--theme--colors--primary-500); + } +} + +.cursor { + position: absolute; + width: 100%; + height: 0; + border-top: 2px solid var(--c--theme--colors--primary-500); +} diff --git a/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx new file mode 100644 index 000000000..313209bf4 --- /dev/null +++ b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx @@ -0,0 +1,63 @@ +import { css } from 'styled-components'; + +import { Box } from '../Box'; +import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu'; +import { Icon } from '../Icon'; +import { Text } from '../Text'; + +export type FilterDropdownProps = { + options: DropdownMenuOption[]; + selectedValue?: string; +}; + +export const FilterDropdown = ({ + options, + selectedValue, +}: FilterDropdownProps) => { + const selectedOption = options.find( + (option) => option.value === selectedValue, + ); + + if (options.length === 0) { + return null; + } + + return ( + + + + {selectedOption?.label ?? options[0].label} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx index 278879278..97d8b7a90 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ import { Command } from 'cmdk'; import { ReactNode, useRef } from 'react'; @@ -48,7 +49,12 @@ export const QuickSearch = ({ return ( <> -
+
{ + e.stopPropagation(); + }} + > {showInput && ( { + e.stopPropagation(); + }} value={inputValue} role="combobox" placeholder={placeholder ?? t('Search')} diff --git a/src/frontend/apps/impress/src/core/AppProvider.tsx b/src/frontend/apps/impress/src/core/AppProvider.tsx index 52c227e74..bf1554058 100644 --- a/src/frontend/apps/impress/src/core/AppProvider.tsx +++ b/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -41,7 +41,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) { - {children} + + {children} +
+ diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index fb9dea730..e1dd3c0d6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { Box, Text } from '@/components'; +import { useTreeStore } from '@/components/common/tree/treeStore'; import { useCunninghamTheme } from '@/cunningham'; import { Doc, @@ -52,6 +53,8 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => { const DocTitleInput = ({ doc }: DocTitleProps) => { const { isDesktop } = useResponsiveStore(); + const { updateNode, setSelectedNode } = useTreeStore(); + const { t } = useTranslation(); const { colorsTokens } = useCunninghamTheme(); const [titleDisplay, setTitleDisplay] = useState(doc.title); @@ -64,7 +67,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => { listInvalideQueries: [KEY_DOC, KEY_LIST_DOC], onSuccess(data) { toast(t('Document title updated successfully'), VariantType.SUCCESS); - + updateNode(doc.id, { title: data.title }); // Broadcast to every user connected to the document broadcast(`${KEY_DOC}-${data.id}`); }, 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 58e71ac23..15cbfc010 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 @@ -38,7 +38,7 @@ interface DocToolBoxProps { export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); - const hasAccesses = doc.nb_accesses > 1 && doc.abilities.accesses_view; + const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; const queryClient = useQueryClient(); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); @@ -194,7 +194,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }} size={isSmallMobile ? 'small' : 'medium'} > - {doc.nb_accesses} + {doc.nb_accesses_direct} )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx index ebbb1d543..f9cabe91f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx @@ -28,6 +28,7 @@ export function useDoc( return useQuery({ queryKey: [KEY_DOC, param], queryFn: () => getDoc(param), + ...queryConfig, }); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index c9881ad70..81a647298 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -8,6 +8,7 @@ import { useAPIInfiniteQuery, } from '@/api'; +import { DocSearchTarget } from '../../doc-search/components/DocSearchFilters'; import { Doc } from '../types'; export const isDocsOrdering = (data: string): data is DocsOrdering => { @@ -31,6 +32,8 @@ export type DocsParams = { is_creator_me?: boolean; title?: string; is_favorite?: boolean; + target?: DocSearchTarget; + parent_id?: string; }; export type DocsResponse = APIList; @@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise => { if (params.is_favorite !== undefined) { searchParams.set('is_favorite', params.is_favorite.toString()); } - - const response = await fetchAPI(`documents/?${searchParams.toString()}`); + let response: Response; + if (params.parent_id && params.target === DocSearchTarget.CURRENT) { + response = await fetchAPI( + `documents/${params.parent_id}/descendants/?${searchParams.toString()}`, + ); + } else { + response = await fetchAPI(`documents/?${searchParams.toString()}`); + } if (!response.ok) { throw new APIError('Failed to get the docs', await errorCauses(response)); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index a3a134aaa..53115c9d2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -17,16 +17,20 @@ import { Doc } from '../types'; interface ModalRemoveDocProps { onClose: () => void; doc: Doc; + afterDelete?: (doc: Doc) => void; } -export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { +export const ModalRemoveDoc = ({ + onClose, + doc, + afterDelete, +}: ModalRemoveDocProps) => { const { toast } = useToastProvider(); const { push } = useRouter(); const pathname = usePathname(); const { mutate: removeDoc, - isError, error, } = useRemoveDoc({ @@ -34,6 +38,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { toast(t('The document has been deleted.'), VariantType.SUCCESS, { duration: 4000, }); + if (afterDelete) { + afterDelete(doc); + return; + } + if (pathname === '/') { onClose(); } else { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRenameDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRenameDoc.tsx new file mode 100644 index 000000000..8f7f9c001 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRenameDoc.tsx @@ -0,0 +1,86 @@ +import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +import { useTreeStore } from '@/components/common/tree/treeStore'; + +import { useUpdateDoc } from '../api'; +import { Doc } from '../types'; + +interface ModalRenameDocProps { + onClose: () => void; + doc: Doc; +} + +export const ModalRenameDoc = ({ onClose, doc }: ModalRenameDocProps) => { + const { t } = useTranslation(); + const { updateNode } = useTreeStore(); + const { mutate: updateDoc } = useUpdateDoc(); + const [title, setTitle] = useState(doc.title); + + const onRename = () => { + updateDoc( + { + id: doc.id, + title, + }, + { + onSuccess: (doc) => { + updateNode(doc.id, doc); + onClose(); + }, + }, + ); + }; + + return ( + + {t('Rename')} + + } + isOpen + onClose={onClose} + size={ModalSize.SMALL} + rightActions={ + <> + + + + } + > + + { + e.stopPropagation(); + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + onRename(); + } + }} + value={title} + label={t('Document name')} + onChange={(e) => { + e.stopPropagation(); + + setTitle(e.target.value); + }} + /> + + + ); +}; 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 8462df0ed..578c853a4 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 @@ -42,9 +42,12 @@ export interface Doc { is_favorite: boolean; link_reach: LinkReach; link_role: LinkRole; - nb_accesses: number; + nb_accesses_direct: number; + depth: number; created_at: string; updated_at: string; + numchild: number; + children?: Doc[]; abilities: { accesses_manage: boolean; accesses_view: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx new file mode 100644 index 000000000..eb46a6755 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx @@ -0,0 +1,66 @@ +import { Button } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; +import { FilterDropdown } from '@/components/filter/FilterDropdown'; + +export enum DocSearchTarget { + ALL = 'all', + CURRENT = 'current', +} + +export type DocSearchFiltersValues = { + target?: DocSearchTarget; +}; + +export type DocSearchFiltersProps = { + values?: DocSearchFiltersValues; + onValuesChange?: (values: DocSearchFiltersValues) => void; + onReset?: () => void; +}; + +export const DocSearchFilters = ({ + values, + onValuesChange, + onReset, +}: DocSearchFiltersProps) => { + const { t } = useTranslation(); + const hasFilters = Object.keys(values ?? {}).length > 0; + const handleTargetChange = (target: DocSearchTarget) => { + onValuesChange?.({ ...values, target }); + }; + + return ( + + + handleTargetChange(DocSearchTarget.ALL), + }, + { + label: t('Current doc'), + value: DocSearchTarget.CURRENT, + callback: () => handleTargetChange(DocSearchTarget.CURRENT), + }, + ]} + /> + + {hasFilters && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx index 283871937..0c15e2ca5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx @@ -4,21 +4,34 @@ import { Doc } from '@/features/docs/doc-management'; import { SimpleDocItem } from '@/features/docs/docs-grid/'; import { useResponsiveStore } from '@/stores'; +import { LightDocItem } from '../../docs-grid/components/LightDocItem'; + type DocSearchItemProps = { doc: Doc; + isSubPage?: boolean; }; -export const DocSearchItem = ({ doc }: DocSearchItemProps) => { +export const DocSearchItem = ({ + doc, + isSubPage = false, +}: DocSearchItemProps) => { const { isDesktop } = useResponsiveStore(); + return ( - - + <> + + + {isSubPage ? ( + + ) : ( + + )} + - + } right={ diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx index 7585ba4ad..35f1fe5fa 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -7,6 +7,7 @@ import { InView } from 'react-intersection-observer'; import { useDebouncedCallback } from 'use-debounce'; import { Box } from '@/components'; +import { useTreeStore } from '@/components/common/tree/treeStore'; import { QuickSearch, QuickSearchData, @@ -17,15 +18,29 @@ import { useResponsiveStore } from '@/stores'; import EmptySearchIcon from '../assets/illustration-docs-empty.png'; +import { DocSearchFilters, DocSearchFiltersValues } from './DocSearchFilters'; import { DocSearchItem } from './DocSearchItem'; -type DocSearchModalProps = ModalProps & {}; +type DocSearchModalProps = ModalProps & { + showFilters?: boolean; + defaultFilters?: DocSearchFiltersValues; +}; -export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => { +export const DocSearchModal = ({ + showFilters = false, + defaultFilters, + ...modalProps +}: DocSearchModalProps) => { const { t } = useTranslation(); + const { rootId, initialNode, reset } = useTreeStore(); const router = useRouter(); + const [search, setSearch] = useState(''); + const [filters, setFilters] = useState( + defaultFilters ?? {}, + ); const { isDesktop } = useResponsiveStore(); + const { data, isFetching, @@ -36,27 +51,37 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => { } = useInfiniteDocs({ page: 1, title: search, + ...filters, + parent_id: rootId, }); const loading = isFetching || isRefetching || isLoading; const handleInputSearch = useDebouncedCallback(setSearch, 300); const handleSelect = (doc: Doc) => { + if (initialNode?.id !== doc.id) { + reset(doc.id, [], doc); + } router.push(`/docs/${doc.id}`); modalProps.onClose?.(); }; + const handleResetFilters = () => { + setFilters({}); + }; + const docsData: QuickSearchData = useMemo(() => { const docs = data?.pages.flatMap((page) => page.results) || []; - + const groupName = + filters.target != null ? t('Select a sub-page') : t('Select a page'); return { - groupName: docs.length > 0 ? t('Select a document') : '', + groupName: docs.length > 0 ? groupName : '', elements: search ? docs : [], emptyString: t('No document found'), endActions: hasNextPage ? [{ content: void fetchNextPage()} /> }] : [], }; - }, [data, hasNextPage, fetchNextPage, t, search]); + }, [data, hasNextPage, fetchNextPage, t, search, filters.target]); return ( { onFilter={handleInputSearch} > + {showFilters && ( + + )} {search.length === 0 && ( { } + renderElement={(doc) => ( + + )} /> )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx index e1282d539..ef7dcb80c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx @@ -9,6 +9,7 @@ import { css } from 'styled-components'; import { APIError } from '@/api'; import { Box } from '@/components'; +import { useTreeStore } from '@/components/common/tree/treeStore'; import { useCunninghamTheme } from '@/cunningham'; import { User } from '@/features/auth'; import { Doc, Role } from '@/features/docs'; @@ -39,6 +40,7 @@ export const DocShareAddMemberList = ({ afterInvite, }: Props) => { const { t } = useTranslation(); + const { refreshNode } = useTreeStore(); const { toast } = useToastProvider(); const [isLoading, setIsLoading] = useState(false); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); @@ -94,14 +96,24 @@ export const DocShareAddMemberList = ({ }; return isInvitationMode - ? createInvitation({ - ...payload, - email: user.email, - }) - : createDocAccess({ - ...payload, - memberId: user.id, - }); + ? createInvitation( + { + ...payload, + email: user.email, + }, + { + onSuccess: () => refreshNode(doc.id), + }, + ) + : createDocAccess( + { + ...payload, + memberId: user.id, + }, + { + onSuccess: () => refreshNode(doc.id), + }, + ); }); const settledPromises = await Promise.allSettled(promises); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx index 1b09154ec..22a717bae 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx @@ -7,6 +7,7 @@ import { DropdownMenuOption, IconOptions, } from '@/components'; +import { useTreeStore } from '@/components/common/tree/treeStore'; import { useCunninghamTheme } from '@/cunningham'; import { Access, Doc, Role } from '@/features/docs/doc-management/'; import { useResponsiveStore } from '@/stores'; @@ -23,6 +24,7 @@ type Props = { }; export const DocShareMemberItem = ({ doc, access }: Props) => { const { t } = useTranslation(); + const { refreshNode } = useTreeStore(); const { isLastOwner, isOtherOwner } = useWhoAmI(access); const { toast } = useToastProvider(); const { isDesktop } = useResponsiveStore(); @@ -48,15 +50,21 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }); const onUpdate = (newRole: Role) => { - updateDocAccess({ - docId: doc.id, - role: newRole, - accessId: access.id, - }); + updateDocAccess( + { + docId: doc.id, + role: newRole, + accessId: access.id, + }, + { onSuccess: () => refreshNode(doc.id) }, + ); }; const onRemove = () => { - removeDocAccess({ accessId: access.id, docId: doc.id }); + removeDocAccess( + { accessId: access.id, docId: doc.id }, + { onSuccess: () => refreshNode(doc.id) }, + ); }; const moreActions: DropdownMenuOption[] = [ diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index 9f26d0598..87250b716 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -57,6 +57,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => { const canShare = doc.abilities.accesses_manage; const canViewAccesses = doc.abilities.accesses_view; const showMemberSection = inputValue === '' && selectedUsers.length === 0; + const showFooter = selectedUsers.length === 0 && !inputValue; const onSelect = (user: User) => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx new file mode 100644 index 000000000..b9f774a81 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc, KEY_LIST_DOC } from '../../doc-management'; + +export type CreateDocParam = Pick & { + parentId: string; +}; + +export const createDocChildren = async ({ + title, + parentId, +}: CreateDocParam): Promise => { + const response = await fetchAPI(`documents/${parentId}/children/`, { + method: 'POST', + body: JSON.stringify({ + title, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to create the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +interface CreateDocProps { + onSuccess: (data: Doc) => void; +} + +export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createDocChildren, + onSuccess: (data) => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC], + }); + onSuccess(data); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx new file mode 100644 index 000000000..406c32a77 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx @@ -0,0 +1,58 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api'; + +import { DocsResponse } from '../../doc-management'; + +export type DocsChildrenParams = { + docId: string; + page?: number; + page_size?: number; +}; + +export const getDocChildren = async ( + params: DocsChildrenParams, +): Promise => { + const { docId, page, page_size } = params; + const searchParams = new URLSearchParams(); + + if (page) { + searchParams.set('page', page.toString()); + } + if (page_size) { + searchParams.set('page_size', page_size.toString()); + } + + const response = await fetchAPI( + `documents/${docId}/children/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc children', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-children'; + +export function useDocChildren( + params: DocsChildrenParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocChildren(params), + ...queryConfig, + }); +} + +export const useInfiniteDocChildren = (params: DocsChildrenParams) => { + return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx new file mode 100644 index 000000000..e360f66e0 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx @@ -0,0 +1,45 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc } from '../../doc-management'; + +export type DocsTreeParams = { + docId: string; +}; + +export const getDocTree = async (params: DocsTreeParams): Promise => { + const { docId } = params; + const searchParams = new URLSearchParams(); + + const response = await fetchAPI( + `documents/${docId}/tree/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc tree', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-tree'; + +export function useDocTree( + params: DocsTreeParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocTree(params), + staleTime: 0, + gcTime: 0, + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx new file mode 100644 index 000000000..0b1504364 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx @@ -0,0 +1,37 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { TreeViewMoveModeEnum } from '../types/tree'; + +export type MoveDocParam = { + sourceDocumentId: string; + targetDocumentId: string; + position: TreeViewMoveModeEnum; +}; + +export const moveDoc = async ({ + sourceDocumentId, + targetDocumentId, + position, +}: MoveDocParam): Promise => { + const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, { + method: 'POST', + body: JSON.stringify({ + target_document_id: targetDocumentId, + position, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to move the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export function useMoveDoc() { + return useMutation({ + mutationFn: moveDoc, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-s.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-s.svg new file mode 100644 index 000000000..790684c6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-s.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-tree-logo.png b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-tree-logo.png new file mode 100644 index 000000000..d816d8f12 Binary files /dev/null and b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-tree-logo.png differ diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx new file mode 100644 index 000000000..a6fb9afd7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'react'; +import { OpenMap } from 'react-arborist/dist/module/state/open-slice'; +import { useTreeData } from 'react-stately'; +import { css } from 'styled-components'; + +import { Box, SeparatedSection, StyledLink } from '@/components'; +import { TreeView } from '@/components/common/tree/TreeView'; +import { useTreeStore } from '@/components/common/tree/treeStore'; +import { useCunninghamTheme } from '@/cunningham'; + +import { Doc } from '../../doc-management'; +import { SimpleDocItem } from '../../docs-grid'; +import { useDocTree } from '../api/useDocTree'; +import { useMoveDoc } from '../api/useMove'; +import { TreeViewDataType, TreeViewMoveResult } from '../types/tree'; + +import { DocTreeItem } from './DocTreeItem'; + +type Props = { + docId: Doc['id']; +}; + +export type DocTreeDataType = TreeViewDataType; +export const DocTree = ({ docId }: Props) => { + const [rootNode, setRootNode] = useState(null); + const { spacingsTokens } = useCunninghamTheme(); + const spacing = spacingsTokens(); + const moveDoc = useMoveDoc(); + const tree = useTreeData({ + initialItems: [], + getKey: (item) => item.id, + initialSelectedKeys: [], + getChildren: (item) => item.children ?? [], + }); + + const [initialOpenState, setInitialOpenState] = useState( + undefined, + ); + + const { + selectedNode, + setSelectedNode, + refreshNode, + setTreeData: setTreeDataStore, + treeData: treeDataStore, + setRootId, + } = useTreeStore(); + + const { data, isLoading, isFetching, isRefetching } = useDocTree({ + docId, + }); + + const afterMove = ( + result: TreeViewMoveResult, + newTreeData: TreeViewDataType[], + ) => { + const { targetNodeId, mode: position, sourceNodeId, oldParentId } = result; + moveDoc.mutate( + { + sourceDocumentId: sourceNodeId, + targetDocumentId: targetNodeId, + position, + }, + { + onSuccess: () => { + setTreeDataStore(newTreeData); + if (oldParentId) { + refreshNode(oldParentId); + } + refreshNode(targetNodeId); + }, + }, + ); + }; + + useEffect(() => { + if (!data) { + return; + } + + const initialOpenState: OpenMap = {}; + const root = data; + + initialOpenState[root.id] = true; + + const serialize = ( + children: Doc[], + parentId: Doc['id'], + ): DocTreeDataType[] => { + if (children.length === 0) { + return []; + } + return children.map((child) => { + if (child?.children?.length && child?.children?.length > 0) { + initialOpenState[child.id] = true; + } + + if (docId === child.id) { + setSelectedNode(child); + } + + const node = { + ...child, + childrenCount: child.numchild, + children: serialize(child.children ?? [], child.id), + parentId: parentId, + }; + if (child?.children?.length && child?.children?.length > 0) { + initialOpenState[child.id] = true; + } + return node; + }); + }; + + console.log('open state', initialOpenState); + + root.children = serialize(root.children ?? [], docId); + + setInitialOpenState(initialOpenState); + setRootNode(root); + setRootId(root.id); + setTreeDataStore(root.children ?? []); + }, [data, setTreeDataStore, docId, setSelectedNode, rootNode, setRootId]); + + const isRootNodeSelected = !selectedNode + ? true + : selectedNode?.id === rootNode?.id; + + if (isLoading || isFetching || isRefetching) { + return
Loading...
; + } + + if (!data) { + return
No data
; + } + + return ( + <> + + + + {rootNode && ( + { + setSelectedNode(rootNode); + }} + > + + + )} + + + + + {initialOpenState && treeDataStore.length > 0 && ( + } + afterMove={(result, newTreeData) => { + void afterMove(result, newTreeData); + }} + /> + )} + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItem.tsx new file mode 100644 index 000000000..3d9303405 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItem.tsx @@ -0,0 +1,175 @@ +import { useModal } from '@openfun/cunningham-react'; +import { useRouter } from 'next/navigation'; +import { Fragment, useState } from 'react'; +import { NodeRendererProps } from 'react-arborist'; +import { useTranslation } from 'react-i18next'; + +import { Box, BoxButton, DropdownMenu, Icon } from '@/components'; +import { TreeViewNode } from '@/components/common/tree/TreeView'; +import { useTreeStore } from '@/components/common/tree/treeStore'; +import { useCunninghamTheme } from '@/cunningham'; +import { useLeftPanelStore } from '@/features/left-panel'; + +import { ModalRemoveDoc } from '../../doc-management'; +import { ModalRenameDoc } from '../../doc-management/components/ModalRenameDoc'; +import { DocShareModal } from '../../doc-share'; +import { LightDocItem } from '../../docs-grid/components/LightDocItem'; +import { useCreateChildrenDoc } from '../api/useCreateChildren'; +import { useDocChildren } from '../api/useDocChildren'; +import { TreeViewDataType } from '../types/tree'; + +import { DocTreeDataType } from './DocTree'; + +type DocTreeItemProps = NodeRendererProps>; + +export const DocTreeItem = ({ node, ...props }: DocTreeItemProps) => { + const data = node.data; + + const deleteModal = useModal(); + + const shareModal = useModal(); + const renameModal = useModal(); + const [isOpen, setIsOpen] = useState(false); + const { updateNode, setSelectedNode, removeNode, refreshNode } = + useTreeStore(); + const { spacingsTokens } = useCunninghamTheme(); + const { refetch } = useDocChildren( + { + docId: data.id, + page_size: 999, + }, + { enabled: false }, + ); + + const { t } = useTranslation(); + const router = useRouter(); + const { togglePanel } = useLeftPanelStore(); + const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + onSuccess: (doc) => { + const actualChildren = node.data.children ?? []; + if (actualChildren.length === 0) { + loadChildren() + .then(() => { + node.open(); + router.push(`/docs/${doc.id}`); + togglePanel(); + }) + .catch(console.error); + } else { + const newDoc = { + ...doc, + children: [], + childrenCount: 0, + parentId: node.id, + }; + updateNode(node.id, { + ...node.data, + children: [...actualChildren, newDoc], + childrenCount: actualChildren.length + 1, + }); + node.open(); + router.push(`/docs/${doc.id}`); + togglePanel(); + } + setSelectedNode(doc); + }, + }); + const spacing = spacingsTokens(); + + const loadChildren = async () => { + const data = await refetch(); + + const childs = data.data?.results ?? []; + const newChilds: TreeViewDataType[] = childs.map( + (child) => ({ + ...child, + childrenCount: child.numchild, + children: [], + parentId: node.id, + }), + ); + node.data.children = newChilds; + updateNode(node.id, { ...node.data, children: newChilds }); + return newChilds; + }; + + const afterDelete = () => { + removeNode(node.data.id); + if (node.data.parentId) { + router.push(`/docs/${node.data.parentId}`); + refreshNode(node.data.parentId); + setSelectedNode(node.data); + } + }; + + const options = [ + { + label: t('Rename'), + icon: 'edit', + callback: renameModal.open, + }, + { + label: t('Share'), + icon: 'group', + callback: shareModal.open, + }, + { + label: t('Delete'), + icon: 'delete', + callback: deleteModal.open, + }, + ]; + return ( + + router.push(`/docs/${node.data.id}`)} + node={node} + {...props} + loadChildren={loadChildren} + > + + + + + { + e.stopPropagation(); + e.preventDefault(); + createChildrenDoc({ + title: t('Untitled page'), + parentId: node.id, + }); + }} + color="primary-text" + > + + +
+ } + /> + + {deleteModal.isOpen && ( + + )} + {shareModal.isOpen && ( + + )} + {renameModal.isOpen && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/stores/useDocRootTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/stores/useDocRootTree.tsx new file mode 100644 index 000000000..b43817c2b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/stores/useDocRootTree.tsx @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +import { Doc } from '@/features/docs/doc-management'; + +export interface DocRootTreeStore { + rootId?: Doc['id']; + setRootId: (id?: Doc['id']) => void; +} + +export const useDocRootTreeStore = create((set) => ({ + rootId: undefined, + setRootId: (id?: string) => { + set({ rootId: id }); + }, +})); diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/types/tree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/types/tree.tsx new file mode 100644 index 000000000..2ea9c5b75 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/types/tree.tsx @@ -0,0 +1,22 @@ +export type BaseType = T & { + id: string; + childrenCount?: number; + parentId?: string; + children?: BaseType[]; +}; + +export type TreeViewDataType = BaseType; + +export enum TreeViewMoveModeEnum { + FIRST_CHILD = 'first-child', + LAST_CHILD = 'last-child', + LEFT = 'left', + RIGHT = 'right', +} + +export type TreeViewMoveResult = { + targetNodeId: string; + mode: TreeViewMoveModeEnum; + sourceNodeId: string; + oldParentId?: string; +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/assets/doc-s.svg b/src/frontend/apps/impress/src/features/docs/docs-grid/assets/doc-s.svg new file mode 100644 index 000000000..790684c6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/assets/doc-s.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx index 8727602d0..3f169d6c3 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx @@ -11,7 +11,7 @@ type Props = { }; export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => { const { t } = useTranslation(); - const sharedCount = doc.nb_accesses; + const sharedCount = doc.nb_accesses_direct; const isShared = sharedCount - 1 > 0; if (!isShared) { diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/LightDocItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/LightDocItem.tsx new file mode 100644 index 000000000..fa429a286 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/LightDocItem.tsx @@ -0,0 +1,86 @@ +import { ReactNode } from 'react'; +import { css } from 'styled-components'; + +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { Doc } from '@/features/docs/doc-management'; + +import Logo from './../assets/doc-s.svg'; + +const ItemTextCss = css` + overflow: hidden; + text-overflow: ellipsis; + white-space: initial; + display: -webkit-box; + line-clamp: 1; + /* width: 100%; */ + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +`; + +type Props = { + doc: Doc; + showActions?: boolean; + rightContent?: ReactNode; +}; + +export const LightDocItem = ({ + doc, + rightContent, + showActions = false, +}: Props) => { + const { spacingsTokens } = useCunninghamTheme(); + const spacing = spacingsTokens(); + return ( + + + + + + + + {doc.title} + + {doc.nb_accesses_direct > 1 && ( + + )} + + {rightContent && ( + + {rightContent} + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/left-panel/assets/doc-s.svg b/src/frontend/apps/impress/src/features/left-panel/assets/doc-s.svg new file mode 100644 index 000000000..790684c6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/left-panel/assets/doc-s.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/left-panel/assets/doc-tree-logo.png b/src/frontend/apps/impress/src/features/left-panel/assets/doc-tree-logo.png new file mode 100644 index 000000000..d816d8f12 Binary files /dev/null and b/src/frontend/apps/impress/src/features/left-panel/assets/doc-tree-logo.png differ diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx index d0eb72922..ce74c17fe 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx @@ -45,10 +45,9 @@ export const LeftPanel = () => { data-testid="left-panel-desktop" $css={` height: calc(100vh - ${HEADER_HEIGHT}px); - width: 300px; + width: 100%; min-width: 300px; overflow: hidden; - border-right: 1px solid ${colors['greyscale-200']}; `} > { - const { currentDoc } = useDocStore(); - const { spacingsTokens } = useCunninghamTheme(); - const spacing = spacingsTokens(); + // const { rootId } = useDocRootTreeStore(); + const { currentDoc, setCurrentDoc } = useDocStore(); + const { reset, initialNode } = useTreeStore(); + + useEffect(() => { + return () => { + setCurrentDoc(undefined); + reset(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (!currentDoc) { return null; } @@ -19,19 +28,8 @@ export const LeftPanelDocContent = () => { $width="100%" $css="width: 100%; overflow-y: auto; overflow-x: hidden;" > - - - - - - - + {initialNode?.id} + {initialNode && } ); }; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx index 9a184ab59..b99723a55 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx @@ -1,37 +1,66 @@ import { Button, ModalSize, useModal } from '@openfun/cunningham-react'; import { t } from 'i18next'; -import { useRouter } from 'next/navigation'; +import { useRouter } from 'next/router'; import { PropsWithChildren } from 'react'; import { Box, Icon, SeparatedSection } from '@/components'; +import { useTreeStore } from '@/components/common/tree/treeStore'; import { useAuth } from '@/features/auth'; -import { useCreateDoc } from '@/features/docs/doc-management'; +import { useCreateDoc, useDocStore } from '@/features/docs/doc-management'; import { DocSearchModal } from '@/features/docs/doc-search'; -import { useCmdK } from '@/hook/useCmdK'; +import { DocSearchTarget } from '@/features/docs/doc-search/components/DocSearchFilters'; +import { useCreateChildrenDoc } from '@/features/docs/doc-tree/api/useCreateChildren'; import { useLeftPanelStore } from '../stores'; -export const LeftPanelHeader = ({ children }: PropsWithChildren) => { +type Props = PropsWithChildren<{}>; + +export const LeftPanelHeader = ({ children }: Props) => { const router = useRouter(); + const { currentDoc } = useDocStore(); + const treeStore = useTreeStore(); + const isDoc = router.pathname === '/docs/[id]'; + const searchModal = useModal(); const { authenticated } = useAuth(); - useCmdK(searchModal.open); const { togglePanel } = useLeftPanelStore(); const { mutate: createDoc } = useCreateDoc({ onSuccess: (doc) => { - router.push(`/docs/${doc.id}`); + void router.push(`/docs/${doc.id}`); + treeStore.setSelectedNode(doc); + togglePanel(); + }, + }); + + const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + onSuccess: (doc) => { + if (treeStore.rootId === currentDoc?.id) { + treeStore.addRootNode(doc); + } else if (currentDoc) { + treeStore.addChildNode(currentDoc.id, doc); + } else { + treeStore.addRootNode(doc); + } + togglePanel(); }, }); const goToHome = () => { - router.push('/'); + void router.push('/'); togglePanel(); }; const createNewDoc = () => { - createDoc(); + if (currentDoc) { + createChildrenDoc({ + title: t('Untitled page'), + parentId: currentDoc.id, + }); + } else { + createDoc(); + } }; return ( @@ -66,14 +95,27 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { )}
{authenticated && ( - + )} {children} {searchModal.isOpen && ( - + )} ); diff --git a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts index 875bb87c4..e78bcc520 100644 --- a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts @@ -190,7 +190,7 @@ export class ApiPlugin implements WorkboxPlugin { created_at: new Date().toISOString(), creator: 'dummy-id', is_favorite: false, - nb_accesses: 1, + nb_accesses_direct: 1, updated_at: new Date().toISOString(), abilities: { accesses_manage: true, diff --git a/src/frontend/apps/impress/src/layouts/MainLayout.tsx b/src/frontend/apps/impress/src/layouts/MainLayout.tsx index 3d55315b0..e7804ec8d 100644 --- a/src/frontend/apps/impress/src/layouts/MainLayout.tsx +++ b/src/frontend/apps/impress/src/layouts/MainLayout.tsx @@ -1,4 +1,5 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, useEffect, useState } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { css } from 'styled-components'; import { Box } from '@/components'; @@ -11,14 +12,56 @@ import { useResponsiveStore } from '@/stores'; type MainLayoutProps = { backgroundColor?: 'white' | 'grey'; + enableResize?: boolean; +}; + +const calculateDefaultSize = (targetWidth: number, isDesktop: boolean) => { + if (!isDesktop) { + return 0; + } + const windowWidth = window.innerWidth; + return (targetWidth / windowWidth) * 100; }; export function MainLayout({ children, backgroundColor = 'white', + enableResize = false, }: PropsWithChildren) { + const windowWidth = window.innerWidth; const { isDesktop } = useResponsiveStore(); const { colorsTokens } = useCunninghamTheme(); + + const [minPanelSize, setMinPanelSize] = useState( + calculateDefaultSize(300, isDesktop), + ); + const [maxPanelSize, setMaxPanelSize] = useState( + calculateDefaultSize(450, isDesktop), + ); + + useEffect(() => { + const updatePanelSize = () => { + const min = calculateDefaultSize(300, isDesktop); + const max = Math.min(calculateDefaultSize(450, isDesktop), 40); + setMinPanelSize(isDesktop ? min : 0); + if (enableResize) { + setMaxPanelSize(max); + } else { + setMaxPanelSize(min); + } + }; + + updatePanelSize(); + window.addEventListener('resize', () => { + console.log('resize'); + updatePanelSize(); + }); + + return () => { + window.removeEventListener('resize', updatePanelSize); + }; + }, [isDesktop, enableResize]); + const colors = colorsTokens(); const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor; @@ -30,29 +73,45 @@ export function MainLayout({ $margin={{ top: `${HEADER_HEIGHT}px` }} $width="100%" > - - - {children} - + + + + + + + + {children} + + +
); diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 1462c16a4..26025a34e 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { Box, Text, TextErrors } from '@/components'; +import { useTreeStore } from '@/components/common/tree/treeStore'; import { setAuthUrl } from '@/features/auth'; import { DocEditor } from '@/features/docs/doc-editor'; import { @@ -33,7 +34,7 @@ export function DocLayout() { - + @@ -60,6 +61,8 @@ const DocPage = ({ id }: DocProps) => { const [doc, setDoc] = useState(); const { setCurrentDoc } = useDocStore(); + const { initialNode, setInitialNode, reset } = useTreeStore(); + const { addTask } = useBroadcastStore(); const queryClient = useQueryClient(); const { replace } = useRouter(); @@ -73,6 +76,14 @@ const DocPage = ({ id }: DocProps) => { } }, [doc?.title]); + useEffect(() => { + return () => { + reset(); + setCurrentDoc(undefined); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (!docQuery || isFetching) { return; @@ -80,7 +91,10 @@ const DocPage = ({ id }: DocProps) => { setDoc(docQuery); setCurrentDoc(docQuery); - }, [docQuery, setCurrentDoc, isFetching]); + if (!initialNode) { + setInitialNode(docQuery); + } + }, [docQuery, setCurrentDoc, setInitialNode, initialNode, isFetching]); /** * We add a broadcast task to reset the query cache diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/test.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/test.tsx new file mode 100644 index 000000000..681b639fa --- /dev/null +++ b/src/frontend/apps/impress/src/pages/docs/[id]/test.tsx @@ -0,0 +1,37 @@ +import { TreeViewDataType } from '@/features/docs/doc-tree/types/tree'; +import { + DataType, + LeftPanelDocContent, +} from '@/features/left-panel/components/LeftPanelDocContent'; + +const initialData: TreeViewDataType[] = [ + { id: 'Noeud #1', name: 'Noeud #1', children: [] }, + { id: 'Noeud #2', name: 'Noeud #2', children: [] }, + { + id: 'Noeud #3', + name: 'Noeud #3', + childrenCount: 0, + children: [], + }, + { + id: 'Noeud #4', + name: 'Noeud #4', + children: [ + { id: 'Noeud #4.1', name: 'Noeud #4.1' }, + { id: 'Noeud #4.2', name: 'Noeud #4.2' }, + { id: 'Noeud #4.3', name: 'Noeud #4.3' }, + ], + }, + { id: 'Noeud #5', name: 'Noeud #5', children: [] }, + { id: 'Noeud #6', name: 'Noeud #6', children: [] }, + { id: 'Noeud #7', name: 'Noeud #7', children: [] }, + { + id: 'Noeud #8 fjdsk nfjksdn fjksd nfjdks nkjfsdn fjkds', + name: 'Noeud #8 hfi sfd hjk sd shjf bdsjhs fbdjhfsdbj kj bq', + children: [], + }, +]; + +export default function Test() { + return ; +} diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index 930d8359e..a883cb10d 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -1,4 +1,7 @@ @import url('../cunningham/cunningham-style.css'); +@import url("@fontsource/material-icons"); + + body { margin: 0; @@ -41,3 +44,26 @@ main ::-webkit-scrollbar-thumb:hover, cursor: pointer; outline: inherit; } + +.material-icons-filled { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; /* Preferred icon size */ + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + + /* Support for all WebKit browsers. */ + -webkit-font-smoothing: antialiased; + /* Support for Safari and Chrome. */ + text-rendering: optimizeLegibility; + /* Support for Firefox. */ + -moz-osx-font-smoothing: grayscale; + /* Support for IE. */ + font-feature-settings: 'liga'; +} diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 38330b156..0c5478107 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -969,6 +969,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.9.2": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433" + integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.25.9", "@babel/template@^7.3.3": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016" @@ -1482,6 +1489,11 @@ resolved "https://registry.yarnpkg.com/@fontsource/material-icons-outlined/-/material-icons-outlined-5.0.13.tgz#f8f2a669cb5bdc45fb3ca41f057bc149e3484695" integrity sha512-mQxKJcFiwclTJd0G5fUg0gJ/ZszdaZRSIMFkvbPvMUteA9aSFzFswHr9Yuacw+x4wlBl7GlsVCujAPaBeLv/dw== +"@fontsource/material-icons@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@fontsource/material-icons/-/material-icons-5.1.1.tgz#5b9b70766a8161af627b4c1b7c8e9b129747d0a2" + integrity sha512-l1EhBIh9US1RMiiKEJ+/FTFvHZIU3cpn0MmxSZA6Ip2C/Szwca6h2xqNg/OTIOFWADune7b/rhtypeDbjHrZzA== + "@formatjs/ecma402-abstract@2.3.2": version "2.3.2" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz#0ee291effe7ee2c340742a6c95d92eacb5e6c00a" @@ -1988,6 +2000,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@juggle/resize-observer@^3.3.1": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@keyv/serialize@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.0.2.tgz#72507c4be94d8914434a4aa80661f8ac6131967f" @@ -2439,6 +2456,95 @@ dependencies: "@opentelemetry/core" "^1.1.0" +"@parcel/watcher-android-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" + integrity sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA== + +"@parcel/watcher-darwin-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz#3d26dce38de6590ef79c47ec2c55793c06ad4f67" + integrity sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw== + +"@parcel/watcher-darwin-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz#99f3af3869069ccf774e4ddfccf7e64fd2311ef8" + integrity sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg== + +"@parcel/watcher-freebsd-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz#14d6857741a9f51dfe51d5b08b7c8afdbc73ad9b" + integrity sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ== + +"@parcel/watcher-linux-arm-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz#43c3246d6892381db473bb4f663229ad20b609a1" + integrity sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA== + +"@parcel/watcher-linux-arm-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz#663750f7090bb6278d2210de643eb8a3f780d08e" + integrity sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q== + +"@parcel/watcher-linux-arm64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz#ba60e1f56977f7e47cd7e31ad65d15fdcbd07e30" + integrity sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w== + +"@parcel/watcher-linux-arm64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz#f7fbcdff2f04c526f96eac01f97419a6a99855d2" + integrity sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg== + +"@parcel/watcher-linux-x64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz#4d2ea0f633eb1917d83d483392ce6181b6a92e4e" + integrity sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A== + +"@parcel/watcher-linux-x64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz#277b346b05db54f55657301dd77bdf99d63606ee" + integrity sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg== + +"@parcel/watcher-win32-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz#7e9e02a26784d47503de1d10e8eab6cceb524243" + integrity sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw== + +"@parcel/watcher-win32-ia32@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz#2d0f94fa59a873cdc584bf7f6b1dc628ddf976e6" + integrity sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ== + +"@parcel/watcher-win32-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz#ae52693259664ba6f2228fa61d7ee44b64ea0947" + integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA== + +"@parcel/watcher@^2.4.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.1.tgz#342507a9cfaaf172479a882309def1e991fb1200" + integrity sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg== + dependencies: + detect-libc "^1.0.3" + is-glob "^4.0.3" + micromatch "^4.0.5" + node-addon-api "^7.0.0" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.5.1" + "@parcel/watcher-darwin-arm64" "2.5.1" + "@parcel/watcher-darwin-x64" "2.5.1" + "@parcel/watcher-freebsd-x64" "2.5.1" + "@parcel/watcher-linux-arm-glibc" "2.5.1" + "@parcel/watcher-linux-arm-musl" "2.5.1" + "@parcel/watcher-linux-arm64-glibc" "2.5.1" + "@parcel/watcher-linux-arm64-musl" "2.5.1" + "@parcel/watcher-linux-x64-glibc" "2.5.1" + "@parcel/watcher-linux-x64-musl" "2.5.1" + "@parcel/watcher-win32-arm64" "2.5.1" + "@parcel/watcher-win32-ia32" "2.5.1" + "@parcel/watcher-win32-x64" "2.5.1" + "@pkgr/core@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" @@ -3355,6 +3461,21 @@ "@react-types/shared" "^3.27.0" "@swc/helpers" "^0.5.0" +"@react-dnd/asap@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab" + integrity sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg== + +"@react-dnd/invariant@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-2.0.0.tgz#09d2e81cd39e0e767d7da62df9325860f24e517e" + integrity sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw== + +"@react-dnd/shallowequal@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a" + integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg== + "@react-pdf/fns@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@react-pdf/fns/-/fns-3.0.0.tgz#2e0137d48b14c531b2f6a9214cb36ea2a7aea3ba" @@ -5086,7 +5207,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@22.13.1", "@types/node@^22.7.5": +"@types/node@*", "@types/node@^22.7.5": version "22.13.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.1.tgz#a2a3fefbdeb7ba6b89f40371842162fac0934f33" integrity sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew== @@ -5148,7 +5269,7 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@*", "@types/react-dom@18.3.1": +"@types/react-dom@*": version "18.3.1" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.1.tgz#1e4654c08a9cdcfb6594c780ac59b55aad42fe07" integrity sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ== @@ -5293,7 +5414,7 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@*", "@typescript-eslint/eslint-plugin@8.23.0", "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0": +"@typescript-eslint/eslint-plugin@*", "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0": version "8.23.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz#7745f4e3e4a7ae5f6f73fefcd856fd6a074189b7" integrity sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w== @@ -5308,7 +5429,7 @@ natural-compare "^1.4.0" ts-api-utils "^2.0.1" -"@typescript-eslint/parser@*", "@typescript-eslint/parser@8.23.0", "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0": +"@typescript-eslint/parser@*", "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0": version "8.23.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.23.0.tgz#57acb3b65fce48d12b70d119436e145842a30081" integrity sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q== @@ -6313,6 +6434,13 @@ chokidar@^3.5.2, chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +chokidar@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + chromatic@11.7.1: version "11.7.1" resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-11.7.1.tgz#9de59dd9d0e2a847627bccd959f05881335b524e" @@ -6362,7 +6490,7 @@ clone@^2.1.2: resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== -clsx@^2.0.0, clsx@^2.1.1: +clsx@2.1.1, clsx@^2.0.0, clsx@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -6882,6 +7010,11 @@ destroy@1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + detect-libc@^2.0.2, detect-libc@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" @@ -6939,6 +7072,15 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +dnd-core@14.0.1: + version "14.0.1" + resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-14.0.1.tgz#76d000e41c494983210fb20a48b835f81a203c2e" + integrity sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A== + dependencies: + "@react-dnd/asap" "^4.0.0" + "@react-dnd/invariant" "^2.0.0" + redux "^4.1.1" + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -7529,7 +7671,7 @@ eslint-visitor-keys@^4.2.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== -eslint@*, eslint@8.57.0: +eslint@*: version "8.57.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668" integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ== @@ -8815,6 +8957,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== +immutable@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1" + integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw== + import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -10284,6 +10431,11 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" @@ -10619,7 +10771,7 @@ micromark@^3.0.0: micromark-util-types "^1.0.1" uvu "^0.5.0" -micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -10800,6 +10952,11 @@ node-abi@^3.61.0: dependencies: semver "^7.3.5" +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + node-ensure@^0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/node-ensure/-/node-ensure-0.0.0.tgz#ecae764150de99861ec5c810fd5d096b183932a7" @@ -11709,6 +11866,17 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +react-arborist@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/react-arborist/-/react-arborist-3.4.0.tgz#8ef3de2c81d3b8cea0f4f4575c1971bd80c556c5" + integrity sha512-QI46oRGXJr0oaQfqqVobIiIoqPp5Y5gM69D2A2P7uHVif+X75XWnScR5drC7YDKgJ4CXVaDeFwnYKOWRRfncMg== + dependencies: + react-dnd "^14.0.3" + react-dnd-html5-backend "^14.0.3" + react-window "^1.8.10" + redux "^5.0.0" + use-sync-external-store "^1.2.0" + react-aria-components@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/react-aria-components/-/react-aria-components-1.3.2.tgz#dee58665210330ec12843e6393ef5cc28ff9a9da" @@ -11871,6 +12039,24 @@ react-aria@^3.34.2, react-aria@^3.37.0: "@react-aria/visually-hidden" "^3.8.19" "@react-types/shared" "^3.27.0" +react-dnd-html5-backend@^14.0.3: + version "14.1.0" + resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz#b35a3a0c16dd3a2bfb5eb7ec62cf0c2cace8b62f" + integrity sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw== + dependencies: + dnd-core "14.0.1" + +react-dnd@^14.0.3: + version "14.0.5" + resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-14.0.5.tgz#ecf264e220ae62e35634d9b941502f3fca0185ed" + integrity sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A== + dependencies: + "@react-dnd/invariant" "^2.0.0" + "@react-dnd/shallowequal" "^2.0.0" + dnd-core "14.0.1" + fast-deep-equal "^3.1.3" + hoist-non-react-statics "^3.3.2" + react-dom@*, react-dom@18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" @@ -11956,6 +12142,11 @@ react-remove-scroll@^2.6.2: use-callback-ref "^1.3.3" use-sidecar "^1.1.3" +react-resizable-panels@^2.1.7: + version "2.1.7" + resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz#afd29d8a3d708786a9f95183a38803c89f13c2e7" + integrity sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA== + react-select@5.10.0: version "5.10.0" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.10.0.tgz#9b5f4544cfecdfc744184b87651468ee0fb6e172" @@ -12058,6 +12249,14 @@ react-transition-group@^4.3.0: loose-envify "^1.4.0" prop-types "^15.6.2" +react-window@^1.8.10: + version "1.8.11" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.11.tgz#a857b48fa85bd77042d59cc460964ff2e0648525" + integrity sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@*, react@18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" @@ -12087,6 +12286,11 @@ readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -12102,6 +12306,18 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux@^4.1.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" + integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w== + dependencies: + "@babel/runtime" "^7.9.2" + +redux@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.10" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" @@ -12498,6 +12714,17 @@ safe-regex-test@^1.0.3, safe-regex-test@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +sass@1.83.4: + version "1.83.4" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.83.4.tgz#5ccf60f43eb61eeec300b780b8dcb85f16eec6d1" + integrity sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA== + dependencies: + chokidar "^4.0.0" + immutable "^5.0.2" + source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" + sax@^1.2.4: version "1.4.1" resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" @@ -12806,7 +13033,7 @@ source-list-map@^2.0.0: resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== -source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.1: +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== @@ -13633,7 +13860,7 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" -typescript@*, typescript@5.7.3, typescript@^5.0.4: +typescript@*, typescript@^5.0.4: version "5.7.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== @@ -13911,6 +14138,13 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-resize-observer@9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c" + integrity sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow== + dependencies: + "@juggle/resize-observer" "^3.3.1" + use-sidecar@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb"