Compare commits

..

3 Commits

Author SHA1 Message Date
Manuel Raynaud f51246b392 (backend) add comment viewset
This commit add the CRUD part to manage comment lifeycle. Permissions
are relying on the Document and Comment abilities. Comment viewset
depends on the Document route and is added to the
document_related_router. Dedicated serializer and permission are
created.
2025-08-28 08:26:16 +02:00
Manuel Raynaud a11a1911bc (backend) add Comment model
In order to store the comments on a document, we created a new model
Comment. User is nullable because anonymous users can comment a Document
is this one is public with a link_role commentator.
2025-08-27 16:38:42 +02:00
Manuel Raynaud 604e5e0eb2 (backend) add commentator role
To allow a user to comment a document we added a new role: commentator.
Commentator is higher than reader but lower than editor.
2025-08-26 17:55:53 +02:00
125 changed files with 2721 additions and 4713 deletions
+1 -2
View File
@@ -23,10 +23,9 @@ jobs:
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
+1 -52
View File
@@ -101,7 +101,7 @@ jobs:
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: test-e2e-chromium
timeout-minutes: 30
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -136,54 +136,3 @@ jobs:
name: playwright-other-report
path: src/frontend/apps/e2e/report/
retention-days: 7
bundle-size-check:
runs-on: ubuntu-latest
needs: install-dependencies
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Detect relevant changes
id: changes
uses: dorny/paths-filter@v2
with:
filters: |
lock:
- 'src/frontend/**/yarn.lock'
app:
- 'src/frontend/apps/impress/**'
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Setup Node.js
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Check bundle size changes
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
build-script: "app:build"
pattern: "apps/impress/out/**/*.{css,js,html}"
exclude: "{**/*.map,**/node_modules/**}"
minimum-change-threshold: 500
compression: "gzip"
cwd: "./src/frontend"
show-total: true
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
omit-unchanged: true
install-script: "yarn install --frozen-lockfile"
+2 -8
View File
@@ -25,18 +25,14 @@ jobs:
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
@@ -93,10 +89,9 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
@@ -189,10 +184,9 @@ jobs:
mc version enable impress/impress-media-storage"
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
+1 -12
View File
@@ -10,12 +10,10 @@ and this project adheres to
### Added
- 👷(CI) add bundle size check job #1268
- ✨(frontend) use title first emoji as doc icon in tree
- ✨(backend) Comments on text editor #1309
### Changed
- ♻️(docs-app) Switch from Jest tests to Vitest #1269
- ⚡️(frontend) improve accessibility:
- #1248
- #1235
@@ -25,20 +23,11 @@ and this project adheres to
- #1244
- #1270
- #1282
- #1261
- ♻️(backend) fallback to email identifier when no name #1298
- 🐛(backend) allow ASCII characters in user sub field #1295
- ⚡️(frontend) improve fallback width calculation #1333
### Fixed
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1264
- 🐛(minio) fix user permission error with Minio and Windows #1264
- 🐛(frontend) fix export when quote block and inline code #1319
- 🐛(frontend) fix base64 font #1324
- 🐛(backend) allow editor to delete subpages #1296
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
## [3.5.0] - 2025-07-31
+2 -70
View File
@@ -93,77 +93,13 @@ post-bootstrap: \
mails-build
.PHONY: post-bootstrap
pre-beautiful-bootstrap: ## Display a welcome message before bootstrap
ifeq ($(OS),Windows_NT)
@echo ""
@echo "================================================================================"
@echo ""
@echo " Welcome to Docs - Collaborative Text Editing from La Suite!"
@echo ""
@echo " This will set up your development environment with:"
@echo " - Docker containers for all services"
@echo " - Database migrations and static files"
@echo " - Frontend dependencies and build"
@echo " - Environment configuration files"
@echo ""
@echo " Services will be available at:"
@echo " - Frontend: http://localhost:3000"
@echo " - API: http://localhost:8071"
@echo " - Admin: http://localhost:8071/admin"
@echo ""
@echo "================================================================================"
@echo ""
@echo "Starting bootstrap process..."
else
@echo "$(BOLD)"
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
@echo "║ ║"
@echo "║ 🚀 Welcome to Docs - Collaborative Text Editing from La Suite ! 🚀 ║"
@echo "║ ║"
@echo "║ This will set up your development environment with : ║"
@echo "║ • Docker containers for all services ║"
@echo "║ • Database migrations and static files ║"
@echo "║ • Frontend dependencies and build ║"
@echo "║ • Environment configuration files ║"
@echo "║ ║"
@echo "║ Services will be available at: ║"
@echo "║ • Frontend: http://localhost:3000 ║"
@echo "║ • API: http://localhost:8071 ║"
@echo "║ • Admin: http://localhost:8071/admin ║"
@echo "║ ║"
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
@echo "$(RESET)"
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
endif
@echo ""
.PHONY: pre-beautiful-bootstrap
post-beautiful-bootstrap: ## Display a success message after bootstrap
@echo ""
ifeq ($(OS),Windows_NT)
@echo "Bootstrap completed successfully!"
@echo ""
@echo "Next steps:"
@echo " - Visit http://localhost:3000 to access the application"
@echo " - Run 'make help' to see all available commands"
else
@echo "$(GREEN)🎉 Bootstrap completed successfully!$(RESET)"
@echo ""
@echo "$(BOLD)Next steps:$(RESET)"
@echo " • Visit http://localhost:3000 to access the application"
@echo " • Run 'make help' to see all available commands"
endif
@echo ""
.PHONY: post-beautiful-bootstrap
bootstrap: ## Prepare the project for local development
bootstrap: ## Prepare Docker developmentimages for the project
bootstrap: \
pre-beautiful-bootstrap \
pre-bootstrap \
build \
post-bootstrap \
run \
post-beautiful-bootstrap
run
.PHONY: bootstrap
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
@@ -406,10 +342,6 @@ run-frontend-development: ## Run the frontend in development mode
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
frontend-test: ## Run the frontend tests
cd $(PATH_FRONT_IMPRESS) && yarn test
.PHONY: frontend-test
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
.PHONY: frontend-i18n-extract
+4 -21
View File
@@ -49,24 +49,13 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
### Self-host
🚀 Docs is easy to install on your own servers
#### 🚀 Docs is easy to install on your own servers
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
Available methods: Helm chart, Nix package
#### 🌍 Known instances
We hope to see many more, here is an incomplete list of public Docs instances (urls listed in alphabetical order). Feel free to make a PR to add ones that are not listed below🙏
| | | |
| --- | --- | ------- |
| Url | Org | Public |
| docs.numerique.gouv.fr | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| docs.suite.anct.gouv.fr | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| notes.demo.opendesk.eu | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| notes.liiib.re | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| docs.federated.nexus | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
In the works: Docker Compose, YunoHost
#### ⚠️ Advanced features
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
## Getting started 🔧
@@ -141,12 +130,6 @@ To start all the services, except the frontend container, you can use the follow
$ make run-backend
```
To execute frontend tests & linting only
```shellscript
$ make frontend-test
$ make frontend-lint
```
**Adding content**
You can create a basic demo site by running this command:
+3 -3
View File
@@ -135,9 +135,9 @@ NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
+1 -1
View File
@@ -27,7 +27,7 @@ backend:
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
-32
View File
@@ -1,32 +0,0 @@
# Installation
If you want to install Docs you've come to the right place.
Here are a bunch of resources to help you install the project.
## Kubernetes
We (Docs maintainers) are only using the Kubernetes deployment method in production. We can only provide advanced support for this method.
Please follow the instructions laid out [here](/docs/installation/kubernetes.md).
## Docker Compose
We are aware that not everyone has Kubernetes Cluster laying around 😆.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=impress) that you can deploy using Compose.
Please follow the instructions [here](/docs/installation/compose.md).
⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help.
## Other ways to install Docs
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are two many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
Here is the list of other methods in alphabetical order:
- Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs)
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&query=lasuite-docs), ⚠️ unstable
- Podman: [code][https://codeberg.org/philo/lasuite-docs-podman], ⚠️ experimental
- YunoHost: [code](https://github.com/YunoHost-Apps/lasuite-docs_ynh), [app store](https://apps.yunohost.org/app/lasuite-docs)
Feel free to make a PR to add ones that are not listed above 🙏
## Cloud providers
Some cloud providers are making it easy to deploy Docs on their infrastructure.
Here is the list in alphabetical order:
- Clever Cloud 🇫🇷 : [market place][https://www.clever-cloud.com/product/docs/], [technical doc](https://www.clever.cloud/developers/guides/docs/#deploy-docs)
Feel free to make a PR to add ones that are not listed above 🙏
+1 -1
View File
@@ -124,7 +124,7 @@ OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
-4
View File
@@ -2,10 +2,6 @@
"extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog", "automated"],
"schedule": ["before 7am on monday"],
"prCreation": "not-pending",
"rebaseWhen": "conflicted",
"updateNotScheduled": false,
"packageRules": [
{
"enabled": false,
+16
View File
@@ -171,3 +171,19 @@ class ResourceAccessPermission(IsAuthenticated):
action = view.action
return abilities.get(action, False)
class CommentPermission(permissions.BasePermission):
"""Permission class for comments."""
def has_permission(self, request, view):
"""Check permission for a given object."""
if view.action in ["create", "list"]:
document_abilities = view.get_document_or_404().get_abilities(request.user)
return document_abilities["comment"]
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
+46 -22
View File
@@ -7,13 +7,12 @@ from base64 import b64decode
from django.conf import settings
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import serializers
from core import choices, enums, models, utils, validators
from core import choices, enums, models, utils
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
@@ -33,30 +32,11 @@ class UserSerializer(serializers.ModelSerializer):
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
def get_full_name(self, instance):
"""Return the full name of the user."""
if not instance.full_name:
email = instance.email.split("@")[0]
return slugify(email)
return instance.full_name
def get_short_name(self, instance):
"""Return the short name of the user."""
if not instance.short_name:
email = instance.email.split("@")[0]
return slugify(email)
return instance.short_name
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
@@ -422,7 +402,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
content = serializers.CharField(required=True)
# User
sub = serializers.CharField(
required=True, validators=[validators.sub_validator], max_length=255
required=True, validators=[models.User.sub_validator], max_length=255
)
email = serializers.EmailField(required=True)
language = serializers.ChoiceField(
@@ -821,3 +801,47 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)
class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments."""
user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Comment
fields = [
"id",
"content",
"created_at",
"updated_at",
"user",
"document",
"abilities",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"user",
"document",
"abilities",
]
def get_abilities(self, comment) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return comment.get_abilities(request.user)
return {}
def validate(self, attrs):
"""Validate invitation data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["document_id"] = self.context["resource_id"]
attrs["user_id"] = user.id if user else None
return attrs
+44 -39
View File
@@ -13,7 +13,6 @@ from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import URLValidator
from django.db import connection, transaction
from django.db import models as db
from django.db.models.expressions import RawSQL
@@ -360,7 +359,7 @@ class DocumentViewSet(
permission_classes = [
permissions.DocumentPermission,
]
queryset = models.Document.objects.select_related("creator").all()
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
children_serializer_class = serializers.ListDocumentSerializer
@@ -787,11 +786,7 @@ class DocumentViewSet(
)
# GET: List children
queryset = (
document.get_children()
.select_related("creator")
.filter(ancestors_deleted_at__isnull=True)
)
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
queryset = self.filter_queryset(queryset)
filterset = DocumentFilter(request.GET, queryset=queryset)
@@ -845,27 +840,19 @@ class DocumentViewSet(
user = self.request.user
try:
current_document = (
self.queryset.select_related(None).only("depth", "path").get(pk=pk)
)
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.select_related(None).filter(pk=pk)
)
(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.select_related(None)
.readable_per_se(request.user)
.only("depth", "path")
.first()
ancestors.readable_per_se(request.user).only("depth", "path").first()
)
if highest_readable is None:
raise (
@@ -893,12 +880,7 @@ class DocumentViewSet(
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
queryset = (
ancestors.select_related("creator").filter(
depth__gte=highest_readable.depth
)
| children
)
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
queryset = queryset.order_by("path")
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_is_favorite(user)
@@ -1300,8 +1282,7 @@ class DocumentViewSet(
)
attachments_documents = (
self.queryset.select_related(None)
.filter(attachments__contains=[key])
self.queryset.filter(attachments__contains=[key])
.only("path")
.order_by("path")
)
@@ -1460,15 +1441,6 @@ class DocumentViewSet(
url = unquote(url)
url_validator = URLValidator(schemes=["http", "https"])
try:
url_validator(url)
except drf.exceptions.ValidationError as e:
return drf.response.Response(
{"detail": str(e)},
status=drf.status.HTTP_400_BAD_REQUEST,
)
try:
response = requests.get(
url,
@@ -1499,10 +1471,10 @@ class DocumentViewSet(
return proxy_response
except requests.RequestException as e:
logger.exception(e)
return drf.response.Response(
{"error": f"Failed to fetch resource from {url}"},
status=status.HTTP_400_BAD_REQUEST,
logger.error("Proxy request failed: %s", str(e))
return drf_response.Response(
{"error": f"Failed to fetch resource: {e!s}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -2100,3 +2072,36 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
class CommentViewSet(
viewsets.ModelViewSet,
):
"""API ViewSet for comments."""
permission_classes = [permissions.CommentPermission]
queryset = models.Comment.objects.select_related("user", "document").all()
serializer_class = serializers.CommentSerializer
pagination_class = Pagination
_document = None
def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"],
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
return super().get_queryset().filter(document=self.kwargs["resource_id"])
+2
View File
@@ -33,6 +33,7 @@ class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
COMMENTATOR = "commentator", _("Commentator") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
@@ -40,6 +41,7 @@ class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
COMMENTATOR = "commentator", _("Commentator") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
+11
View File
@@ -256,3 +256,14 @@ class InvitationFactory(factory.django.DjangoModelFactory):
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
class CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a document"""
class Meta:
model = models.Comment
document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory)
content = factory.Faker("text")
@@ -2,8 +2,6 @@
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
dependencies = [
@@ -35,17 +33,4 @@ class Migration(migrations.Migration):
verbose_name="language",
),
),
migrations.AlterField(
model_name="user",
name="sub",
field=models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. ASCII characters only.",
max_length=255,
null=True,
unique=True,
validators=[core.validators.sub_validator],
verbose_name="sub",
),
),
]
@@ -0,0 +1,146 @@
# Generated by Django 5.2.4 on 2025-08-26 08:11
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_add_is_masked_field_to_link_trace"),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.CreateModel(
name="Comment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("content", models.TextField()),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.document",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="comments",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("-created_at",),
},
),
]
+65 -13
View File
@@ -14,7 +14,7 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail
from django.core import mail, validators
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -39,7 +39,6 @@ from .choices import (
RoleChoices,
get_equivalent_link_definition,
)
from .validators import sub_validator
logger = getLogger(__name__)
@@ -137,12 +136,22 @@ class UserManager(auth_models.UserManager):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_("Required. 255 characters or fewer. ASCII characters only."),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
validators=[sub_validator],
unique=True,
validators=[sub_validator],
blank=True,
null=True,
)
@@ -753,12 +762,7 @@ class Document(MP_Node, BaseModel):
can_update = (
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
can_create_children = can_update and user.is_authenticated
can_destroy = (
is_owner
if self.is_root()
else (is_owner_or_admin or (user.is_authenticated and self.creator == user))
)
can_comment = (can_update or role == RoleChoices.COMMENTATOR) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
@@ -781,11 +785,12 @@ class Document(MP_Node, BaseModel):
"media_check": can_get,
"can_edit": can_update,
"children_list": can_get,
"children_create": can_create_children,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"comment": can_comment,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": can_destroy,
"destroy": is_owner,
"duplicate": can_get and user.is_authenticated,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
@@ -1142,7 +1147,12 @@ class DocumentAccess(BaseAccess):
set_role_to = []
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
[
RoleChoices.READER,
RoleChoices.COMMENTATOR,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
@@ -1274,6 +1284,48 @@ class DocumentAskForAccess(BaseModel):
self.document.send_email(subject, [email], context, language)
class Comment(BaseModel):
"""User comment on a document."""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="comments",
null=True,
blank=True,
)
content = models.TextField()
class Meta:
db_table = "impress_comment"
ordering = ("-created_at",)
verbose_name = _("Comment")
verbose_name_plural = _("Comments")
def __str__(self):
author = self.user or _("Anonymous")
return f"{author!s} on {self.document!s}"
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
role = self.document.get_role(user)
can_comment = self.document.get_abilities(user)["comment"]
return {
"destroy": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"update": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"partial_update": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"retrieve": can_comment,
}
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""
@@ -292,6 +292,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
}
assert result_dict[str(document_access_other_user.id)] == [
"reader",
"commentator",
"editor",
"administrator",
"owner",
@@ -300,7 +301,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
# Add an access for the other user on the parent
parent_access_other_user = factories.UserDocumentAccessFactory(
document=parent, user=other_user, role="editor"
document=parent, user=other_user, role="commentator"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
@@ -313,6 +314,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"commentator",
"editor",
"administrator",
"owner",
@@ -320,6 +322,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"commentator",
"editor",
"administrator",
"owner",
@@ -332,28 +335,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
],
@@ -414,44 +417,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "editor", "administrator"],
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
[],
[],
],
@@ -459,7 +462,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["editor", "editor", "administrator", "editor"],
[
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
[],
["editor", "administrator"],
[],
@@ -0,0 +1,588 @@
"""Test API for comments on documents."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# List comments
def test_list_comments_anonymous_user_public_document():
"""Anonymous users should be allowed to list comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
)
comment1, comment2 = factories.CommentFactory.create_batch(2, document=document)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment2.id),
"content": comment2.content,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"document": str(comment2.document.id),
"abilities": comment2.get_abilities(AnonymousUser()),
},
{
"id": str(comment1.id),
"content": comment1.content,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"document": str(comment1.document.id),
"abilities": comment1.get_abilities(AnonymousUser()),
},
],
}
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
def test_list_comments_anonymous_user_non_public_document(link_reach):
"""Anonymous users should not be allowed to list comments on a non-public document."""
document = factories.DocumentFactory(
link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTATOR
)
factories.CommentFactory(document=document)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/")
assert response.status_code == 401
def test_list_comments_authenticated_user_accessible_document():
"""Authenticated users should be allowed to list comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
)
comment1 = factories.CommentFactory(document=document)
comment2 = factories.CommentFactory(document=document, user=user)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment2.id),
"content": comment2.content,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"document": str(comment2.document.id),
"abilities": comment2.get_abilities(user),
},
{
"id": str(comment1.id),
"content": comment1.content,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"document": str(comment1.document.id),
"abilities": comment1.get_abilities(user),
},
],
}
def test_list_comments_authenticated_user_non_accessible_document():
"""Authenticated users should not be allowed to list comments on a non-accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
factories.CommentFactory(document=document)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/")
assert response.status_code == 403
def test_list_comments_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to list comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
factories.CommentFactory(document=document)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/")
assert response.status_code == 403
# Create comment
def test_create_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to create comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"content": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": None,
"document": str(document.id),
"abilities": {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": True,
},
}
def test_create_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to create comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
)
assert response.status_code == 401
def test_create_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to create comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"content": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": {
"full_name": user.full_name,
"short_name": user.short_name,
},
"document": str(document.id),
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
}
def test_create_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to create comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
)
assert response.status_code == 403
# Retrieve comment
def test_retrieve_comment_anonymous_user_public_document():
"""Anonymous users should be allowed to retrieve comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
)
comment = factories.CommentFactory(document=document)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(comment.id),
"content": comment.content,
"created_at": comment.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment.user.full_name,
"short_name": comment.user.short_name,
},
"document": str(comment.document.id),
"abilities": comment.get_abilities(AnonymousUser()),
}
def test_retrieve_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to retrieve comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
comment = factories.CommentFactory(document=document)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_retrieve_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to retrieve comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
)
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
def test_retrieve_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to retrieve comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Update comment
def test_update_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to update comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
)
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 401
def test_update_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to update comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 401
def test_update_comment_authenticated_user_accessible_document():
"""Authenticated users should not be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR]
),
)
],
)
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_user_own_comment():
"""Authenticated users should be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR]
),
)
],
)
comment = factories.CommentFactory(document=document, content="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.content == "other content"
def test_update_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_no_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.content == "other content"
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
comment = factories.CommentFactory(document=document, content="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.content == "other content"
# Delete comment
def test_delete_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to delete comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
)
comment = factories.CommentFactory(document=document)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to delete comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
comment = factories.CommentFactory(document=document)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_authenticated_user_accessible_document_own_comment():
"""Authenticated users should be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
)
comment = factories.CommentFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_accessible_document_not_own_comment():
"""Authenticated users should not be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
)
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
comment = factories.CommentFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be able to delete comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
@@ -2,7 +2,6 @@
import pytest
import responses
from requests.exceptions import RequestException
from rest_framework.test import APIClient
from core import factories
@@ -150,41 +149,3 @@ def test_api_docs_cors_proxy_unsupported_media_type():
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 415
@pytest.mark.parametrize(
"url_to_fetch",
[
"ftp://external-url.com/assets/index.html",
"ftps://external-url.com/assets/index.html",
"invalid-url.com",
"ssh://external-url.com/assets/index.html",
],
)
def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
"""Test the CORS proxy API for documents with an invalid URL."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == ["Enter a valid URL."]
@responses.activate
def test_api_docs_cors_proxy_request_failed():
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=RequestException("Connection refused"))
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
}
@@ -148,7 +148,7 @@ def test_api_documents_create_for_owner_invalid_sub():
data = {
"title": "My Document",
"content": "Document content",
"sub": "invalid süb",
"sub": "123!!",
"email": "john.doe@example.com",
}
@@ -163,7 +163,10 @@ def test_api_documents_create_for_owner_invalid_sub():
assert not Document.objects.exists()
assert response.json() == {
"sub": ["Enter a valid sub. This value should be ASCII only."]
"sub": [
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
]
}
@@ -36,6 +36,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commentator", "editor"],
"cors_proxy": True,
"descendants": True,
"destroy": False,
@@ -45,8 +46,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": False,
@@ -111,6 +112,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commentator", "editor"],
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -216,6 +218,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commentator", "editor"],
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -224,8 +227,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -298,6 +301,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commentator", "editor"],
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -488,13 +492,14 @@ def test_api_documents_retrieve_authenticated_related_parent():
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"can_edit": access.role != "reader",
"can_edit": access.role not in ["reader", "commentator"],
"children_create": access.role != "reader",
"children_list": True,
"collaboration_auth": True,
"comment": access.role != "reader",
"descendants": True,
"cors_proxy": True,
"destroy": access.role in ["administrator", "owner"],
"destroy": access.role == "owner",
"duplicate": True,
"favorite": True,
"invite_owner": access.role == "owner",
@@ -79,16 +79,17 @@ def test_api_documents_trashbin_format():
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"comment": True,
"cors_proxy": True,
"descendants": True,
"destroy": True,
"duplicate": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -1,44 +0,0 @@
"""Test user light serializer."""
import pytest
from core import factories
from core.api.serializers import UserLightSerializer
pytestmark = pytest.mark.django_db
def test_user_light_serializer():
"""Test user light serializer."""
user = factories.UserFactory(
email="test@test.com",
full_name="John Doe",
short_name="John",
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "John Doe"
assert serializer.data["short_name"] == "John"
def test_user_light_serializer_no_full_name():
"""Test user light serializer without full name."""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name="John",
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "test_foo"
assert serializer.data["short_name"] == "John"
def test_user_light_serializer_no_short_name():
"""Test user light serializer without short name."""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name=None,
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "test_foo"
assert serializer.data["short_name"] == "test_foo"
@@ -0,0 +1,273 @@
"""Test the comment model."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from core import factories
from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"role,can_comment",
[
(LinkRoleChoices.READER, False),
(LinkRoleChoices.COMMENTATOR, True),
(LinkRoleChoices.EDITOR, True),
],
)
def test_comment_get_abilities_anonymous_user_public_document(role, can_comment):
"""Anonymous users cannot comment on a document."""
document = factories.DocumentFactory(
link_role=role, link_reach=LinkReachChoices.PUBLIC
)
comment = factories.CommentFactory(document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED]
)
def test_comment_get_abilities_anonymous_user_restricted_document(link_reach):
"""Anonymous users cannot comment on a restricted document."""
document = factories.DocumentFactory(link_reach=link_reach)
comment = factories.CommentFactory(document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": False,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment):
"""Readers cannot comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader_own_comment(
link_role, link_reach, can_comment
):
"""User with reader role on a document has all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(
document=document, user=user if can_comment else None
)
assert comment.get_abilities(user) == {
"destroy": can_comment,
"update": can_comment,
"partial_update": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commentator(link_role, link_reach):
"""Commentators can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTATOR)],
)
comment = factories.CommentFactory(document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commentator_own_comment(link_role, link_reach):
"""Commentators have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTATOR)],
)
comment = factories.CommentFactory(document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor(link_role, link_reach):
"""Editors can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach):
"""Editors have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
}
def test_comment_get_abilities_user_admin():
"""Admins have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)])
comment = factories.CommentFactory(
document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
}
def test_comment_get_abilities_user_owner():
"""Owners have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)])
comment = factories.CommentFactory(
document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
}
@@ -123,7 +123,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -166,7 +166,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -183,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -200,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -217,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -234,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -271,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commentator", "editor", "administrator"],
}
@@ -288,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commentator", "editor", "administrator"],
}
@@ -305,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commentator", "editor", "administrator"],
}
+195 -106
View File
@@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth):
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(True, "restricted", "commentator"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "restricted", "commentator"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
(False, "authenticated", "commentator"),
],
)
def test_models_documents_get_abilities_forbidden(
@@ -164,6 +167,7 @@ def test_models_documents_get_abilities_forbidden(
"destroy": False,
"duplicate": False,
"favorite": False,
"comment": False,
"invite_owner": False,
"mask": False,
"media_auth": False,
@@ -171,8 +175,8 @@ def test_models_documents_get_abilities_forbidden(
"move": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"partial_update": False,
@@ -222,6 +226,7 @@ def test_models_documents_get_abilities_reader(
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -230,8 +235,77 @@ def test_models_documents_get_abilities_reader(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": is_authenticated,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definition"]
)
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_commentator(
is_authenticated, reach, django_assert_num_queries
):
"""
Check abilities returned for a document giving commentator role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="commentator")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": is_authenticated,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -287,6 +361,7 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -295,8 +370,8 @@ def test_models_documents_get_abilities_editor(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -341,6 +416,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": True,
@@ -349,8 +425,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -392,6 +468,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -400,8 +477,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"invite_owner": False,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -446,6 +523,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -454,8 +532,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -507,6 +585,8 @@ def test_models_documents_get_abilities_reader_user(
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_reach != "restricted"
and document.link_role in ["commentator", "editor"],
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -515,8 +595,72 @@ def test_models_documents_get_abilities_reader_user(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": access_from_link,
"restore": False,
"retrieve": True,
"tree": True,
"update": access_from_link,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definition"]
)
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
def test_models_documents_get_abilities_commentator_user(
ai_access_setting, django_assert_num_queries
):
"""Check abilities returned for the commentator of a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "commentator")])
access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor"
)
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -566,6 +710,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -574,8 +719,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -593,86 +738,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
}
@pytest.mark.parametrize(
"is_authenticated, is_creator,role,link_reach,link_role,can_destroy",
[
(True, False, "owner", "restricted", "editor", True),
(True, True, "owner", "restricted", "editor", True),
(True, False, "owner", "restricted", "reader", True),
(True, True, "owner", "restricted", "reader", True),
(True, False, "owner", "authenticated", "editor", True),
(True, True, "owner", "authenticated", "editor", True),
(True, False, "owner", "authenticated", "reader", True),
(True, True, "owner", "authenticated", "reader", True),
(True, False, "owner", "public", "editor", True),
(True, True, "owner", "public", "editor", True),
(True, False, "owner", "public", "reader", True),
(True, True, "owner", "public", "reader", True),
(True, False, "administrator", "restricted", "editor", True),
(True, True, "administrator", "restricted", "editor", True),
(True, False, "administrator", "restricted", "reader", True),
(True, True, "administrator", "restricted", "reader", True),
(True, False, "administrator", "authenticated", "editor", True),
(True, True, "administrator", "authenticated", "editor", True),
(True, False, "administrator", "authenticated", "reader", True),
(True, True, "administrator", "authenticated", "reader", True),
(True, False, "administrator", "public", "editor", True),
(True, True, "administrator", "public", "editor", True),
(True, False, "administrator", "public", "reader", True),
(True, True, "administrator", "public", "reader", True),
(True, False, "editor", "restricted", "editor", False),
(True, True, "editor", "restricted", "editor", True),
(True, False, "editor", "restricted", "reader", False),
(True, True, "editor", "restricted", "reader", True),
(True, False, "editor", "authenticated", "editor", False),
(True, True, "editor", "authenticated", "editor", True),
(True, False, "editor", "authenticated", "reader", False),
(True, True, "editor", "authenticated", "reader", True),
(True, False, "editor", "public", "editor", False),
(True, True, "editor", "public", "editor", True),
(True, False, "editor", "public", "reader", False),
(True, True, "editor", "public", "reader", True),
(True, False, "reader", "restricted", "editor", False),
(True, False, "reader", "restricted", "reader", False),
(True, False, "reader", "authenticated", "editor", False),
(True, True, "reader", "authenticated", "editor", True),
(True, False, "reader", "authenticated", "reader", False),
(True, False, "reader", "public", "editor", False),
(True, True, "reader", "public", "editor", True),
(True, False, "reader", "public", "reader", False),
(False, False, None, "restricted", "editor", False),
(False, False, None, "restricted", "reader", False),
(False, False, None, "authenticated", "editor", False),
(False, False, None, "authenticated", "reader", False),
(False, False, None, "public", "editor", False),
(False, False, None, "public", "reader", False),
],
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def test_models_documents_get_abilities_children_destroy( # noqa: PLR0913
is_authenticated,
is_creator,
role,
link_reach,
link_role,
can_destroy,
):
"""For a sub document, if a user can create children, he can destroy it."""
user = factories.UserFactory() if is_authenticated else AnonymousUser()
parent = factories.DocumentFactory(link_reach=link_reach, link_role=link_role)
document = factories.DocumentFactory(
link_reach=link_reach,
link_role=link_role,
parent=parent,
creator=user if is_creator else None,
)
if is_authenticated:
factories.UserDocumentAccessFactory(document=parent, user=user, role=role)
abilities = document.get_abilities(user)
assert abilities["destroy"] is can_destroy
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.parametrize(
"is_authenticated,reach",
@@ -1278,7 +1343,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
"reader",
{
"public": ["reader", "editor"],
"public": ["reader", "commentator", "editor"],
},
),
(
"public",
"commentator",
{
"public": ["commentator", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
@@ -1286,8 +1358,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"authenticated",
"reader",
{
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
},
),
(
"authenticated",
"commentator",
{
"authenticated": ["commentator", "editor"],
"public": ["commentator", "editor"],
},
),
(
@@ -1300,8 +1380,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"reader",
{
"restricted": None,
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
},
),
(
"restricted",
"commentator",
{
"restricted": None,
"authenticated": ["commentator", "editor"],
"public": ["commentator", "editor"],
},
),
(
@@ -1318,15 +1407,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
None,
{
"public": ["reader", "editor"],
"public": ["reader", "commentator", "editor"],
},
),
(
None,
"reader",
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commentator", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"restricted": None,
},
),
@@ -1334,8 +1423,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
None,
None,
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commentator", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"restricted": None,
},
),
@@ -44,25 +44,3 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
@pytest.mark.parametrize(
"sub,is_valid",
[
("valid_sub.@+-:=/", True),
("invalid süb", False),
(12345, True),
],
)
def test_models_users_sub_validator(sub, is_valid):
"""The "sub" field should be validated."""
user = factories.UserFactory()
user.sub = sub
if is_valid:
user.full_clean()
else:
with pytest.raises(
ValidationError,
match=("Enter a valid sub. This value should be ASCII only."),
):
user.full_clean()
+5 -1
View File
@@ -26,7 +26,11 @@ document_related_router.register(
viewsets.InvitationViewset,
basename="invitations",
)
document_related_router.register(
"comments",
viewsets.CommentViewSet,
basename="comments",
)
document_related_router.register(
"ask-for-access",
viewsets.DocumentAskForAccessViewSet,
-9
View File
@@ -1,9 +0,0 @@
"""Custom validators for the core app."""
from django.core.exceptions import ValidationError
def sub_validator(value):
"""Validate that the sub is ASCII only."""
if not value.isascii():
raise ValidationError("Enter a valid sub. This value should be ASCII only.")
@@ -9,7 +9,7 @@
},
"externalLinks": [
{
"label": "GitHub",
"label": "Github",
"href": "https://github.com/suitenumerique/docs/"
},
{
@@ -314,7 +314,7 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.locator('[data-test="ai-actions"]').click();
await page.getByRole('button', { name: 'AI' }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
@@ -400,11 +400,11 @@ test.describe('Doc Editor', () => {
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
if (!ai_transform && !ai_translate) {
await expect(page.locator('[data-test="ai-actions"]')).toBeHidden();
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
return;
}
await page.locator('[data-test="ai-actions"]').click();
await page.getByRole('button', { name: 'AI' }).click();
if (ai_transform) {
await expect(
@@ -25,7 +25,7 @@ test.describe('Doc Export', () => {
await createDoc(page, 'doc-editor', browserName, 1);
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
})
.click();
@@ -78,7 +78,8 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
exact: true,
})
.click();
@@ -129,7 +130,8 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
exact: true,
})
.click();
@@ -198,7 +200,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
})
.click();
@@ -275,7 +277,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
})
.click();
@@ -325,7 +327,8 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
exact: true,
})
.click();
@@ -387,7 +390,8 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
exact: true,
})
.click();
@@ -461,7 +465,8 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
exact: true,
})
.click();
@@ -532,7 +537,8 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'download',
exact: true,
})
.click();
@@ -44,9 +44,7 @@ test.describe('Doc Header', () => {
await expect(card.getByText('Owner ·')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open the document options' }),
).toBeVisible();
@@ -61,31 +59,6 @@ test.describe('Doc Header', () => {
await verifyDocName(page, 'Hello World');
});
test('it updates the title doc adding a leading emoji', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await docTitle.fill('👍 Hello Emoji World');
await docTitle.blur();
await verifyDocName(page, '👍 Hello Emoji World');
// Check the tree
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
await expect(docTree.getByLabel('Document emoji icon')).toBeVisible();
await expect(docTree.getByLabel('Simple document icon')).toBeHidden();
await page.getByTestId('home-button').click();
// Check the documents grid
const gridRow = await getGridRow(page, 'Hello Emoji World');
await expect(gridRow.getByLabel('Document emoji icon')).toBeVisible();
await expect(gridRow.getByLabel('Simple document icon')).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
@@ -142,9 +115,7 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
@@ -214,9 +185,7 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
@@ -276,9 +245,7 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
@@ -175,10 +175,10 @@ test.describe('Document search', () => {
// Expect to find the first doc
await expect(
page.getByRole('presentation').getByText(firstDocTitle),
page.getByRole('presentation').getByLabel(firstDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByText(secondDocTitle),
page.getByRole('presentation').getByLabel(secondDocTitle),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -196,13 +196,13 @@ test.describe('Document search', () => {
// Now there is a sub page - expect to have the focus on the current doc
await expect(
page.getByRole('presentation').getByText(secondDocTitle),
page.getByRole('presentation').getByLabel(secondDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByText(secondChildDocTitle),
page.getByRole('presentation').getByLabel(secondChildDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByText(firstDocTitle),
page.getByRole('presentation').getByLabel(firstDocTitle),
).toBeHidden();
});
});
@@ -315,81 +315,3 @@ test.describe('Doc Tree: Inheritance', () => {
await expect(docTree.getByText(docParent)).toBeVisible();
});
});
test.describe('Doc tree keyboard interactions (subdocs)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('navigates in the tree and actions with keyboard and toggles menu (options and create childDoc)', async ({
page,
browserName,
}) => {
const [rootDocTitle] = await createDoc(
page,
'doc-tree-keyboard',
browserName,
1,
);
await verifyDocName(page, rootDocTitle);
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'subdoc-tree-actions',
);
await verifyDocName(page, childTitle);
const docTree = page.getByTestId('doc-tree');
const actionsGroup = page.getByRole('toolbar', {
name: `Actions for ${childTitle}`,
});
await expect(actionsGroup).toBeVisible();
const moreOptions = actionsGroup.getByRole('button', {
name: `More options for ${childTitle}`,
});
await expect(moreOptions).toBeVisible();
await moreOptions.focus();
await expect(moreOptions).toBeFocused();
await page.keyboard.press('ArrowRight');
const addChild = actionsGroup.getByTestId('add-child-doc');
await expect(addChild).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(moreOptions).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByText('Copy link')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByText('Copy link')).toBeHidden();
await page.keyboard.press('ArrowRight');
await expect(addChild).toBeFocused();
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await page.keyboard.press('Enter');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const newChildDoc = (await response.json()) as { id: string };
const childButton = page.getByTestId(`doc-sub-page-item-${newChildDoc.id}`);
const childTreeItem = docTree
.locator('.c__tree-view--row')
.filter({ has: childButton })
.first();
await childTreeItem.focus();
});
});
@@ -21,7 +21,7 @@ test.describe('Footer', () => {
await expect(footer.getByAltText('Docs Logo')).toBeVisible();
await expect(footer.getByRole('heading', { name: 'Docs' })).toBeVisible();
await expect(footer.getByRole('link', { name: 'GitHub' })).toBeVisible();
await expect(footer.getByRole('link', { name: 'Github' })).toBeVisible();
await expect(footer.getByRole('link', { name: 'DINUM' })).toBeVisible();
await expect(footer.getByRole('link', { name: 'ZenDiS' })).toBeVisible();
@@ -136,11 +136,9 @@ export const getGridRow = async (page: Page, title: string) => {
const rows = docsGrid.getByRole('row');
const row = rows
.filter({
hasText: title,
})
.first();
const row = rows.filter({
hasText: title,
});
await expect(row).toBeVisible();
@@ -63,6 +63,5 @@ export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
await rootItem.hover();
await rootItem.getByTestId('add-child-doc').click();
await rootItem.getByRole('button', { name: 'add_box' }).click();
};
+1 -1
View File
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.55.0",
"@playwright/test": "1.54.2",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-config-impress": "*",
-1
View File
@@ -1,2 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_PUBLISH_AS_MIT=false
-13
View File
@@ -50,13 +50,6 @@ tokens.themes.default.theme = {
...tokens.themes.default.theme.colors,
...customColors,
},
font: {
...tokens.themes.default.theme.font,
families: {
base: 'sans-serif',
accent: 'sans-serif',
},
},
},
};
@@ -96,12 +89,6 @@ const dsfrTheme = {
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
font: {
families: {
base: 'Marianne',
accent: 'Marianne',
},
},
},
components: {
'la-gaufre': true,
+33
View File
@@ -0,0 +1,33 @@
import type { Config } from 'jest';
import nextJest from 'next/jest.js';
const createJestConfig = nextJest({
dir: './',
});
// Add any custom config to be passed to Jest
const config: Config = {
coverageProvider: 'v8',
moduleNameMapper: {
'^@/docs/(.*)$': '<rootDir>/src/features/docs/$1',
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jsdom',
};
const jestConfig = async () => {
const nextJestConfig = await createJestConfig(config)();
return {
...nextJestConfig,
moduleNameMapper: {
'\\.svg$': '<rootDir>/jest/mocks/svg.js',
'^.+\\.svg\\?url$': `<rootDir>/jest/mocks/fileMock.js`,
BlockNoteEditor: `<rootDir>/jest/mocks/ComponentMock.js`,
'custom-blocks': `<rootDir>/jest/mocks/ComponentMock.js`,
...nextJestConfig.moduleNameMapper,
},
};
};
export default jestConfig;
+4
View File
@@ -0,0 +1,4 @@
import '@testing-library/jest-dom';
import * as dotenv from 'dotenv';
dotenv.config({ path: './.env.test' });
@@ -0,0 +1,5 @@
import React from 'react';
export const ComponentMock = () => {
return <div>My component mocked</div>;
};
@@ -0,0 +1,16 @@
module.exports = {
src: '/img.jpg',
height: 40,
width: 40,
blurDataURL: 'data:image/png;base64,imagedata',
};
if (
(typeof exports.default === 'function' ||
(typeof exports.default === 'object' && exports.default !== null)) &&
typeof exports.default.__esModule === 'undefined'
) {
Object.defineProperty(exports.default, '__esModule', { value: true });
Object.assign(exports.default, exports);
module.exports = exports.default;
}
@@ -0,0 +1,3 @@
const nameMock = 'svg';
export default nameMock;
export const ReactComponent = 'svg';
+26 -28
View File
@@ -11,83 +11,81 @@
"lint": "tsc --noEmit && next lint",
"prettier": "prettier --write .",
"stylelint": "stylelint \"**/*.css\"",
"test": "vitest",
"test:watch": "vitest --watch"
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.37.0",
"@blocknote/core": "0.37.0",
"@blocknote/mantine": "0.37.0",
"@blocknote/react": "0.37.0",
"@blocknote/xl-docx-exporter": "0.37.0",
"@blocknote/xl-multi-column": "0.37.0",
"@blocknote/xl-pdf-exporter": "0.37.0",
"@blocknote/code-block": "0.35.0",
"@blocknote/core": "0.35.0",
"@blocknote/mantine": "0.35.0",
"@blocknote/react": "0.35.0",
"@blocknote/xl-docx-exporter": "0.35.0",
"@blocknote/xl-multi-column": "0.35.0",
"@blocknote/xl-pdf-exporter": "0.35.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.16.1",
"@gouvfr-lasuite/ui-kit": "0.11.0",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.2.3",
"@openfun/cunningham-react": "3.2.1",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "10.8.0",
"@tanstack/react-query": "5.85.6",
"@sentry/nextjs": "10.2.0",
"@tanstack/react-query": "5.84.1",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.4.0",
"i18next": "25.4.2",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.1",
"next": "15.4.7",
"posthog-js": "1.261.0",
"next": "15.4.6",
"posthog-js": "1.258.6",
"react": "*",
"react-aria-components": "1.12.1",
"react-aria-components": "1.11.0",
"react-dom": "*",
"react-i18next": "15.7.3",
"react-i18next": "15.6.1",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.5",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.8"
"zustand": "5.0.7"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.85.6",
"@tanstack/react-query-devtools": "5.84.1",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.8.0",
"@testing-library/jest-dom": "6.6.4",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "30.0.0",
"@types/lodash": "4.17.20",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.0.2",
"cross-env": "10.0.0",
"dotenv": "17.2.1",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
"jsdom": "26.1.0",
"jest": "30.0.5",
"jest-environment-jsdom": "30.0.5",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.23.1",
"stylelint": "16.23.0",
"stylelint-config-standard": "39.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.101.3",
"webpack": "5.101.0",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -1,101 +0,0 @@
@font-face {
font-family: Marianne;
src:
url('Marianne-Thin.woff2') format('woff2'),
url('Marianne-Thin.woff') format('woff');
font-weight: 100;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Thin_Italic.woff2') format('woff2'),
url('Marianne-Thin_Italic.woff') format('woff');
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Light.woff2') format('woff2'),
url('Marianne-Light.woff') format('woff');
font-weight: 300;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Light_Italic.woff2') format('woff2'),
url('Marianne-Light_Italic.woff') format('woff');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Regular.woff2') format('woff2'),
url('Marianne-Regular.woff') format('woff');
font-weight: 400;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Regular_Italic.woff2') format('woff2'),
url('Marianne-Regular_Italic.woff') format('woff');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Medium.woff2') format('woff2'),
url('Marianne-Medium.woff') format('woff');
font-weight: 500;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Medium_Italic.woff2') format('woff2'),
url('Marianne-Medium_Italic.woff') format('woff');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Bold.woff2') format('woff2'),
url('Marianne-Bold.woff') format('woff');
font-weight: 700;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Bold_Italic.woff2') format('woff2'),
url('Marianne-Bold_Italic.woff') format('woff');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-ExtraBold.woff2') format('woff2'),
url('Marianne-ExtraBold.woff') format('woff');
font-weight: 800;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
url('Marianne-ExtraBold_Italic.woff') format('woff');
font-weight: 800;
font-style: italic;
}
@@ -1,5 +1,3 @@
import { describe, expect, it } from 'vitest';
import { APIError, isAPIError } from '@/api';
describe('APIError', () => {
@@ -1,5 +1,3 @@
import { describe, expect, it } from 'vitest';
import { baseApiUrl } from '@/api';
describe('config', () => {
@@ -1,5 +1,4 @@
import fetchMock from 'fetch-mock';
import { beforeEach, describe, expect, it } from 'vitest';
import { fetchAPI } from '@/api';
@@ -1,6 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useAPIInfiniteQuery } from '@/api';
@@ -22,8 +21,8 @@ const createWrapper = () => {
describe('helpers', () => {
it('fetches and paginates correctly', async () => {
const mockAPI = vi
.fn<(params: { page: number; query: string }) => Promise<DummyResponse>>()
const mockAPI = jest
.fn<Promise<DummyResponse>, [{ page: number; query: string }]>()
.mockResolvedValueOnce({
results: [{ id: 1 }],
next: 'url?page=2',
@@ -1,5 +1,3 @@
import { describe, expect, it } from 'vitest';
import { errorCauses, getCSRFToken } from '@/api';
describe('utils', () => {
@@ -51,7 +51,7 @@ export const Box = styled('div')<BoxProps>`
${({ $cursor }) => $cursor && `cursor: ${$cursor};`}
${({ $direction }) => `flex-direction: ${$direction || 'column'};`}
${({ $display, as }) =>
`display: ${$display || (as?.match('span|input') ? 'inline-flex' : 'flex')};`}
`display: ${$display || as?.match('span|input') ? 'inline-flex' : 'flex'};`}
${({ $flex }) => $flex && `flex: ${$flex};`}
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
@@ -1,4 +1,3 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { Box } from '../Box';
@@ -2,7 +2,6 @@ import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
import {
Fragment,
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useRef,
@@ -12,10 +11,11 @@ import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDropdownKeyboardNav } from '@/hook/useDropdownKeyboardNav';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: string | ReactNode;
icon?: string;
label: string;
testId?: string;
value?: string;
@@ -81,28 +81,14 @@ export const DropdownMenu = ({
// Focus selected menu item when menu opens
useEffect(() => {
if (!isOpen || menuItemRefs.current.length === 0) {
return;
}
const selectedIndex = options.findIndex((option) => option.isSelected);
if (selectedIndex !== -1) {
setFocusedIndex(selectedIndex);
setTimeout(() => {
menuItemRefs.current[selectedIndex]?.focus();
}, 0);
return;
}
// Fallback: focus first enabled/visible option
const firstEnabledIndex = options.findIndex(
(opt) => opt.show !== false && !opt.disabled,
);
if (firstEnabledIndex !== -1) {
setFocusedIndex(firstEnabledIndex);
setTimeout(() => {
menuItemRefs.current[firstEnabledIndex]?.focus();
}, 0);
if (isOpen && menuItemRefs.current.length > 0) {
const selectedIndex = options.findIndex((option) => option.isSelected);
if (selectedIndex !== -1) {
setFocusedIndex(selectedIndex);
setTimeout(() => {
menuItemRefs.current[selectedIndex]?.focus();
}, 0);
}
}
}, [isOpen, options]);
@@ -170,6 +156,7 @@ export const DropdownMenu = ({
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
return (
<Fragment key={option.label}>
@@ -220,8 +207,17 @@ export const DropdownMenu = ({
}
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
}
${isFocused &&
css`
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
`}
`}
>
<Box
@@ -229,17 +225,14 @@ export const DropdownMenu = ({
$align="center"
$gap={spacingsTokens['base']}
>
{option.icon &&
(typeof option.icon === 'string' ? (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
) : (
option.icon
))}
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
)}
<Text $variation={isDisabled ? '400' : '1000'}>
{option.label}
</Text>
@@ -1,8 +1,6 @@
import { RefObject, useEffect } from 'react';
import { DropdownMenuOption } from '@/components/DropdownMenu';
import { useKeyboardActivation } from '../features/docs/doc-tree/hooks/useKeyboardActivation';
import { DropdownMenuOption } from '../DropdownMenu';
type UseDropdownKeyboardNavProps = {
isOpen: boolean;
@@ -21,22 +19,6 @@ export const useDropdownKeyboardNav = ({
setFocusedIndex,
onOpenChange,
}: UseDropdownKeyboardNavProps) => {
useKeyboardActivation(['Enter', ' '], isOpen, () => {
if (focusedIndex === -1) {
return;
}
const enabledIndices = options
.map((opt, i) => (opt.show !== false && !opt.disabled ? i : -1))
.filter((i) => i !== -1);
const selectedOpt = options[enabledIndices[focusedIndex]];
if (selectedOpt?.callback) {
onOpenChange(false);
void selectedOpt.callback();
}
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) {
@@ -44,42 +26,57 @@ export const useDropdownKeyboardNav = ({
}
const enabledIndices = options
.map((opt, i) => (opt.show !== false && !opt.disabled ? i : -1))
.filter((i) => i !== -1);
.map((option, index) =>
option.show !== false && !option.disabled ? index : -1,
)
.filter((index) => index !== -1);
switch (event.key) {
case 'ArrowDown': {
case 'ArrowDown':
event.preventDefault();
const nextIndex =
focusedIndex < enabledIndices.length - 1 ? focusedIndex + 1 : 0;
const nextEnabled = enabledIndices[nextIndex];
const nextEnabledIndex = enabledIndices[nextIndex];
setFocusedIndex(nextIndex);
menuItemRefs.current[nextEnabled]?.focus();
menuItemRefs.current[nextEnabledIndex]?.focus();
break;
}
case 'ArrowUp': {
case 'ArrowUp':
event.preventDefault();
const prevIndex =
focusedIndex > 0 ? focusedIndex - 1 : enabledIndices.length - 1;
const prevEnabled = enabledIndices[prevIndex];
const prevEnabledIndex = enabledIndices[prevIndex];
setFocusedIndex(prevIndex);
menuItemRefs.current[prevEnabled]?.focus();
menuItemRefs.current[prevEnabledIndex]?.focus();
break;
}
case 'Escape': {
case 'Enter':
case ' ':
event.preventDefault();
if (focusedIndex >= 0 && focusedIndex < enabledIndices.length) {
const selectedOptionIndex = enabledIndices[focusedIndex];
const selectedOption = options[selectedOptionIndex];
if (selectedOption && selectedOption.callback) {
onOpenChange(false);
void selectedOption.callback();
}
}
break;
case 'Escape':
event.preventDefault();
onOpenChange(false);
break;
}
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => document.removeEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [
isOpen,
focusedIndex,
@@ -1,9 +1,12 @@
import { css } from 'styled-components';
import { Box } from '../Box';
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
import { Icon } from '../Icon';
import { Text } from '../Text';
import {
DropdownMenu,
DropdownMenuOption,
} from '../dropdown-menu/DropdownMenu';
export type FilterDropdownProps = {
options: DropdownMenuOption[];
@@ -3,7 +3,7 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './DropdownMenu';
export * from './dropdown-menu/DropdownMenu';
export * from './quick-search';
export * from './Icon';
export * from './InfiniteScroll';
@@ -1,6 +1,5 @@
@import url('@gouvfr-lasuite/ui-kit/style');
@import url('./cunningham-tokens.css');
@import url('/assets/fonts/Marianne/Marianne-font.css');
:root {
/**
@@ -38,13 +37,6 @@
);
}
/**
* Modal
*/
.c__modal__backdrop {
z-index: 1000;
}
/**
* Tooltip
*/
@@ -143,8 +143,8 @@
--c--theme--font--weights--bold: 600;
--c--theme--font--weights--extrabold: 800;
--c--theme--font--weights--black: 900;
--c--theme--font--families--base: sans-serif;
--c--theme--font--families--accent: sans-serif;
--c--theme--font--families--base: marianne;
--c--theme--font--families--accent: marianne;
--c--theme--font--letterspacings--h1: normal;
--c--theme--font--letterspacings--h2: normal;
--c--theme--font--letterspacings--h3: normal;
@@ -298,9 +298,6 @@
--c--components--button--tertiary-text--color: var(
--c--theme--colors--primary-600
);
--c--components--button--tertiary-text--disabled: var(
--c--theme--colors--primary-300
);
--c--components--button--danger--color-hover: white;
--c--components--button--danger--background--color: var(
--c--theme--colors--danger-600
@@ -556,8 +553,6 @@
--c--theme--logo--widthHeader: 110px;
--c--theme--logo--widthFooter: 220px;
--c--theme--logo--alt: gouvernement logo;
--c--theme--font--families--base: marianne;
--c--theme--font--families--accent: marianne;
--c--components--la-gaufre: true;
--c--components--home-proconnect: true;
--c--components--favicon--ico: /assets/favicon-dsfr.ico;
@@ -153,7 +153,7 @@ export const tokens = {
extrabold: 800,
black: 900,
},
families: { base: 'sans-serif', accent: 'sans-serif' },
families: { base: 'Marianne', accent: 'Marianne' },
letterSpacings: {
h1: 'normal',
h2: 'normal',
@@ -266,7 +266,6 @@ export const tokens = {
'background--color-hover': '#eee',
'color-hover': '#000091',
color: '#313178',
disabled: '#CACAFB',
},
danger: {
'color-hover': 'white',
@@ -436,7 +435,6 @@ export const tokens = {
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
font: { families: { base: 'Marianne', accent: 'Marianne' } },
},
components: {
'la-gaufre': true,
@@ -1,28 +1,42 @@
import { Crisp } from 'crisp-sdk-web';
import fetchMock from 'fetch-mock';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { gotoLogout } from '../utils';
// Mock the Crisp service
vi.mock('@/services/Crisp', () => ({
terminateCrispSession: vi.fn(),
jest.mock('crisp-sdk-web', () => ({
...jest.requireActual('crisp-sdk-web'),
Crisp: {
isCrispInjected: jest.fn().mockReturnValue(true),
setTokenId: jest.fn(),
user: {
setEmail: jest.fn(),
},
session: {
reset: jest.fn(),
},
},
}));
describe('utils', () => {
afterEach(() => {
vi.clearAllMocks();
jest.clearAllMocks();
fetchMock.restore();
});
it('checks support session is terminated when logout', async () => {
const { terminateCrispSession } = await import('@/services/Crisp');
it('checks support session is terminated when logout', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
// Mock window.location.replace
const mockReplace = vi.fn();
Object.defineProperty(window, 'location', {
window.$crisp = true;
const propertyDescriptors = Object.getOwnPropertyDescriptors(window);
for (const key in propertyDescriptors) {
propertyDescriptors[key].configurable = true;
}
const clonedWindow = Object.defineProperties({}, propertyDescriptors);
Object.defineProperty(clonedWindow, 'location', {
value: {
...window.location,
replace: mockReplace,
replace: jest.fn(),
},
writable: true,
configurable: true,
@@ -30,9 +44,6 @@ describe('utils', () => {
gotoLogout();
expect(terminateCrispSession).toHaveBeenCalled();
expect(mockReplace).toHaveBeenCalledWith(
'http://test.jest/api/v1.0/logout/',
);
expect(Crisp.session.reset).toHaveBeenCalled();
});
});
@@ -1,14 +1,13 @@
import { renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { Fragment } from 'react';
import { beforeEach, describe, expect, vi } from 'vitest';
import { AbstractAnalytic } from '@/libs';
import { AppWrapper } from '@/tests/utils';
import { useAuth } from '../useAuth';
const trackEventMock = vi.fn();
const trackEventMock = jest.fn();
const flag = true;
class TestAnalytic extends AbstractAnalytic {
public constructor() {
@@ -32,11 +31,11 @@ class TestAnalytic extends AbstractAnalytic {
}
}
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
jest.mock('next/router', () => ({
...jest.requireActual('next/router'),
useRouter: () => ({
pathname: '/dashboard',
replace: vi.fn(),
replace: jest.fn(),
}),
}));
@@ -44,7 +43,7 @@ const dummyUser = { id: '123', email: 'test@example.com' };
describe('useAuth hook - trackEvent effect', () => {
beforeEach(() => {
vi.clearAllMocks();
jest.clearAllMocks();
fetchMock.restore();
});
@@ -1,38 +1,36 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { useRouter } from 'next/router';
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { AppWrapper } from '@/tests/utils';
import { useSaveDoc } from '../useSaveDoc';
vi.mock('next/router', () => ({
useRouter: vi.fn(),
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
vi.mock('@/docs/doc-versioning', () => ({
jest.mock('@/docs/doc-versioning', () => ({
KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions',
}));
vi.mock('@/docs/doc-management', async () => ({
useUpdateDoc: (
await vi.importActual('@/docs/doc-management/api/useUpdateDoc')
).useUpdateDoc,
jest.mock('@/docs/doc-management', () => ({
useUpdateDoc: jest.requireActual('@/docs/doc-management/api/useUpdateDoc')
.useUpdateDoc,
}));
describe('useSaveDoc', () => {
const mockRouterEvents = {
on: vi.fn(),
off: vi.fn(),
on: jest.fn(),
off: jest.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
jest.clearAllMocks();
fetchMock.restore();
(useRouter as Mock).mockReturnValue({
(useRouter as jest.Mock).mockReturnValue({
events: mockRouterEvents,
});
});
@@ -41,7 +39,7 @@ describe('useSaveDoc', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
@@ -62,8 +60,8 @@ describe('useSaveDoc', () => {
addEventListenerSpy.mockRestore();
});
it('should not save when canSave is false', () => {
vi.useFakeTimers();
it('should not save when canSave is false', async () => {
jest.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
@@ -82,19 +80,22 @@ describe('useSaveDoc', () => {
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
});
// Since canSave is false, no API call should be made
expect(fetchMock.calls().length).toBe(0);
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
vi.useRealTimers();
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
jest.useRealTimers();
});
it('should save when there are local changes', async () => {
vi.useFakeTimers();
jest.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
@@ -116,22 +117,21 @@ describe('useSaveDoc', () => {
});
act(() => {
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
// Switch to real timers to allow the mutation promise to resolve
vi.useRealTimers();
await waitFor(() => {
expect(fetchMock.lastCall()?.[0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
);
});
jest.useRealTimers();
});
it('should not save when there are no local changes', () => {
vi.useFakeTimers();
it('should not save when there are no local changes', async () => {
jest.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
@@ -148,20 +148,21 @@ describe('useSaveDoc', () => {
});
act(() => {
// Advance timers without triggering any local updates
vi.advanceTimersByTime(61000);
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
// Since there are no local changes, no API call should be made
expect(fetchMock.calls().length).toBe(0);
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
vi.useRealTimers();
jest.useRealTimers();
});
it('should cleanup event listeners on unmount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
@@ -1,14 +1,20 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
jest.mock('@/features/docs/doc-export/utils', () => ({
anything: true,
}));
jest.mock('@/features/docs/doc-export/components/ModalExport', () => ({
ModalExport: () => <span>ModalExport</span>,
}));
describe('useModuleExport', () => {
afterAll(() => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv;
});
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
jest.clearAllMocks();
jest.resetModules();
});
it('should return undefined when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => {
@@ -16,7 +22,7 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
}, 10000);
});
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
@@ -1,64 +0,0 @@
import { describe, expect, it } from 'vitest';
import { utilTable } from '../blocks-mapping/tablePDF';
/**
* Tests for utilTable utility.
* Scenarios covered:
* - All widths specified and below full width
* - Mix of known / unknown widths (fallback distribution)
* - All widths unknown
* - Widths exceeding full table width (clamping & scale=100)
* - Sum exceeding full width without unknowns (no division by zero side-effects)
*/
describe('utilTable', () => {
it('returns unchanged widths and correct scale when all widths are known and below full width', () => {
const input = [165, 200];
const { columnWidths, tableScale } = utilTable(730, input);
expect(columnWidths).toEqual(input); // unchanged
expect(tableScale).toBe(50);
});
it('distributes fallback width equally among unknown columns', () => {
const input: (number | undefined)[] = [100, undefined, 200, undefined];
const { columnWidths, tableScale } = utilTable(730, input);
expect(columnWidths).toEqual([100, 215, 200, 215]);
expect(tableScale).toBe(100); // fills full width exactly
});
it('handles all columns unknown', () => {
const input: (number | undefined)[] = [undefined, undefined];
const { columnWidths, tableScale } = utilTable(730, input);
expect(columnWidths).toEqual([365, 365]);
expect(tableScale).toBe(100);
});
it('clamps total width to full width when sum exceeds it (single large column)', () => {
const input = [800];
const { columnWidths, tableScale } = utilTable(730, input);
expect(columnWidths).toEqual([800]);
expect(tableScale).toBe(100);
});
it('clamps total width to full width when multiple columns exceed it', () => {
const input = [500, 300]; // sum = 800 > 730
const { columnWidths, tableScale } = utilTable(730, input);
expect(columnWidths).toEqual([500, 300]);
expect(tableScale).toBe(100);
});
it('does not assign fallback when there are no unknown widths (avoid division by zero impact)', () => {
const input = [400, 400];
const { columnWidths, tableScale } = utilTable(730, input);
expect(columnWidths).toEqual([400, 400]);
expect(tableScale).toBe(100);
});
it('computes proportional scale with custom fullWidth', () => {
const input = [100, 200]; // total 300
const { columnWidths, tableScale } = utilTable(1000, input);
expect(columnWidths).toEqual([100, 200]);
expect(tableScale).toBe(30);
});
});
@@ -13,7 +13,6 @@ import { StyleSheet, Text } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
const PIXELS_PER_POINT = 0.75;
const FULL_WIDTH = 730;
const styles = StyleSheet.create({
tableContainer: {
border: '1px solid #ddd',
@@ -48,10 +47,16 @@ export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['
true,
) as boolean[];
const { columnWidths, tableScale } = utilTable(
FULL_WIDTH,
blockContent.columnWidths,
/**
* Calculate the table scale based on the column widths.
*/
const columnWidths = blockContent.columnWidths.map((w) => w || 120);
const fullWidth = 730;
const totalWidth = Math.min(
columnWidths.reduce((sum, w) => sum + w, 0),
fullWidth,
);
const tableScale = (totalWidth * 100) / fullWidth;
return (
<Table style={[styles.tableContainer, { width: `${tableScale}%` }]}>
@@ -119,34 +124,3 @@ export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['
</Table>
);
};
/**
* Utility function to calculate the table column widths and scale.
* @param columnWidths - Array of column widths.
* @returns An object containing the resized column widths and the table scale.
*/
export const utilTable = (
fullWidth: number,
columnWidths: (number | undefined)[],
) => {
const totalColumnWidthKnown = columnWidths.reduce(
(sum: number, w) => sum + (w ?? 0),
0,
);
const nbColumnWidthUnknown = columnWidths.filter((w) => !w).length;
const fallbackWidth =
(fullWidth - totalColumnWidthKnown) / nbColumnWidthUnknown;
const columnWidthsResized = columnWidths.map((w) => w || fallbackWidth);
const totalWidth = Math.min(
columnWidthsResized.reduce((sum: number, w) => sum + w, 0),
fullWidth,
);
const tableScale = Math.round(((totalWidth * 100) / fullWidth) * 1000) / 1000;
return {
columnWidths: columnWidthsResized,
tableScale,
};
};
@@ -24,16 +24,4 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
interlinkingSearchInline: () => new Paragraph(''),
interlinkingLinkInline: inlineContentMappingInterlinkingLinkDocx,
},
styleMapping: {
...docxDefaultSchemaMappings.styleMapping,
// Switch to core PDF "Courier" font to avoid relying on GeistMono
// that is not available in italics
code: (enabled?: boolean) =>
enabled
? {
font: 'Courier New',
shading: { fill: 'DCDCDC' },
}
: {},
},
};
@@ -29,11 +29,4 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
interlinkingSearchInline: () => <></>,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkPDF,
},
styleMapping: {
...pdfDefaultSchemaMappings.styleMapping,
// Switch to core PDF "Courier" font to avoid relying on GeistMono
// that is not available in italics
code: (enabled?: boolean) =>
enabled ? { fontFamily: 'Courier', backgroundColor: '#dcdcdc' } : {},
},
};
@@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { Fragment } from 'react';
import { beforeEach, describe, expect, vi } from 'vitest';
import { AbstractAnalytic, Analytics } from '@/libs';
import { AppWrapper } from '@/tests/utils';
@@ -29,10 +28,14 @@ class TestAnalytic extends AbstractAnalytic {
}
}
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
jest.mock('@/features/docs/doc-export/', () => ({
ModalExport: () => <span>ModalExport</span>,
}));
jest.mock('next/router', () => ({
...jest.requireActual('next/router'),
useRouter: () => ({
push: vi.fn(),
push: jest.fn(),
}),
}));
@@ -1,69 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
useRouter: () => ({
push: vi.fn(),
}),
}));
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
describe('DocToolBox - Licence', () => {
afterAll(() => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv;
});
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
test('The export button is rendered when MIT version is deactivated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const { DocToolBox } = await import('../components/DocToolBox');
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = await screen.findByLabelText('Export the document');
await userEvent.click(optionsButton);
expect(
await screen.findByText(
'Download your document in a .docx or .pdf format.',
),
).toBeInTheDocument();
}, 10000);
test('The export button is not rendered when MIT version is activated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
const { DocToolBox } = await import('../components/DocToolBox');
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
expect(
screen.getByLabelText('Open the document options'),
).toBeInTheDocument();
expect(
screen.queryByLabelText('Export the document'),
).not.toBeInTheDocument();
});
});
@@ -241,7 +241,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
aria-label={t('Export the document')}
/>
)}
<DropdownMenu options={options}>
@@ -1,264 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { LinkReach, LinkRole, Role } from '../types';
import {
base64ToBlocknoteXmlFragment,
base64ToYDoc,
currentDocRole,
getDocLinkReach,
getDocLinkRole,
getEmojiAndTitle,
} from '../utils';
// Mock Y.js
vi.mock('yjs', () => ({
Doc: vi.fn().mockImplementation(() => ({
getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'),
})),
applyUpdate: vi.fn(),
}));
describe('doc-management utils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('currentDocRole', () => {
it('should return OWNER when destroy ability is true', () => {
const abilities = {
destroy: true,
accesses_manage: false,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.OWNER);
});
it('should return ADMIN when accesses_manage ability is true and destroy is false', () => {
const abilities = {
destroy: false,
accesses_manage: true,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.ADMIN);
});
it('should return EDITOR when partial_update ability is true and higher abilities are false', () => {
const abilities = {
destroy: false,
accesses_manage: false,
partial_update: true,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.EDITOR);
});
it('should return READER when no higher abilities are true', () => {
const abilities = {
destroy: false,
accesses_manage: false,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.READER);
});
});
describe('base64ToYDoc', () => {
it('should convert base64 string to Y.Doc', () => {
const base64String = 'dGVzdA=='; // "test" in base64
const mockYDoc = { getXmlFragment: vi.fn() };
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToYDoc(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(result).toBe(mockYDoc);
});
it('should handle empty base64 string', () => {
const base64String = '';
const mockYDoc = { getXmlFragment: vi.fn() };
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToYDoc(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(result).toBe(mockYDoc);
});
});
describe('base64ToBlocknoteXmlFragment', () => {
it('should convert base64 to Blocknote XML fragment', () => {
const base64String = 'dGVzdA==';
const mockYDoc = {
getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'),
};
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToBlocknoteXmlFragment(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(mockYDoc.getXmlFragment).toHaveBeenCalledWith('document-store');
expect(result).toBe('mocked-xml-fragment');
});
});
describe('getDocLinkReach', () => {
it('should return computed_link_reach when available', () => {
const doc = {
computed_link_reach: LinkReach.PUBLIC,
link_reach: LinkReach.RESTRICTED,
} as any;
const result = getDocLinkReach(doc);
expect(result).toBe(LinkReach.PUBLIC);
});
it('should fallback to link_reach when computed_link_reach is not available', () => {
const doc = {
link_reach: LinkReach.AUTHENTICATED,
} as any;
const result = getDocLinkReach(doc);
expect(result).toBe(LinkReach.AUTHENTICATED);
});
it('should handle undefined computed_link_reach', () => {
const doc = {
computed_link_reach: undefined,
link_reach: LinkReach.RESTRICTED,
} as any;
const result = getDocLinkReach(doc);
expect(result).toBe(LinkReach.RESTRICTED);
});
});
describe('getDocLinkRole', () => {
it('should return computed_link_role when available', () => {
const doc = {
computed_link_role: LinkRole.EDITOR,
link_role: LinkRole.READER,
} as any;
const result = getDocLinkRole(doc);
expect(result).toBe(LinkRole.EDITOR);
});
it('should fallback to link_role when computed_link_role is not available', () => {
const doc = {
link_role: LinkRole.READER,
} as any;
const result = getDocLinkRole(doc);
expect(result).toBe(LinkRole.READER);
});
it('should handle undefined computed_link_role', () => {
const doc = {
computed_link_role: undefined,
link_role: LinkRole.EDITOR,
} as any;
const result = getDocLinkRole(doc);
expect(result).toBe(LinkRole.EDITOR);
});
});
describe('getEmojiAndTitle', () => {
it('should extract emoji and title when emoji is present at the beginning', () => {
const title = '🚀 My Awesome Document';
const result = getEmojiAndTitle(title);
expect(result.emoji).toBe('🚀');
expect(result.titleWithoutEmoji).toBe('My Awesome Document');
});
it('should handle complex emojis with modifiers', () => {
const title = '👨‍💻 Developer Notes';
const result = getEmojiAndTitle(title);
expect(result.emoji).toBe('👨‍💻');
expect(result.titleWithoutEmoji).toBe('Developer Notes');
});
it('should handle emojis with skin tone modifiers', () => {
const title = '👍 Great Work!';
const result = getEmojiAndTitle(title);
expect(result.emoji).toBe('👍');
expect(result.titleWithoutEmoji).toBe('Great Work!');
});
it('should return null emoji and full title when no emoji is present', () => {
const title = 'Document Without Emoji';
const result = getEmojiAndTitle(title);
expect(result.emoji).toBeNull();
expect(result.titleWithoutEmoji).toBe('Document Without Emoji');
});
it('should handle empty title', () => {
const title = '';
const result = getEmojiAndTitle(title);
expect(result.emoji).toBeNull();
expect(result.titleWithoutEmoji).toBe('');
});
it('should handle title with only emoji', () => {
const title = '📝';
const result = getEmojiAndTitle(title);
expect(result.emoji).toBe('📝');
expect(result.titleWithoutEmoji).toBe('');
});
it('should handle title with emoji in the middle (should not extract)', () => {
const title = 'My 📝 Document';
const result = getEmojiAndTitle(title);
expect(result.emoji).toBeNull();
expect(result.titleWithoutEmoji).toBe('My 📝 Document');
});
it('should handle title with multiple emojis at the beginning', () => {
const title = '🚀📚 Project Documentation';
const result = getEmojiAndTitle(title);
expect(result.emoji).toBe('🚀');
expect(result.titleWithoutEmoji).toBe('📚 Project Documentation');
});
});
});
@@ -1,36 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Text, TextType } from '@/components';
type DocIconProps = TextType & {
emoji?: string | null;
defaultIcon: React.ReactNode;
};
export const DocIcon = ({
emoji,
defaultIcon,
$size = 'sm',
$variation = '1000',
$weight = '400',
...textProps
}: DocIconProps) => {
const { t } = useTranslation();
if (!emoji) {
return <>{defaultIcon}</>;
}
return (
<Text
{...textProps}
$size={$size}
$variation={$variation}
$weight={$weight}
aria-hidden="true"
aria-label={t('Document emoji icon')}
>
{emoji}
</Text>
);
};
@@ -1,18 +1,15 @@
import { DateTime } from 'luxon';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, getEmojiAndTitle, useTrans } from '@/docs/doc-management';
import { Doc, useTrans } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import PinnedDocumentIcon from '../assets/pinned-document.svg';
import SimpleFileIcon from '../assets/simple-document.svg';
import { DocIcon } from './DocIcon';
const ItemTextCss = css`
overflow: hidden;
text-overflow: ellipsis;
@@ -27,32 +24,17 @@ type SimpleDocItemProps = {
doc: Doc;
isPinned?: boolean;
showAccesses?: boolean;
onActivate?: () => void;
};
export const SimpleDocItem = ({
doc,
isPinned = false,
showAccesses = false,
onActivate,
}: SimpleDocItemProps) => {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { untitledDocument } = useTrans();
const router = useRouter();
const handleActivate = () => {
if (onActivate) {
onActivate();
} else {
router.push(`/docs/${doc.id}`);
}
};
const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle(
doc.title || untitledDocument,
);
return (
<Box
@@ -61,9 +43,6 @@ export const SimpleDocItem = ({
$overflow="auto"
$width="100%"
className="--docs--simple-doc-item"
role="presentation"
onClick={handleActivate}
aria-label={`${t('Open document')} ${doc.title || untitledDocument}`}
>
<Box
$direction="row"
@@ -74,7 +53,6 @@ export const SimpleDocItem = ({
`}
$padding={`${spacingsTokens['3xs']} 0`}
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
aria-hidden="true"
>
{isPinned ? (
<PinnedDocumentIcon
@@ -83,29 +61,23 @@ export const SimpleDocItem = ({
color={colorsTokens['primary-500']}
/>
) : (
<DocIcon
emoji={emoji}
defaultIcon={
<SimpleFileIcon
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
/>
}
$size="25px"
<SimpleFileIcon
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
/>
)}
</Box>
<Box $justify="center" $overflow="auto">
<Text
aria-describedby="doc-title"
aria-label={displayTitle}
aria-label={doc.title}
$size="sm"
$variation="1000"
$weight="500"
$css={ItemTextCss}
>
{displayTitle}
{doc.title || untitledDocument}
</Text>
{(!isDesktop || showAccesses) && (
<Box
@@ -113,7 +85,6 @@ export const SimpleDocItem = ({
$align="center"
$gap={spacingsTokens['3xs']}
$margin={{ top: '-2px' }}
aria-hidden="true"
>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
@@ -1,4 +1,3 @@
import emojiRegex from 'emoji-regex';
import * as Y from 'yjs';
import { Doc, LinkReach, LinkRole, Role } from './types';
@@ -31,19 +30,3 @@ export const getDocLinkReach = (doc: Doc): LinkReach => {
export const getDocLinkRole = (doc: Doc): LinkRole => {
return doc.computed_link_role ?? doc.link_role;
};
export const getEmojiAndTitle = (title: string) => {
// Use emoji-regex library for comprehensive emoji detection compatible with ES5
const regex = emojiRegex();
// Check if the title starts with an emoji
const match = title.match(regex);
if (match && title.startsWith(match[0])) {
const emoji = match[0];
const titleWithoutEmoji = title.substring(emoji.length).trim();
return { emoji, titleWithoutEmoji };
}
return { emoji: null, titleWithoutEmoji: title };
};
@@ -1,76 +0,0 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BoxButton, Icon } from '@/components';
type ButtonAddChildDocProps = {
onCreateChild: (params: { parentId: string }) => void;
parentId: string;
title?: string | null;
};
export const ButtonAddChildDoc = ({
onCreateChild,
parentId,
title,
}: ButtonAddChildDocProps) => {
const { t } = useTranslation();
const preventDefaultAndStopPropagation = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
},
[],
);
const isValidKeyEvent = useCallback((e: React.KeyboardEvent) => {
return e.key === 'Enter' || e.key === ' ';
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
preventDefaultAndStopPropagation(e);
void onCreateChild({ parentId });
},
[onCreateChild, parentId, preventDefaultAndStopPropagation],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isValidKeyEvent(e)) {
preventDefaultAndStopPropagation(e);
void onCreateChild({ parentId });
}
},
[
onCreateChild,
parentId,
preventDefaultAndStopPropagation,
isValidKeyEvent,
],
);
return (
<BoxButton
as="button"
tabIndex={-1}
data-testid="add-child-doc"
onClick={handleClick}
onKeyDown={handleKeyDown}
color="primary"
aria-label={t('Add child document to {{title}}', {
title: title || t('Untitled document'),
})}
$hasTransition={false}
>
<Icon
variant="filled"
$variation="800"
$theme="primary"
iconName="add_box"
aria-hidden="true"
/>
</BoxButton>
);
};
@@ -1,73 +0,0 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Icon } from '@/components';
type ButtonMoreOptionsProps = {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
title?: string | null;
className?: string;
};
export const ButtonMoreOptions = ({
isOpen,
onOpenChange,
title,
className = 'icon-button',
}: ButtonMoreOptionsProps) => {
const { t } = useTranslation();
const preventDefaultAndStopPropagation = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
},
[],
);
const isValidKeyEvent = useCallback((e: React.KeyboardEvent) => {
return e.key === 'Enter' || e.key === ' ';
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
preventDefaultAndStopPropagation(e);
onOpenChange?.(!isOpen);
},
[isOpen, onOpenChange, preventDefaultAndStopPropagation],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isValidKeyEvent(e)) {
preventDefaultAndStopPropagation(e);
onOpenChange?.(!isOpen);
}
},
[isOpen, onOpenChange, preventDefaultAndStopPropagation, isValidKeyEvent],
);
return (
<Icon
onClick={handleClick}
iconName="more_horiz"
variant="filled"
$theme="primary"
$variation="600"
className={className}
tabIndex={-1}
role="button"
aria-label={t('More options for {{title}}', {
title: title || t('Untitled document'),
})}
aria-haspopup="true"
aria-expanded={isOpen}
onKeyDown={handleKeyDown}
$css={css`
cursor: pointer;
`}
/>
);
};
@@ -5,24 +5,14 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
getEmojiAndTitle,
useTrans,
} from '@/features/docs/doc-management';
import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
import { useActionableMode } from '@/features/docs/doc-tree/hooks/useActionableMode';
import { useLoadChildrenOnOpen } from '@/features/docs/doc-tree/hooks/useLoadChildrenOnOpen';
import { Doc, useTrans } from '@/features/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
import SubPageIcon from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions';
@@ -43,23 +33,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { node } = props;
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
const isActive = node.isFocused || menuOpen || isSelectedNow;
const [actionsOpen, setActionsOpen] = useState(false);
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || '');
const displayTitle = titleWithoutEmoji || untitledDocument;
const handleActivate = () => {
treeContext?.treeData.setSelectedNode(doc);
router.push(`/docs/${doc.id}`);
};
const { actionsRef, onKeyDownCapture } = useActionableMode(node, menuOpen);
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
@@ -90,97 +68,61 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};
useKeyboardActivation(
['Enter', ' '],
isActive && !menuOpen,
handleActivate,
true,
);
useLoadChildrenOnOpen(
node.data.value.id,
node.isOpen,
treeContext?.treeData.handleLoadChildren,
treeContext?.treeData.setChildren,
(doc.children?.length ?? 0) > 0 || doc.childrenCount === 0,
);
// prepare the text for the screen reader
const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = isSelectedNow;
const ariaLabel = docTitle;
return (
<Box
className="--docs-sub-page-item"
draggable={doc.abilities.move && isDesktop}
$position="relative"
role="treeitem"
aria-label={ariaLabel}
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
$css={css`
/* Ensure the outline (handled by TreeView) matches the visual area */
.c__tree-view--node {
padding: ${spacingsTokens['3xs']};
border-radius: 4px;
}
background-color: ${actionsOpen
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
.light-doc-item-actions {
display: flex;
opacity: ${isActive || !isDesktop ? 1 : 0};
display: ${actionsOpen || !isDesktop ? 'flex' : 'none'};
position: absolute;
right: 0;
top: 0;
height: 100%;
z-index: 10;
background: ${isDesktop
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
}
.c__tree-view--node:hover,
.c__tree-view--node.isFocused {
background-color: var(--c--theme--colors--greyscale-100);
.c__tree-view--node.isSelected {
.light-doc-item-actions {
display: flex;
opacity: 1;
visibility: visible;
/* background: var(--c--theme--colors--greyscale-100); */
background: var(--c--theme--colors--greyscale-100);
}
}
.row.preview & {
background-color: inherit;
}
/* Ensure actions are visible when hovering the whole item container */
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
border-radius: 4px;
.light-doc-item-actions {
display: flex;
opacity: 1;
visibility: visible;
background: var(--c--theme--colors--greyscale-100);
}
}
`}
>
<TreeViewItem {...props} onClick={handleActivate}>
<BoxButton
onClick={(e) => {
e.stopPropagation();
handleActivate();
}}
tabIndex={-1}
<TreeViewItem
{...props}
onClick={() => {
treeContext?.treeData.setSelectedNode(props.node.data.value as Doc);
router.push(`/docs/${props.node.data.value.id}`);
}}
>
<Box
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
$width="100%"
$direction="row"
$gap={spacingsTokens['xs']}
role="button"
tabIndex={0}
$align="center"
$minHeight="24px"
data-testid={`doc-sub-page-item-${doc.id}`}
aria-label={`${t('Open document')} ${docTitle}`}
>
<Box $width="16px" $height="16px">
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
<SubPageIcon />
</Box>
<Box
@@ -195,7 +137,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
`}
>
<Text $css={ItemTextCss} $size="sm" $variation="1000">
{displayTitle}
{doc.title || untitledDocument}
</Text>
{doc.nb_accesses_direct >= 1 && (
<Icon
@@ -203,30 +145,25 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
iconName="group"
$size="16px"
$variation="400"
aria-hidden="true"
/>
)}
</Box>
</BoxButton>
</TreeViewItem>
<Box
ref={actionsRef}
onKeyDownCapture={onKeyDownCapture}
$direction="row"
$align="center"
className="light-doc-item-actions"
role="toolbar"
aria-label={`${t('Actions for')} ${docTitle}`}
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={setMenuOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
isOpen={actionsOpen}
onOpenChange={setActionsOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</Box>
</TreeViewItem>
</Box>
);
};
@@ -5,8 +5,8 @@ import {
useResponsive,
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
@@ -15,7 +15,6 @@ import { Doc, SimpleDocItem } from '@/docs/doc-management';
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
import { useMoveDoc } from '../api/useMove';
import { useRootTreeItem } from '../hooks/useRootTreeItem';
import { findIndexInTree } from '../utils';
import { DocSubPageItem } from './DocSubPageItem';
@@ -27,30 +26,21 @@ type DocTreeProps = {
export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const { isDesktop } = useResponsive();
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
const { t } = useTranslation();
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
);
const { mutate: moveDoc } = useMoveDoc();
const {
rootIsSelected,
rootActionsOpen,
setRootActionsOpen,
rootActionsRef,
onRootToolbarKeys,
handleRootFocus,
handleRootKeyDown,
handleRootClick,
handleRootActivate,
handleCreateSuccess,
} = useRootTreeItem();
const { data: tree, isFetching } = useDocTree(
{ docId: currentDoc.id },
{
docId: currentDoc.id,
},
{
enabled: !!!treeContext?.root?.id,
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
@@ -65,6 +55,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
});
treeContext?.treeData.handleMove(result);
};
/**
* This function resets the tree states.
*/
@@ -72,6 +63,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) {
return;
}
treeContext?.setRoot(null);
setInitialOpenState(undefined);
}, [treeContext]);
@@ -84,6 +76,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) {
return;
}
const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id);
if (index === -1 && currentDoc.id !== treeContext.root?.id) {
resetStateTree();
@@ -98,6 +91,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
return () => {
resetStateTree();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -145,20 +139,17 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
}, [currentDoc, treeContext]);
/**
* This is the main return of the component.
*/
if (!treeContext || !treeContext.root) {
return null;
}
const rootIsSelected =
treeContext.treeData.selectedNode?.id === treeContext.root.id;
return (
<Box
ref={setTreeRoot}
data-testid="doc-tree"
$height="100%"
role="tree"
aria-label={t('Document tree')}
$css={css`
.c__tree-view--container {
z-index: 1;
@@ -178,12 +169,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
>
<Box
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document')}: ${treeContext.root?.title || t('Untitled document')}`}
aria-selected={rootIsSelected}
tabIndex={0}
onFocus={handleRootFocus}
onKeyDown={handleRootKeyDown}
$css={css`
padding: ${spacingsTokens['2xs']};
border-radius: 4px;
@@ -204,8 +189,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
opacity: 1;
}
}
&:hover,
&:focus-within {
&:hover {
.doc-tree-root-item-actions {
opacity: 1;
}
@@ -217,57 +201,61 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
width: 100%;
`}
href={`/docs/${treeContext.root.id}`}
onClick={handleRootClick}
aria-label={`${t('Open root document')}: ${treeContext.root?.title || t('Untitled document')}`}
tabIndex={-1} // évite le double tabstop
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
treeContext.treeData.setSelectedNode(
treeContext.root ?? undefined,
);
router.push(`/docs/${treeContext?.root?.id}`);
}}
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem
doc={treeContext.root}
showAccesses={true}
onActivate={handleRootActivate}
/>
<SimpleDocItem doc={treeContext.root} showAccesses={true} />
<DocTreeItemActions
doc={treeContext.root}
onCreateSuccess={handleCreateSuccess}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: treeContext.root?.id ?? undefined,
};
treeContext?.treeData.addChild(null, newDoc);
}}
isOpen={rootActionsOpen}
isRoot={true}
onOpenChange={setRootActionsOpen}
actionsRef={rootActionsRef}
onKeyDownCapture={onRootToolbarKeys}
/>
</Box>
</StyledLink>
</Box>
</Box>
{initialOpenState &&
treeContext.treeData.nodes.length > 0 &&
treeRoot && (
<TreeView
dndRootElement={treeRoot}
initialOpenState={initialOpenState}
afterMove={handleMove}
selectedNodeId={
treeContext.treeData.selectedNode?.id ??
treeContext.initialTargetId ??
undefined
{initialOpenState && treeContext.treeData.nodes.length > 0 && (
<TreeView
initialOpenState={initialOpenState}
afterMove={handleMove}
selectedNodeId={
treeContext.treeData.selectedNode?.id ??
treeContext.initialTargetId ??
undefined
}
canDrop={({ parentNode }) => {
const parentDoc = parentNode?.data.value as Doc;
if (!parentDoc) {
return currentDoc.abilities.move && isDesktop;
}
canDrop={({ parentNode }) => {
const parentDoc = parentNode?.data.value as Doc;
if (!parentDoc) {
return currentDoc.abilities.move && isDesktop;
}
return parentDoc.abilities.move && isDesktop;
}}
canDrag={(node) => {
const doc = node.value as Doc;
return doc.abilities.move && isDesktop;
}}
rootNodeId={treeContext.root.id}
renderNode={DocSubPageItem}
/>
)}
return parentDoc.abilities.move && isDesktop;
}}
canDrag={(node) => {
const doc = node.value as Doc;
return doc.abilities.move && isDesktop;
}}
rootNodeId={treeContext.root.id}
renderNode={DocSubPageItem}
/>
)}
</Box>
);
};
@@ -8,7 +8,7 @@ import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon } from '@/components';
import { Box, BoxButton, Icon } from '@/components';
import {
Doc,
ModalRemoveDoc,
@@ -17,9 +17,6 @@ import {
useCreateChildDoc,
useDuplicateDoc,
} from '@/docs/doc-management';
import { ButtonAddChildDoc } from '@/features/docs/doc-tree/components/ButtonAddChildDoc';
import { ButtonMoreOptions } from '@/features/docs/doc-tree/components/ButtonMoreOptions';
import { useDropdownFocusManagement } from '@/features/docs/doc-tree/hooks/useDropdownFocusManagement';
import { useDetachDoc } from '../api/useDetach';
import MoveDocIcon from '../assets/doc-extract-bold.svg';
@@ -31,8 +28,6 @@ type DocTreeItemActionsProps = {
onCreateSuccess?: (newDoc: Doc) => void;
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement>;
onKeyDownCapture?: (e: React.KeyboardEvent) => void;
};
export const DocTreeItemActions = ({
@@ -42,8 +37,6 @@ export const DocTreeItemActions = ({
onCreateSuccess,
onOpenChange,
parentId,
actionsRef,
onKeyDownCapture,
}: DocTreeItemActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
@@ -150,59 +143,50 @@ export const DocTreeItemActions = ({
}
};
useDropdownFocusManagement({
isOpen: !!isOpen,
docId: doc.id,
actionsRef,
});
return (
<Box className="doc-tree-root-item-actions">
<Box
ref={actionsRef}
tabIndex={-1}
onKeyDownCapture={onKeyDownCapture}
$direction="row"
$align="center"
className="--docs--doc-tree-item-actions"
$gap="4px"
$css={css`
&:focus-within {
opacity: 1;
visibility: visible;
}
button:focus-visible,
[role='button']:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
background-color: var(--c--theme--colors--greyscale-050);
border-radius: 4px;
}
.icon-button:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
background-color: var(--c--theme--colors--greyscale-050);
border-radius: 4px;
}
`}
>
<DropdownMenu
options={options}
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<ButtonMoreOptions
isOpen={isOpen}
onOpenChange={onOpenChange}
title={doc.title}
<Icon
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onOpenChange?.(!isOpen);
}}
iconName="more_horiz"
variant="filled"
$theme="primary"
$variation="600"
/>
</DropdownMenu>
{doc.abilities.children_create && (
<ButtonAddChildDoc
onCreateChild={createChildDoc}
parentId={doc.id}
title={doc.title}
/>
<BoxButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
createChildDoc({
parentId: doc.id,
});
}}
color="primary"
>
<Icon
variant="filled"
$variation="800"
$theme="primary"
iconName="add_box"
/>
</BoxButton>
)}
</Box>
{deleteModal.isOpen && (
@@ -1,16 +0,0 @@
// Centralized selector constants for doc-tree hooks
export const SELECTORS = {
MODAL:
'[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content',
MODAL_SCROLLER: '.c__modal__scroller',
ACTIONS_CONTAINER: '.--docs--doc-tree-item-actions',
DOC_SUB_PAGE_ITEM: '.--docs-sub-page-item',
ROLE_MENU: '[role="menu"]',
ROLE_MENUITEM_OR_BUTTON:
'[role="menuitem"], button, [tabindex]:not([tabindex="-1"])',
FOCUSABLE:
'button, [role="button"], a[href], input, [tabindex]:not([tabindex="-1"])',
DATA_TESTID_DOC_SUB_PAGE_ITEM: 'doc-sub-page-item-',
DATA_TESTID_DOC_SUB_PAGE_ITEM_PREFIX: '[data-testid^="doc-sub-page-item-"]',
} as const;
@@ -1,104 +0,0 @@
import { useEffect, useRef } from 'react';
import { SELECTORS } from '../dom-selectors';
export type ActionableNodeLike = {
isFocused?: boolean;
focus?: () => void;
};
/**
* Hook to manage keyboard navigation for actionable items in a tree view.
*
* Disables navigation when dropdown menu is open to prevent conflicts.
*/
export const useActionableMode = (
node: ActionableNodeLike,
isMenuOpen?: boolean,
) => {
const actionsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const modalOpen = document.querySelector(SELECTORS.MODAL);
if (!node?.isFocused || modalOpen) {
return;
}
const toActions = (e: KeyboardEvent) => {
const modalOpen = document.querySelector(SELECTORS.MODAL);
if (modalOpen) {
return;
}
if (e.key === 'F2') {
const isAlreadyInActions = actionsRef.current?.contains(
document.activeElement,
);
if (isAlreadyInActions) {
return;
}
e.preventDefault();
const focusables = actionsRef.current?.querySelectorAll<HTMLElement>(
SELECTORS.FOCUSABLE,
);
const first = focusables?.[0];
first?.focus();
}
};
document.addEventListener('keydown', toActions, true);
return () => {
document.removeEventListener('keydown', toActions, true);
};
}, [node?.isFocused]);
const onKeyDownCapture = (e: React.KeyboardEvent) => {
if (isMenuOpen) {
return;
}
const modal = document.querySelector(SELECTORS.MODAL);
if (modal) {
return;
}
if (e.key === 'Escape') {
e.stopPropagation();
node?.focus?.();
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
const focusables = actionsRef.current?.querySelectorAll<HTMLElement>(
SELECTORS.FOCUSABLE,
);
if (!focusables || focusables.length === 0) {
return;
}
const currentIndex = Array.from(focusables).findIndex(
(el) => el === document.activeElement,
);
let nextIndex: number;
if (e.key === 'ArrowLeft') {
nextIndex = currentIndex > 0 ? currentIndex - 1 : focusables.length - 1;
} else {
nextIndex = currentIndex < focusables.length - 1 ? currentIndex + 1 : 0;
}
focusables[nextIndex]?.focus();
}
};
return { actionsRef, onKeyDownCapture };
};
@@ -1,101 +0,0 @@
import { useEffect } from 'react';
import { SELECTORS } from '../dom-selectors';
interface UseDropdownFocusManagementProps {
isOpen: boolean;
docId: string;
actionsRef?: React.RefObject<HTMLDivElement>;
}
export const useDropdownFocusManagement = ({
isOpen,
docId,
actionsRef,
}: UseDropdownFocusManagementProps) => {
// Focus management for dropdown menu opening
useEffect(() => {
if (!isOpen) {
return;
}
const timer = setTimeout(() => {
// Try to find menu in actions container first
const menuElement = actionsRef?.current
?.closest(SELECTORS.ACTIONS_CONTAINER)
?.querySelector(SELECTORS.ROLE_MENU);
if (menuElement) {
const firstMenuItem = menuElement.querySelector<HTMLElement>(
SELECTORS.ROLE_MENUITEM_OR_BUTTON,
);
if (firstMenuItem) {
firstMenuItem.focus();
return;
}
}
// Fallback: find any menu in document
const allMenus = document.querySelectorAll(SELECTORS.ROLE_MENU);
const lastMenu = allMenus[allMenus.length - 1];
if (lastMenu) {
const firstMenuItem = lastMenu.querySelector<HTMLElement>(
SELECTORS.ROLE_MENUITEM_OR_BUTTON,
);
if (firstMenuItem) {
firstMenuItem.focus();
}
}
}, 100);
return () => clearTimeout(timer);
}, [isOpen, actionsRef]);
// Focus management for returning to sub-document when menu closes
useEffect(() => {
if (isOpen) {
return;
}
const timer = setTimeout(() => {
const modal = document.querySelector(SELECTORS.MODAL);
if (modal) {
return;
}
// Only handle focus return if no modal is open
let subPageItem = actionsRef?.current?.closest(
SELECTORS.DOC_SUB_PAGE_ITEM,
);
// If not found, try to find by data-testid
if (!subPageItem) {
const testIdElement = document.querySelector(
`[data-testid="${SELECTORS.DATA_TESTID_DOC_SUB_PAGE_ITEM}${docId}"]`,
);
subPageItem =
testIdElement?.closest(SELECTORS.DOC_SUB_PAGE_ITEM) ||
testIdElement?.parentElement?.closest(SELECTORS.DOC_SUB_PAGE_ITEM);
}
// Focus the sub-document if found
if (subPageItem) {
const focusableElement = subPageItem.querySelector<HTMLElement>(
SELECTORS.DATA_TESTID_DOC_SUB_PAGE_ITEM_PREFIX,
);
if (focusableElement) {
focusableElement.focus();
} else {
(subPageItem as HTMLElement).focus();
}
return;
}
// Fallback: focus actions container
actionsRef?.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, [isOpen, actionsRef, docId]);
};

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