Compare commits

..

20 Commits

Author SHA1 Message Date
Nathan Panchout 8a981ad11f wip 2025-03-10 07:58:46 +01:00
Nathan Panchout 625258be76 wip 2025-03-04 10:39:47 +01:00
Nathan Panchout 8849470d05 wip 2025-02-27 08:50:50 +01:00
Nathan Panchout a571f9bc6e wip 2025-02-27 08:49:56 +01:00
Nathan Panchout 8bc56f0000 wip 2025-02-27 08:48:56 +01:00
Nathan Panchout 68b185e49a wip 2025-02-27 08:48:56 +01:00
Nathan Panchout 910983ece7 wip 2025-02-27 08:48:55 +01:00
Samuel Paccoud - DINUM 9f4c0d2737 🐛(backend) fix numchild when soft deleting/restoring a document
The numchild attribute must be incremented/decremented manually
when we soft delete a document if we want it to remain accurate,
which is important to display the tree structure in the frontend.
2025-02-26 20:01:22 +01:00
Samuel Paccoud - DINUM 668e61019f -backend) add number of direct accesses related to a document
The "nb_accesses" field was displaying the number of access instances
related to a document or any of its ancestors. Some features on the
frontend require to know how many of these access instances are related
to the document directly.
2025-02-26 20:01:22 +01:00
Samuel Paccoud - DINUM cc7d4adf65 (backend) limit link reach/role select options depending on ancestors
If a document already gets a link reach/role inheriting from one of its
ancestors, we should not propose setting link reach/role on the
document that would be more restrictive than what we inherited from
ancestors.
2025-02-26 20:01:22 +01:00
Samuel Paccoud - DINUM 9bf0fa80cd (backend) add new "descendants" action to document API endpoint
We want to be able to make a search query inside a hierchical document.
It's elegant to do it as a document detail action so that we benefit
from access control.
2025-02-26 20:01:21 +01:00
Samuel Paccoud - DINUM 80ee5b1356 ♻️(backend) refactor list view to allow filtering other views
the "filter_queryset" method is called in the middle of the
"get_object" method. We use the "get_object" in actions like
"children", "tree", etc. which start by calling "get_object"
but return lists of documents.

We would like to apply filters to these views but the it didn't
work because the "get_object" method was also impacted by the
filters...

In a future PR, we should take control of the "get_object" method
and decouple all this. We need a quick solution to allow releasing
the hierchical documents feature in the frontend.
2025-02-26 20:00:29 +01:00
Samuel Paccoud - DINUM 13dd86e8d5 fixup! (backend) add "tree" action on document API endpoint 2025-02-26 20:00:29 +01:00
Samuel Paccoud - DINUM 7f6e4cdeff (backend) add "tree" action on document API endpoint
We want to display the tree structure to which a document belongs
on the left side panel of its detail view. For this, we need an
endpoint to retrieve the list view of the document's ancestors
opened.

By opened, we mean that when display the document, we also need to
display its siblings. When displaying the parent of the current
document, we also need to display the siblings of the parent...
2025-02-26 06:49:41 +01:00
Samuel Paccoud - DINUM a3fb229a4f (backend) allow forcing page size within limits
We want to be able to increase the page size with by passing
the query string parameter "page_size".
2025-02-26 06:49:05 +01:00
Anthony LC 54a75bc338 ♻️(frontend) update some elements
- add panel information when document is
authenticated
- add a copy link button in the toolbox
on the document
- fix when long title document
- modals fit design
- mobile responsive changes
2025-02-24 10:49:20 +01:00
Anthony LC 50d098c777 💄(frontend) adapt language picker design
Adapt the language picker design to
to fit with the new design of the app.
2025-02-24 10:49:20 +01:00
Anthony LC 757c09b189 ♻️(frontend) adapt other error pages to new design
Adapt the other error pages to the new design.
2025-02-24 10:27:42 +01:00
Anthony LC 30c5cfab62 💄(frontend) add page 401
Add a 401 page when user try to access a
doc that need authentication.
2025-02-24 10:27:42 +01:00
Anthony LC f069329e18 💄(frontend) add page 403
Add a page and integrate the design for the
403 error page.
2025-02-24 10:27:42 +01:00
106 changed files with 5520 additions and 621 deletions
+10 -1
View File
@@ -8,16 +8,25 @@ and this project adheres to
## [Unreleased]
## 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
- 🛂(frontend) Restore version visibility #629
- 📝(doc) minor README.md formatting and wording enhancements
- ♻️Stop setting a default title on doc creation #634
- 📝(readme) remove front-end local run instructions local.md #651
- ♻️(frontend) misc ui improvements #644
## Fixed
- ♻️(frontend) improve table pdf rendering
- 🐛(backend) refactor to fix filtering on children and descendants views #645
## [2.2.0] - 2025-02-10
+21 -1
View File
@@ -40,7 +40,7 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion and Outline.
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
## Getting started 🔧
@@ -100,6 +100,26 @@ password: impress
$ make run
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
```
And run the frontend locally in development mode with the following command:
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
```
**Adding content**
You can create a basic demo site by running:
-92
View File
@@ -1,92 +0,0 @@
# Run Docs locally
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose version
Docker Compose version v2.32.4
```
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to <http://localhost:3000>.
You will be prompted to log in, the default credentials are:
```
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
```
**Adding content**
You can create a basic demo site by running:
```shellscript
$ make demo
```
Finally, you can check all available Make rules using:
```shellscript
$ make help
```
**Django admin**
You can access the Django admin site at
<http://localhost:8071/admin>.
You first need to create a superuser account:
```shellscript
$ make superuser
```
## Front-end dev instructions
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
```
And run the frontend locally in development mode with the following command:
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
```
+14 -3
View File
@@ -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
+40 -22
View File
@@ -128,26 +128,14 @@ class TemplateAccessSerializer(BaseAccessSerializer):
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 ListDocumentSerializer(BaseResourceSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"title",
@@ -178,13 +167,30 @@ class ListDocumentSerializer(BaseResourceSerializer):
"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 DocumentSerializer(ListDocumentSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"title",
@@ -230,7 +237,8 @@ class DocumentSerializer(ListDocumentSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"updated_at",
@@ -359,7 +367,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
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 @@ class FileUploadSerializer(serializers.Serializer):
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 TemplateSerializer(BaseResourceSerializer):
]
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):
+29
View File
@@ -11,6 +11,35 @@ import botocore
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.
+148 -40
View File
@@ -20,7 +20,6 @@ from django.http import Http404
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.ai_services import AIService
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 @@ class DocumentViewSet(
)
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 @@ class DocumentViewSet(
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 @@ class DocumentViewSet(
)
# Apply ordering only now that everyting is filtered and annotated
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
queryset = 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
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 @@ class DocumentViewSet(
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 @@ class DocumentViewSet(
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 @@ class DocumentViewSet(
)
# 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):
"""
+173 -41
View File
@@ -6,6 +6,7 @@ Declare and configure the models for the impress core application
import hashlib
import smtplib
import uuid
from collections import defaultdict
from datetime import timedelta
from logging import getLogger
@@ -29,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
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 @@ class BaseAccess(BaseModel):
}
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 @@ class Document(MP_Node, BaseModel):
"""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 @@ class Document(MP_Node, BaseModel):
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"]
)
links_definitions = defaultdict(set)
links_definitions[self.link_reach].add(self.link_role)
return links_definitions
# Merge ancestor link definitions
for ancestor in ancestors_links:
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
def get_abilities(self, user):
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 @@ class Document(MP_Node, BaseModel):
) 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 @@ class Document(MP_Node, BaseModel):
"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 @@ class Document(MP_Node, BaseModel):
"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 @@ class Document(MP_Node, BaseModel):
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:
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(
"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():
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 @@ class Document(MP_Node, BaseModel):
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 @@ class Document(MP_Node, BaseModel):
)
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 = (
@@ -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
@@ -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 @@ pytestmark = pytest.mark.django_db
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"),
@@ -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],
},
],
}
@@ -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()
@@ -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
@@ -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):
@@ -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):
File diff suppressed because it is too large Load Diff
@@ -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",
]:
@@ -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
+397 -23
View File
@@ -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
@@ -17,13 +17,13 @@ test.describe('404', () => {
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible();
await expect(page.getByText('Back to home page')).toBeVisible();
await expect(page.getByText('Home')).toBeVisible();
});
test('checks go back to home page redirects to home page', async ({
page,
}) => {
await page.getByText('Back to home page').click();
await page.getByText('Home').click();
await expect(page).toHaveURL('/');
});
});
@@ -97,7 +97,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -37,10 +37,10 @@ test.describe('Doc Editor', () => {
// Change language to French
await header.click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
header.getByRole('button').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
@@ -130,7 +130,7 @@ test.describe('Doc Editor', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -7,17 +7,9 @@ type SmallDoc = {
title: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
@@ -94,6 +86,10 @@ test.describe('Documents Grid mobile', () => {
});
test.describe('Document grid item options', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
@@ -212,6 +208,8 @@ test.describe('Document grid item options', () => {
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
await page.goto('/');
// All Docs
const response = await page.waitForResponse(
(response) =>
@@ -282,11 +280,9 @@ test.describe('Documents filters', () => {
});
test.describe('Documents Grid', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
await page.goto('/');
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
@@ -314,11 +310,12 @@ test.describe('Documents Grid', () => {
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse(
(response) =>
const responsePromisePage1 = page.waitForResponse((response) => {
return (
response.url().endsWith(`/documents/?page=1`) &&
response.status() === 200,
);
response.status() === 200
);
});
const responsePromisePage2 = page.waitForResponse(
(response) =>
@@ -326,6 +323,8 @@ test.describe('Documents Grid', () => {
response.status() === 200,
);
await page.goto('/');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();
@@ -89,7 +89,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Delete document',
})
.click();
@@ -153,7 +153,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Delete document' }),
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -177,7 +177,7 @@ test.describe('Doc Header', () => {
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('button', {
page.getByRole('menuitem', {
name: 'delete',
}),
).toBeEnabled();
@@ -195,7 +195,7 @@ test.describe('Doc Header', () => {
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('button', {
page.getByRole('menuitem', {
name: 'delete',
}),
).toBeEnabled();
@@ -233,7 +233,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Delete document' }),
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -295,7 +295,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Delete document' }),
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -352,7 +352,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Copy as Markdown' }).click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -387,7 +387,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Copy as HTML' }).click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -460,7 +460,7 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
@@ -494,7 +494,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -65,15 +65,15 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('button', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Administrator' }),
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -121,7 +121,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Owner' }).click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -139,7 +139,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Owner' }).click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -162,8 +162,8 @@ test.describe('Document create member', () => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'translate Français' }).click();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
@@ -178,7 +178,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrateur' }).click();
await page.getByRole('menuitem', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -212,7 +212,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -232,14 +232,14 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Reader' }).click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});
@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
await list.click();
await currentUserRole.click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('button', { name: 'Reader' }).click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -215,13 +215,13 @@ test.describe('Document list members', () => {
await expect(mySelfMoreActions).toBeVisible();
await userReaderMoreActions.click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(
page.getByText('You do not have permission to perform this action.'),
page.getByText('You do not have permission to view this document.'),
).toBeVisible();
});
});
@@ -19,7 +19,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Version history',
})
.click();
@@ -59,7 +59,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Version history',
})
.click();
@@ -91,7 +91,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
});
@@ -120,7 +120,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Version history',
})
.click();
@@ -50,7 +50,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -60,7 +60,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Public',
})
.click();
@@ -100,7 +100,9 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
@@ -133,7 +135,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('You do not have permission to perform this action.'),
page.getByText('You do not have permission to view this document.'),
).toBeVisible();
});
@@ -160,7 +162,7 @@ test.describe('Doc Visibility: Restricted', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -213,7 +215,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Public',
})
.click();
@@ -225,7 +227,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Reading',
})
.click();
@@ -287,7 +289,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Public',
})
.click();
@@ -355,7 +357,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -379,7 +381,10 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
@@ -402,7 +407,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -411,6 +416,14 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Document accessible to any connected person', {
exact: true,
}),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const urlDoc = page.url();
@@ -456,7 +469,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -12,7 +12,7 @@ test.describe('Home page', () => {
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(
header.getByRole('combobox', { name: 'Language' }),
header.getByRole('button', { name: /Language/ }),
).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
@@ -9,19 +9,20 @@ test.describe('Language', () => {
await expect(page.getByLabel('Logout')).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
header.getByRole('button').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await header.getByRole('button').getByText('Français').click();
await page.getByRole('menuitem', { name: 'Deutsch' }).click();
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
@@ -53,8 +54,11 @@ test.describe('Language', () => {
// Switch language to French
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
// Check for French 404 response
await check404Response('Pas trouvé.');
@@ -29,7 +29,7 @@ test.describe('Left panel mobile', () => {
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const languageButton = page.getByRole('button', { name: /Language/ });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
+2
View File
@@ -1,3 +1,4 @@
const config = {
themes: {
default: {
@@ -381,6 +382,7 @@ const config = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: {
+6
View File
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

@@ -8,17 +8,20 @@ import {
import { Button, Popover } from 'react-aria-components';
import styled from 'styled-components';
import { BoxProps } from './Box';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd;
transition: opacity 0.2s ease-in-out;
`;
const StyledButton = styled(Button)`
interface StyledButtonProps {
$css?: BoxProps['$css'];
}
const StyledButton = styled(Button)<StyledButtonProps>`
cursor: pointer;
border: none;
background: none;
@@ -29,10 +32,12 @@ const StyledButton = styled(Button)`
font-size: 0.938rem;
padding: 0;
text-wrap: nowrap;
${({ $css }) => $css};
`;
export interface DropButtonProps {
button: ReactNode;
buttonCss?: BoxProps['$css'];
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
@@ -40,6 +45,7 @@ export interface DropButtonProps {
export const DropButton = ({
button,
buttonCss,
isOpen = false,
onOpenChange,
children,
@@ -58,12 +64,24 @@ export const DropButton = ({
onOpenChange?.(isOpen);
};
const props = {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
onOpenChangeHandler(!isLocalOpen);
},
};
return (
<>
<StyledButton
// {...props}
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
onPress={(e) => {
onOpenChangeHandler(!isLocalOpen);
}}
aria-label={label}
$css={buttonCss}
>
{button}
</StyledButton>
@@ -1,4 +1,4 @@
import { PropsWithChildren, useState } from 'react';
import { PropsWithChildren, useRef, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -8,6 +8,7 @@ export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
isSelected?: boolean;
@@ -20,8 +21,11 @@ export type DropdownMenuProps = {
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
buttonCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
selectedValues?: string[];
afterOpenChange?: (isOpen: boolean) => void;
};
export const DropdownMenu = ({
@@ -30,16 +34,21 @@ export const DropdownMenu = ({
disabled = false,
showArrow = false,
arrowCss,
buttonCss,
label,
topMessage,
afterOpenChange,
selectedValues,
}: PropsWithChildren<DropdownMenuProps>) => {
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const blockButtonRef = useRef<HTMLDivElement>(null);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
afterOpenChange?.(isOpen);
};
if (disabled) {
@@ -51,10 +60,17 @@ export const DropdownMenu = ({
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
button={
showArrow ? (
<Box $direction="row" $align="center">
<div>{children}</div>
<Box
ref={blockButtonRef}
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Icon
$variation="600"
$css={
@@ -67,11 +83,17 @@ export const DropdownMenu = ({
/>
</Box>
) : (
children
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
)
}
>
<Box $maxWidth="320px">
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu"
>
{topMessage && (
<Text
$variation="700"
@@ -90,6 +112,7 @@ export const DropdownMenu = ({
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
@@ -144,7 +167,8 @@ export const DropdownMenu = ({
{option.label}
</Text>
</Box>
{option.isSelected && (
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
</BoxButton>
@@ -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 (
<Text $isMaterialIcon {...textProps}>
<Text
$isMaterialIcon={!isFilled}
{...textProps}
className={
isFilled
? `material-icons-filled ${textProps.className}`
: textProps.className
}
>
{iconName}
</Text>
);
@@ -25,7 +25,9 @@ export interface TextProps extends BoxProps {
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'
@@ -0,0 +1,9 @@
import styles from './loader.module.scss';
interface LoaderProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export const Loader = ({ size = 'sm' }: LoaderProps) => {
return <div className={[styles.loader, styles[size]].join(' ')} />;
};
@@ -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);
}
}
@@ -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<T> = {
treeData: TreeViewDataType<T>[];
width?: number | string;
selectedNodeId?: string;
rootNodeId: string;
initialOpenState?: OpenMap;
renderNode: (
props: NodeRendererProps<TreeViewDataType<T>>,
) => React.ReactNode;
afterMove?: (
result: TreeViewMoveResult,
newTreeData: TreeViewDataType<T>[],
) => void;
};
export const TreeView = <T,>({
treeData,
width,
rootNodeId,
renderNode,
afterMove,
selectedNodeId,
initialOpenState,
}: TreeViewProps<T>) => {
const onMove3 = (args: {
dragIds: string[];
dragNodes: NodeApi<BaseType<T>>[];
parentId: string | null;
parentNode: NodeApi<BaseType<T>> | 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<BaseType<T>>[];
parentId: string | null;
parentNode: NodeApi<BaseType<T>> | null;
index: number;
}) => {
// Création d'une copie profonde pour éviter les mutations directes
const newData = JSON.parse(
JSON.stringify(treeData),
) as TreeViewDataType<T>[];
const draggedId = args.dragIds[0];
// Fonction helper pour trouver et supprimer un nœud dans l'arbre
const findAndRemoveNode = (
items: TreeViewDataType<T>[],
parentId?: string,
): {
currentIndex: number;
newIndex: number;
parentId?: string;
draggedNode: TreeViewDataType<T>;
} | 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<T>[]) => {
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 (
<Tree
data={treeData}
openByDefault={false}
height={1000}
indent={20}
width={width}
initialOpenState={initialOpenState}
selection={selectedNodeId}
disableEdit={true}
selectionFollowsFocus={true}
disableMultiSelection={true}
rowHeight={32}
overscanCount={20}
padding={25}
renderCursor={Cursor}
onMove={onMove as MoveHandler<TreeViewDataType<T>>}
>
{(props) => renderNode(props)}
</Tree>
);
};
function Cursor({ top, left }: CursorProps) {
return (
<div
className={styles.cursor}
style={{
top,
left: left + 10,
}}
></div>
);
}
export type TreeViewNodeProps<T> = NodeRendererProps<TreeViewDataType<T>> & {
children: ReactNode;
onClick?: () => void;
loadChildren?: (node?: TreeViewDataType<T>) => Promise<TreeViewDataType<T>[]>;
};
export const TreeViewNode = <T,>({
children,
onClick,
node,
dragHandle,
style,
loadChildren,
}: TreeViewNodeProps<T>) => {
/* This node instance can do many things. See the API reference. */
const timeoutRef = useRef<NodeJS.Timeout | null>(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 (
<div
onClick={onClick}
onKeyDown={onClick}
role="button"
tabIndex={0}
className={clsx(styles.node, {
[styles.willReceiveDrop]: node.willReceiveDrop,
[styles.selected]: node.isSelected,
toto: true,
})}
style={{
...style,
}}
ref={dragHandle}
>
{isLeaf ? (
<Box $padding={{ left: '16px' }} />
) : (
<>
{isLoading ? (
<Box>
<Loader />
</Box>
) : (
<Icon
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
void handleClick();
}}
$variation="500"
$size="16px"
iconName={
node.isOpen ? 'keyboard_arrow_down' : 'keyboard_arrow_right'
}
/>
)}
</>
)}
{children}
</div>
);
};
@@ -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<T> {
treeData: TreeViewDataType<T>[];
selectedNode: TreeViewDataType<T> | null;
rootId: string | undefined;
initialNode: TreeViewDataType<T> | undefined;
setInitialNode: (node: TreeViewDataType<T> | undefined) => void;
setSelectedNode: (node: TreeViewDataType<T> | null) => void;
setTreeData: (data: TreeViewDataType<T>[]) => void;
updateNode: (nodeId: string, newData: Partial<TreeViewDataType<T>>) => void;
addRootNode: (node: TreeViewDataType<T>) => void;
removeNode: (nodeId: string) => void;
addChildNode: (parentId: string, newNode: TreeViewDataType<T>) => void;
refreshNode: (nodeId: string) => void;
findNode: (nodeId: string) => TreeViewDataType<T> | null;
setRootId: (id?: string) => void;
reset: (
rootId?: string,
treeData?: TreeViewDataType<T>[],
selectedNode?: TreeViewDataType<T> | null,
) => void;
}
export const createTreeStore = <T>(
refreshCallback?: (id: string) => Promise<Partial<TreeViewDataType<T>>>,
) =>
create<TreeStore<T>>((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<T>[],
): TreeViewDataType<T>[] => {
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<T>[],
): TreeViewDataType<T>[] => {
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<T>[],
): TreeViewDataType<T>[] => {
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<T>[],
): TreeViewDataType<T> | 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<Doc>(async (docId) => {
const doc = await getDoc({ id: docId });
return { ...doc, childrenCount: doc.numchild };
});
@@ -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);
}
@@ -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 (
<DropdownMenu
selectedValues={selectedValue ? [selectedValue] : undefined}
options={options}
>
<Box
$css={css`
border: 1px solid
${selectedOption
? 'var(--c--theme--colors--primary-500)'
: 'var(--c--theme--colors--greyscale-250)'};
border-radius: 4px;
background-color: ${selectedOption
? 'var(--c--theme--colors--primary-100)'
: 'var(--c--theme--colors--greyscale-000)'};
gap: var(--c--theme--spacings--2xs);
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
`}
color="secondary"
$direction="row"
$align="center"
>
<Text
$weight={400}
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
>
{selectedOption?.label ?? options[0].label}
</Text>
<Icon
$size="16px"
iconName="keyboard_arrow_down"
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
/>
</Box>
</DropdownMenu>
);
};
@@ -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 (
<>
<QuickSearchStyle />
<div className="quick-search-container">
<div
className="quick-search-container"
onKeyDown={(e) => {
e.stopPropagation();
}}
>
<Command label={label} shouldFilter={false} ref={ref}>
{showInput && (
<QuickSearchInput
@@ -57,6 +57,9 @@ export const QuickSearchInput = ({
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus={true}
aria-label={t('Quick search input')}
onClick={(e) => {
e.stopPropagation();
}}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
@@ -41,7 +41,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<ConfigProvider>
<Auth>{children}</Auth>
<Auth>
{children}
<div id="modals" />
</Auth>
</ConfigProvider>
</CunninghamProvider>
</QueryClientProvider>
@@ -406,6 +406,10 @@ input:-webkit-autofill:focus {
);
}
.c__button--primary-text {
color: var(--c--components--button--primary-text--color);
}
.c__button--primary-text:hover,
.c__button--primary-text:focus-visible {
background-color: var(
@@ -505,6 +505,9 @@
--c--components--button--primary-text--color-hover: var(
--c--theme--colors--primary-text
);
--c--components--button--primary-text--color: var(
--c--theme--colors--primary-800
);
--c--components--button--secondary--background--color-hover: #f6f6f6;
--c--components--button--secondary--background--color-active: #ededed;
--c--components--button--secondary--border--color: var(
@@ -505,6 +505,7 @@ export const tokens = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: { 'color-hover': '#F6F6F6', 'color-active': '#EDEDED' },
@@ -14,7 +14,11 @@ export const ButtonLogin = () => {
if (!authenticated) {
return (
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
<Button
onClick={() => gotoLogin()}
color="primary-text"
aria-label={t('Login')}
>
{t('Login')}
</Button>
);
@@ -32,7 +36,7 @@ export const ProConnectButton = () => {
return (
<BoxButton
onClick={gotoLogin}
onClick={() => gotoLogin()}
aria-label={t('Proconnect Login')}
$css={css`
background-color: var(--c--theme--colors--primary-text);
@@ -16,8 +16,11 @@ export const setAuthUrl = () => {
}
};
export const gotoLogin = () => {
setAuthUrl();
export const gotoLogin = (withRedirect = true) => {
if (withRedirect) {
setAuthUrl();
}
window.location.replace(LOGIN_URL);
};
@@ -76,13 +76,13 @@ export const cssEditor = (readonly: boolean) => css`
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
margin-top: 32px;
}
&:has(h2) {
padding-top: 24px;
margin-top: 24px;
}
&:has(h3) {
padding-top: 16px;
margin-top: 16px;
}
}
@@ -92,9 +92,16 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: 4px;
}
@media screen and (width <= 768px) {
& .bn-editor {
padding-right: 36px;
}
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
@@ -27,6 +27,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
const { t } = useTranslation();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const { transRole } = useTrans();
@@ -38,7 +39,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')}
>
{docIsPublic && (
{(docIsPublic || docIsAuth) && (
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
@@ -57,10 +58,12 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName="public"
iconName={docIsPublic ? 'public' : 'vpn_lock'}
/>
<Text $theme="primary" $variation="800">
{t('Public document')}
{docIsPublic
? t('Public document')
: t('Document accessible to any connected person')}
</Text>
</Box>
)}
@@ -76,8 +79,9 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
<Box $gap={spacings['3xs']}>
<Box $gap={spacings['3xs']} $overflow="auto">
<DocTitle doc={doc} />
<Box $direction="row">
@@ -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}`);
},
@@ -101,39 +104,35 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}, [doc]);
return (
<>
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
$color={colorsTokens()['greyscale-1000']}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
$color={colorsTokens()['greyscale-1000']}
$margin={{ left: '-2px', right: '10px' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
</>
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
);
};
@@ -18,7 +18,11 @@ import {
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import {
Doc,
ModalRemoveDoc,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -34,7 +38,7 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1;
const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -50,6 +54,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const options: DropdownMenuOption[] = [
...(isSmallMobile
@@ -66,6 +71,11 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
@@ -184,7 +194,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}}
size={isSmallMobile ? 'small' : 'medium'}
>
{doc.nb_accesses}
{doc.nb_accesses_direct}
</Button>
</Box>
)}
@@ -28,6 +28,7 @@ export function useDoc(
return useQuery<Doc, APIError, Doc>({
queryKey: [KEY_DOC, param],
queryFn: () => getDoc(param),
...queryConfig,
});
}
@@ -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<Doc>;
@@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
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));
@@ -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 {
@@ -71,9 +80,15 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
</Button>
</>
}
size={ModalSize.MEDIUM}
size={ModalSize.SMALL}
title={
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
{t('Delete a doc')}
</Text>
}
@@ -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 (
<Modal
closeOnClickOutside
title={
<Text $size="h6" $margin={{ all: '0' }} $align="flex-start">
{t('Rename')}
</Text>
}
isOpen
onClose={onClose}
size={ModalSize.SMALL}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
<Button aria-label={t('Confirm rename')} fullWidth onClick={onRename}>
{t('Rename')}
</Button>
</>
}
>
<Box $padding={{ top: 'base' }}>
<Input
onClick={(e) => {
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);
}}
/>
</Box>
</Modal>
);
};
@@ -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;
@@ -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 (
<Box
$direction="row"
$align="center"
$height="35px"
$justify="space-between"
$gap="10px"
$margin={{ horizontal: 'sm', vertical: 'base' }}
>
<Box $direction="row" $align="center" $gap="10px">
<FilterDropdown
selectedValue={values?.target}
options={[
{
label: t('All docs'),
value: DocSearchTarget.ALL,
callback: () => handleTargetChange(DocSearchTarget.ALL),
},
{
label: t('Current doc'),
value: DocSearchTarget.CURRENT,
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
},
]}
/>
</Box>
{hasFilters && (
<Button color="primary-text" size="small" onClick={onReset}>
{t('Reset')}
</Button>
)}
</Box>
);
};
@@ -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 (
<Box data-testid={`doc-search-item-${doc.id}`} $width="100%">
<QuickSearchItemContent
left={
<Box $direction="row" $align="center" $gap="10px" $width="100%">
<Box $flex={isDesktop ? 9 : 1}>
<SimpleDocItem doc={doc} showAccesses />
<>
<Box $direction="row" $align="center" $gap="10px" $width="100%">
<Box $flex={isDesktop ? 9 : 1}>
{isSubPage ? (
<LightDocItem doc={doc} showActions={false} />
) : (
<SimpleDocItem doc={doc} showAccesses />
)}
</Box>
</Box>
</Box>
</>
}
right={
<Icon iconName="keyboard_return" $theme="primary" $variation="800" />
@@ -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<DocSearchFiltersValues>(
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<Doc> = 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: <InView onChange={() => void fetchNextPage()} /> }]
: [],
};
}, [data, hasNextPage, fetchNextPage, t, search]);
}, [data, hasNextPage, fetchNextPage, t, search, filters.target]);
return (
<Modal
@@ -75,6 +100,13 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
onFilter={handleInputSearch}
>
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
{showFilters && (
<DocSearchFilters
values={filters}
onValuesChange={setFilters}
onReset={handleResetFilters}
/>
)}
{search.length === 0 && (
<Box
$direction="column"
@@ -93,7 +125,9 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
<QuickSearchGroup
onSelect={handleSelect}
group={docsData}
renderElement={(doc) => <DocSearchItem doc={doc} />}
renderElement={(doc) => (
<DocSearchItem isSubPage={filters.target != null} doc={doc} />
)}
/>
)}
</Box>
@@ -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);
@@ -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[] = [
@@ -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) => {
@@ -33,9 +33,7 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
>
<Button
fullWidth={false}
onClick={() => {
copyDocLink();
}}
onClick={copyDocLink}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
>
@@ -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<Doc, 'title'> & {
parentId: string;
};
export const createDocChildren = async ({
title,
parentId,
}: CreateDocParam): Promise<Doc> => {
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<Doc>;
};
interface CreateDocProps {
onSuccess: (data: Doc) => void;
}
export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, CreateDocParam>({
mutationFn: createDocChildren,
onSuccess: (data) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
onSuccess(data);
},
});
}
@@ -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<DocsResponse> => {
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<DocsResponse>;
};
export const KEY_LIST_DOC_CHILDREN = 'doc-children';
export function useDocChildren(
params: DocsChildrenParams,
queryConfig?: Omit<
UseQueryOptions<DocsResponse, APIError, DocsResponse>,
'queryKey' | 'queryFn'
>,
) {
return useQuery<DocsResponse, APIError, DocsResponse>({
queryKey: [KEY_LIST_DOC_CHILDREN, params],
queryFn: () => getDocChildren(params),
...queryConfig,
});
}
export const useInfiniteDocChildren = (params: DocsChildrenParams) => {
return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params);
};
@@ -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<Doc> => {
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<Doc>;
};
export const KEY_LIST_DOC_CHILDREN = 'doc-tree';
export function useDocTree(
params: DocsTreeParams,
queryConfig?: Omit<
UseQueryOptions<Doc, APIError, Doc>,
'queryKey' | 'queryFn'
>,
) {
return useQuery<Doc, APIError, Doc>({
queryKey: [KEY_LIST_DOC_CHILDREN, params],
queryFn: () => getDocTree(params),
staleTime: 0,
gcTime: 0,
...queryConfig,
});
}
@@ -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<void> => {
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<void>;
};
export function useMoveDoc() {
return useMutation<void, APIError, MoveDocParam>({
mutationFn: moveDoc,
});
}
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

@@ -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<Doc>;
export const DocTree = ({ docId }: Props) => {
const [rootNode, setRootNode] = useState<Doc | null>(null);
const { spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
const moveDoc = useMoveDoc();
const tree = useTreeData<Doc>({
initialItems: [],
getKey: (item) => item.id,
initialSelectedKeys: [],
getChildren: (item) => item.children ?? [],
});
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
);
const {
selectedNode,
setSelectedNode,
refreshNode,
setTreeData: setTreeDataStore,
treeData: treeDataStore,
setRootId,
} = useTreeStore();
const { data, isLoading, isFetching, isRefetching } = useDocTree({
docId,
});
const afterMove = (
result: TreeViewMoveResult,
newTreeData: TreeViewDataType<Doc>[],
) => {
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 <div>Loading...</div>;
}
if (!data) {
return <div>No data</div>;
}
return (
<>
<SeparatedSection showSeparator={false}>
<Box $padding={{ horizontal: 'sm' }}>
<Box
$css={css`
padding: ${spacing['2xs']};
border-radius: 4px;
background-color: ${isRootNodeSelected
? 'var(--c--theme--colors--greyscale-100)'
: 'transparent'};
`}
>
{rootNode && (
<StyledLink
href={`/docs/${rootNode.id}`}
onClick={() => {
setSelectedNode(rootNode);
}}
>
<SimpleDocItem doc={rootNode} showAccesses={false} />
</StyledLink>
)}
</Box>
</Box>
</SeparatedSection>
<Box
$padding={{ all: 'sm' }}
$margin={{ top: '-35px' }}
$width="100%"
tabIndex={-1}
>
{initialOpenState && treeDataStore.length > 0 && (
<TreeView
initialOpenState={initialOpenState}
treeData={treeDataStore}
width="100%"
selectedNodeId={selectedNode?.id}
rootNodeId={docId}
renderNode={(props) => <DocTreeItem {...props} />}
afterMove={(result, newTreeData) => {
void afterMove(result, newTreeData);
}}
/>
)}
</Box>
</>
);
};
@@ -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<TreeViewDataType<DocTreeDataType>>;
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<DocTreeDataType>[] = 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 (
<Fragment>
<TreeViewNode
onClick={() => router.push(`/docs/${node.data.id}`)}
node={node}
{...props}
loadChildren={loadChildren}
>
<LightDocItem
showActions={isOpen}
doc={node.data}
rightContent={
<Box $direction="row" $gap={spacing['xs']} $align="center">
<DropdownMenu options={options} afterOpenChange={setIsOpen}>
<Icon iconName="more_horiz" $theme="primary" $variation="600" />
</DropdownMenu>
<BoxButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
createChildrenDoc({
title: t('Untitled page'),
parentId: node.id,
});
}}
color="primary-text"
>
<Icon
$variation="800"
$theme="primary"
isFilled
iconName="add_box"
/>
</BoxButton>
</Box>
}
/>
</TreeViewNode>
{deleteModal.isOpen && (
<ModalRemoveDoc
onClose={deleteModal.onClose}
doc={node.data}
afterDelete={afterDelete}
/>
)}
{shareModal.isOpen && (
<DocShareModal doc={node.data} onClose={shareModal.close} />
)}
{renameModal.isOpen && (
<ModalRenameDoc onClose={renameModal.onClose} doc={node.data} />
)}
</Fragment>
);
};
@@ -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<DocRootTreeStore>((set) => ({
rootId: undefined,
setRootId: (id?: string) => {
set({ rootId: id });
},
}));
@@ -0,0 +1,22 @@
export type BaseType<T> = T & {
id: string;
childrenCount?: number;
parentId?: string;
children?: BaseType<T>[];
};
export type TreeViewDataType<T> = BaseType<T>;
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;
};
@@ -100,17 +100,21 @@ export const ModalConfirmationVersion = ({
</Button>
</>
}
size={ModalSize.MEDIUM}
size={ModalSize.SMALL}
title={
<Text $size="h6" $align="flex-start">
<Text $size="h6" $align="flex-start" $variation="1000">
{t('Warning')}
</Text>
}
>
<Box aria-label={t('Modal confirmation to restore the version')}>
<Box>
<Text>{t('Your current document will revert to this version.')}</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
<Text $variation="600">
{t('Your current document will revert to this version.')}
</Text>
<Text $variation="600">
{t('If a member is editing, his works can be lost.')}
</Text>
</Box>
</Box>
</Modal>
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -61,12 +61,8 @@ export const DocsGrid = ({
$position="relative"
$width="100%"
$maxWidth="960px"
$maxHeight="calc(100vh - 52px - 1rem)"
$maxHeight="calc(100vh - 52px - 2rem)"
$align="center"
$css={css`
overflow-x: hidden;
overflow-y: auto;
`}
>
<DocsGridLoader isLoading={isRefetching || loading} />
<Card
@@ -75,8 +71,7 @@ export const DocsGrid = ({
$height="100%"
$width="100%"
$css={css`
overflow-x: hidden;
overflow-y: auto;
${!isDesktop ? 'border: none;' : ''}
`}
$padding={{
top: 'base',
@@ -101,7 +96,7 @@ export const DocsGrid = ({
</Box>
)}
{hasDocs && (
<Box $gap="6px">
<Box $gap="6px" $overflow="auto">
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
@@ -122,27 +117,29 @@ export const DocsGrid = ({
)}
</Box>
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
</Box>
)}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button onClick={() => void fetchNextPage()} color="primary-text">
{t('More docs')}
</Button>
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button
onClick={() => void fetchNextPage()}
color="primary-text"
>
{t('More docs')}
</Button>
)}
</InView>
)}
</InView>
</Box>
)}
</Card>
</Box>
@@ -54,6 +54,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$css={css`
flex: ${flexLeft};
align-items: center;
min-width: 0;
`}
href={`/docs/${doc.id}`}
>
@@ -64,6 +65,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$gap={spacings.xs}
$flex={flexLeft}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{showAccesses && (
@@ -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) {
@@ -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 (
<Box
$width="100%"
$direction="row"
$gap={spacing['xs']}
$align="center"
$css={css`
.light-doc-item-actions {
opacity: 0;
display: 'flex';
}
&:hover {
.light-doc-item-actions {
opacity: 1;
}
}
`}
>
<Box $width={16} $height={16}>
<Logo />
</Box>
<Box
$direction="row"
$align="center"
$css={css`
display: flex;
flex-direction: row;
width: 100%;
gap: 0.5rem;
align-items: center;
`}
>
<Text $css={ItemTextCss} $size="sm">
{doc.title}
</Text>
{doc.nb_accesses_direct > 1 && (
<Icon isFilled iconName="group" $size="16px" $variation="400" />
)}
</Box>
{rightContent && (
<Box
$direction="row"
$gap={spacing['xs']}
$align="center"
className="light-doc-item-actions"
>
{rightContent}
</Box>
)}
</Box>
);
};
@@ -38,7 +38,7 @@ export const SimpleDocItem = ({
const { untitledDocument } = useTrans();
return (
<Box $direction="row" $gap={spacings.sm}>
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
<Box
$direction="row"
$align="center"
@@ -53,7 +53,7 @@ export const SimpleDocItem = ({
<SimpleFileIcon aria-label={t('Simple document icon')} />
)}
</Box>
<Box $justify="center">
<Box $justify="center" $overflow="auto">
<Text
aria-describedby="doc-title"
aria-label={doc.title}
@@ -55,7 +55,7 @@ export default function HomeBanner() {
$textAlign="center"
$margin="none"
$css={css`
line-height: 56px;
line-height: ${!isMobile ? '56px' : '45px'};
`}
>
{t('Collaborative writing, Simplified.')}
@@ -74,7 +74,7 @@ export default function HomeBanner() {
<ProConnectButton />
) : (
<Button
onClick={gotoLogin}
onClick={() => gotoLogin()}
icon={
<Text $isMaterialIcon $color="white">
bolt
@@ -116,8 +116,8 @@ export const HomeSection = ({
`}
$variation="1000"
$weight="bold"
$size={!isSmallDevice ? 'xs-alt' : 'h4'}
$textAlign={isSmallMobile ? 'center' : 'left'}
$size={!isSmallDevice ? 'xs-alt' : isSmallMobile ? 'h6' : 'h4'}
$textAlign="left"
$margin="none"
>
{title}
@@ -1,84 +1,61 @@
import { Select } from '@openfun/cunningham-react';
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { css } from 'styled-components';
import { Box, Text } from '@/components/';
import { DropdownMenu, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
flex-shrink: 0;
width: auto;
.c__select__wrapper {
min-height: 2rem;
height: auto;
border-color: transparent;
padding: 0 0.15rem 0 0.45rem;
border-radius: 1px;
.labelled-box .labelled-box__children {
padding-right: 2rem;
.c_select__render .typo-text {
${({ $isSmall }) => $isSmall && `display: none;`}
}
}
&:hover {
box-shadow: none !important;
}
}
`;
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { preload: languages } = i18n.options;
Settings.defaultLocale = i18n.language;
const language = i18n.language;
Settings.defaultLocale = language;
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
value: lang,
label: lang,
render: () => (
<Box
className="c_select__render"
$direction="row"
$gap="0.7rem"
$align="center"
>
<Text
$isMaterialIcon
$size="1rem"
$theme="primary"
$weight="bold"
$variation="800"
>
translate
</Text>
<Text $theme="primary" $weight="500" $variation="800">
{LANGUAGES_ALLOWED[lang]}
</Text>
</Box>
),
}));
}, [languages]);
return (
<SelectStyled
label={t('Language')}
showLabelWhenSelected={false}
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg"
options={optionsPicker}
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
label: LANGUAGES_ALLOWED[lang],
isSelected: language === lang,
callback: () => {
i18n.changeLanguage(lang).catch((err) => {
console.error('Error changing language', err);
});
}}
/>
},
}));
}, [i18n, language, languages]);
return (
<DropdownMenu
options={optionsPicker}
showArrow
buttonCss={css`
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
border-radius: 4px;
padding: 0.5rem 0.6rem;
& > div {
gap: 0.2rem;
display: flex;
}
& .material-icons {
color: var(--c--components--button--primary-text--color) !important;
}
`}
>
<Text
$theme="primary"
aria-label={t('Language')}
$direction="row"
$gap="0.5rem"
>
<Text $isMaterialIcon $color="inherit" $size="xl">
translate
</Text>
{LANGUAGES_ALLOWED[language]}
</Text>
</DropdownMenu>
);
};
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

@@ -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']};
`}
>
<Box
@@ -1,14 +1,23 @@
import { css } from 'styled-components';
import { useEffect } from 'react';
import { Box, SeparatedSection } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '@/components';
import { useTreeStore } from '@/components/common/tree/treeStore';
import { useDocStore } from '@/features/docs/doc-management';
import { SimpleDocItem } from '@/features/docs/docs-grid';
import { DocTree } from '@/features/docs/doc-tree/components/DocTree';
export const LeftPanelDocContent = () => {
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;"
>
<SeparatedSection showSeparator={false}>
<Box $padding={{ horizontal: 'sm' }}>
<Box
$css={css`
padding: ${spacing['2xs']};
border-radius: 4px;
background-color: var(--c--theme--colors--greyscale-100);
`}
>
<SimpleDocItem doc={currentDoc} showAccesses={true} />
</Box>
</Box>
</SeparatedSection>
{initialNode?.id}
{initialNode && <DocTree docId={initialNode.id} />}
</Box>
);
};
@@ -39,7 +39,7 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => {
`}
key={doc.id}
>
<StyledLink href={`/docs/${doc.id}`}>
<StyledLink href={`/docs/${doc.id}`} $css="overflow: auto;">
<SimpleDocItem showAccesses doc={doc} />
</StyledLink>
<div className="pinned-actions">
@@ -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) => {
)}
</Box>
{authenticated && (
<Button onClick={createNewDoc}>{t('New doc')}</Button>
<Button
onClick={createNewDoc}
disabled={!currentDoc}
color={!isDoc ? 'primary' : 'tertiary'}
>
{t(isDoc ? 'New page' : 'New doc')}
</Button>
)}
</Box>
</SeparatedSection>
{children}
</Box>
{searchModal.isOpen && (
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
<DocSearchModal
{...searchModal}
size={ModalSize.LARGE}
showFilters={isDoc}
defaultFilters={{
target: isDoc ? DocSearchTarget.CURRENT : undefined,
}}
/>
)}
</>
);
@@ -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,
@@ -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,16 +12,58 @@ 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<MainLayoutProps>) {
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;
return (
<div>
@@ -30,29 +73,45 @@ export function MainLayout({
$margin={{ top: `${HEADER_HEIGHT}px` }}
$width="100%"
>
<LeftPanel />
<Box
as="main"
id={MAIN_LAYOUT_ID}
$align="center"
$flex={1}
$width="100%"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$padding={{
all: isDesktop ? 'base' : '2xs',
}}
$background={
backgroundColor === 'white'
? colors['greyscale-000']
: colors['greyscale-050']
}
$css={css`
overflow-y: auto;
overflow-x: clip;
`}
>
{children}
</Box>
<PanelGroup direction="horizontal">
<Panel
defaultSize={minPanelSize}
minSize={minPanelSize}
maxSize={maxPanelSize}
>
<LeftPanel />
</Panel>
<PanelResizeHandle
style={{
width: '1px',
backgroundColor: colors['greyscale-200'],
}}
/>
<Panel>
<Box
as="main"
id={MAIN_LAYOUT_ID}
$align="center"
$flex={1}
$width="100%"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$padding={{
all: isDesktop ? 'base' : '0',
}}
$background={
backgroundColor === 'white'
? colors['greyscale-000']
: colors['greyscale-050']
}
$css={css`
overflow-y: auto;
overflow-x: clip;
`}
>
{children}
</Box>
</Panel>
</PanelGroup>
</Box>
</div>
);
@@ -6,17 +6,27 @@ import { HEADER_HEIGHT, Header } from '@/features/header';
import { LeftPanel } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
export function PageLayout({ children }: PropsWithChildren) {
interface PageLayoutProps {
withFooter?: boolean;
}
export function PageLayout({
children,
withFooter = true,
}: PropsWithChildren<PageLayoutProps>) {
const { isDesktop } = useResponsiveStore();
return (
<Box $minHeight="100vh" $margin={{ top: `${HEADER_HEIGHT}px` }}>
<Box
$minHeight={`calc(100vh - ${HEADER_HEIGHT}px)`}
$margin={{ top: `${HEADER_HEIGHT}px` }}
>
<Header />
<Box as="main" $width="100%" $css="flex-grow:1;">
{!isDesktop && <LeftPanel />}
{children}
</Box>
<Footer />
{withFooter && <Footer />}
</Box>
);
}
@@ -0,0 +1,57 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { ReactElement, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import img401 from '@/assets/icons/icon-401.png';
import { Box, Text } from '@/components';
import { gotoLogin, useAuth } from '@/features/auth';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const { authenticated } = useAuth();
const { replace } = useRouter();
useEffect(() => {
if (authenticated) {
void replace(`/`);
}
}, [authenticated, replace]);
return (
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
src={img401}
alt={t('Image 401')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t('Log in to access the document.')}
</Text>
<Button onClick={() => gotoLogin(false)} aria-label={t('Login')}>
{t('Login')}
</Button>
</Box>
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;
@@ -0,0 +1,60 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import img403 from '@/assets/icons/icon-403.png';
import { Box, StyledLink, Text } from '@/components';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
width: fit-content;
`;
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
src={img403}
alt={t('Image 403')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t('You do not have permission to view this document.')}
</Text>
<StyledLink href="/">
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;

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