Compare commits

...

27 Commits

Author SHA1 Message Date
Anthony LC 4f2e07f949 🛂(frontend) limit input search to 254 characters
254 characters should be sufficient for most
of our usecases.
Limit input search to 254 characters to prevent
errors caused by overly long email addresses.
2025-09-10 16:11:16 +02:00
Anthony LC 8c1e95c587 (demo) change email from user to user.test in demo
When we create a new user in the demo environment,
the email address will now follow the format
user.test@example.com instead of user@example.com.
"user" was only 4 characters long, it created failing
tests in the e2e suite.
2025-09-10 16:11:16 +02:00
Manuel Raynaud 20161fd6db 🐛(backend) validate user search input data
Only the input data min length was checked. We also have to check the
mex length because the levenshtein dos not accept more than 254
characters and the email field has a max length of 254
2025-09-10 16:11:15 +02:00
dependabot[bot] e827cfeee1 Bump django from 5.2.4 to 5.2.6 in /src/backend (#1360)
⬆️(backend) bump django from 5.2.4 to 5.2.6
    
Bumps [django](https://github.com/django/django) from 5.2.4 to 5.2.6.
- [Commits](https://github.com/django/django/compare/5.2.4...5.2.6)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.6
  dependency-type: direct:production
...
    
Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 14:09:17 +00:00
Manuel Raynaud eab2a75bff ♻️(tilt) use hem dev-backend chart (#1340)
Remove usage of bitnami charts and use our own dev-backend charts
instead.
2025-09-10 11:43:30 +00:00
Cyril cd84751cb9 (frontend) fix major accessibility issues found by wave and axe
improves a11y by fixing multiple critical validation errors

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-10 10:26:47 +02:00
Anthony LC 1d20a8b0a7 💄(frontend) remove margin from modal title
Recent improvement changes the modal title with
a h1 tag, h1 tag adds margin by default.
We remove the margin from the h1 tag to stick to
the design system.
2025-09-10 09:35:54 +02:00
Cyril 8a310d004b (frontend) improve modal a11y: structure, labels, and title
added aria-label, structured text in p, and added title for better accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-10 08:10:35 +02:00
Cyril 9f9fae96e5 (frontend) unify tab focus style for better visual consistency
standardizes keyboard focus appearance to improve UI coherence

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-09 18:37:28 +02:00
Cyril 9cb2b6a6fb (frontend) improve accessibility of cdoc content with correct aria tags
added appropriate aria attributes and semantic tags to enhance accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-09 15:25:38 +02:00
Anthony LC 0a1eaa3c40 🚨(i18n) upgrade eslint to v9 with i18n package
We upgraded ESLint to version 9 in the i18n package,
which includes several improvements and fixes.
This change also involves updating the ESLint
configuration files to the new format and ensuring
compatibility with the latest ESLint features.
2025-09-09 12:27:32 +02:00
Anthony LC da72a1601a 🚨(y-provider) upgrade eslint to v9 with y-provider server
We upgraded ESLint to version 9 in the y-provider server,
which includes several improvements and fixes.
This change also involves updating the ESLint
configuration files to the new format and ensuring
compatibility with the latest ESLint features.
2025-09-09 12:27:32 +02:00
Anthony LC 9a51e02cd7 🚨(e2e) upgrade eslint to v9 with e2e app
We upgraded ESLint to version 9 in the e2e app,
which includes several improvements and fixes.
This change also involves updating the ESLint
configuration files to the new format and ensuring
compatibility with the latest ESLint features.
2025-09-09 12:27:31 +02:00
Anthony LC 4184c339eb 🚨(docs) upgrade eslint to v9 with Docs app
We upgraded ESLint to version 9 in the Docs app,
which includes several improvements and fixes.
This change also involves updating the ESLint
configuration files to the new format and ensuring
compatibility with the latest ESLint features.
2025-09-09 12:27:31 +02:00
Anthony LC 3688591dd1 ⬆️(dependency) upgrade eslint to v9
We upgraded ESLint to version 9 in the
eslint-config-impress package.
We rename it to eslint-plugin-docs.
2025-09-09 11:03:54 +02:00
Sylvain Zimmer 25783182b8 🗑️(convert) cleanup old content route
Remove rout /api/content, there is no more controller behind and is not
used anymore.
2025-09-08 14:25:10 +02:00
Sylvain Zimmer 80a62bcbc1 (convert) improve tests with stricter tests and less ipsum
Use real example data to run convert handler tests.
2025-09-08 14:24:11 +02:00
Sylvain Zimmer ede0a77665 ♻️(convert) reuse existing convert yprovider endpoint for content API
reuse convert service instead of renaming it in content
2025-09-08 14:23:42 +02:00
Sylvain Zimmer 8a8a1460e5 (api) add API route to fetch document content
This allows API users to process document content, enabling the
use of Docs as a headless CMS for instance, or any kind of document
processing. Fixes #1206.
2025-09-08 14:21:38 +02:00
Manuel Raynaud 0ac9f059b6 🔒️(backend) configure throttle on every viewsets
We want to configure the throttle on all doc's viewsets. In order to
monitor them, we use the MonitoredScopedRateThrottle class and a custom
callback caputing the message in sentry at the warning level.
2025-09-08 09:23:17 +02:00
Manuel Raynaud 179a84150b ⬆️(backend) upgrade django-lasuite to version 0.0.14
To use monitored throttling
2025-09-08 08:16:32 +02:00
Cyril 084d0c1089 (frontend) make delete buttons nvda-accessible
add aria-labels and include close button in title prop so NVDA announces actions

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-05 17:49:18 +02:00
Cyril c9a6c4d4c6 (frontend) improve placeholder contrast in blocknote for wcag
fixes insufficient contrast

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-05 16:27:33 +02:00
Quentin BEY 9db7d0af8d 🔒️(all) refactor Docker Hub login to use official GitHub actions
Replace custom Docker Hub authentication with standard, secure,
official GitHub actions for improved security and maintainability.

Uses officially supported actions that follow security best practices
and receive regular updates from GitHub.

Avoid unsecure handling of GitHub secrets.

Thanks to @lebaudantoine
2025-09-05 16:05:10 +02:00
Anthony LC 9135dff088 🔖(minor) release 3.6.0
Added:
- 👷(CI) add bundle size check job
- (frontend) use title first emoji as doc icon in tree

Changed:
- ♻️(docs-app) Switch from Jest tests to Vitest
- (frontend) improve accessibility:
  - 🌐(frontend) set html lang attribute dynamically
  - (frontend) inject language attribute to pdf export
  - (frontend) improve accessibility of search modal
  - (frontend) add correct attributes to decorative and interactive icons
  - 🎨(frontend) improve nav structure
  - ️(frontend) keyboard interaction with menu
  - (frontend) improve header accessibility
  - (frontend) improve accessibility for decorative images in editor
- ♻️(backend) fallback to email identifier when no name
- 🐛(backend) allow ASCII characters in user sub field
- ️(frontend) improve fallback width calculation

Fixed:
- 🐛(makefile) Windows compatibility fix for Docker volume mounting
- 🐛(minio) fix user permission error with Minio and Windows
- 🐛(frontend) fix export when quote block and inline code
- 🐛(frontend) fix base64 font
- 🐛(backend) allow editor to delete subpages
- 🐛(frontend) fix dnd conflict with tree and Blocknote
- 🐛(frontend) fix display bug on homepage
2025-09-05 09:11:58 +02:00
Anthony LC cc4c67d15b 🩹(frontend) add computed_link_reach on PUT link-configuration
By default a document is "restricted", a restricted
document cannot have a role "editor" or "reader".
With inheritance, a child document could have a computed
link reach different than "restricted" though.

We pass the computed link reach when we update the
link role, to be sure if follows the parent computed
link reach.
2025-09-05 09:11:58 +02:00
Anthony LC 63a2bde11e (e2e) fix e2e tests
After translating to french, a selector was not
accessible anymore because the aria label
was in english. We update the selector to use the
french aria label.
We increase as well the timeout of another test
that was flaky.
2025-09-04 16:15:08 +02:00
139 changed files with 2264 additions and 1048 deletions
+8 -2
View File
@@ -32,7 +32,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -65,7 +68,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
+38 -15
View File
@@ -10,34 +10,56 @@ and this project adheres to
### Added
- ✨(api) add API route to fetch document content #1206
- ♿(frontend) improve accessibility:
- #1349
- #1271
- #1341
### Changed
- 🔒️(backend) configure throttle on every viewsets #1343
- ⬆️ Bump eslint to V9 #1071
- ♿(frontend) improve accessibility:
- ♿(frontend) fix major accessibility issues reported by wave and axe #1344
- #1341
- ♻️(tilt) use helm dev-backend chart
## [3.6.0] - 2025-09-04
### Added
- 👷(CI) add bundle size check job #1268
- ✨(frontend) use title first emoji as doc icon in tree
- ✨(frontend) use title first emoji as doc icon in tree #1289
### Changed
- ♻️(docs-app) Switch from Jest tests to Vitest #1269
- ⚡️(frontend) improve accessibility:
- #1248
- #1235
- #1275
- #1255
- #1262
- #1244
- #1270
- #1282
- (frontend) improve accessibility:
- 🌐(frontend) set html lang attribute dynamically #1248
- ♿(frontend) inject language attribute to pdf export #1235
- ♿(frontend) improve accessibility of search modal #1275
- ♿(frontend) add correct attributes to icons #1255
- 🎨(frontend) improve nav structure #1262
- ♿️(frontend) keyboard interaction with menu #1244
- ♿(frontend) improve header accessibility #1270
- ♿(frontend) improve accessibility for decorative images in editor #1282
- #1338
- #1281
- ♻️(backend) fallback to email identifier when no name #1298
- 🐛(backend) allow ASCII characters in user sub field #1295
- 🐛(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
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1263
- 🐛(minio) fix user permission error with Minio and Windows #1263
- 🐛(frontend) fix export when quote block and inline code #1319
- 🐛(frontend) fix base64 font #1324
- 🐛(backend) allow editor to delete subpages #1296
- 🐛(backend) allow creator to delete subpages #1297
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
## [3.5.0] - 2025-07-31
@@ -709,7 +731,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.5.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.6.0...main
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
+1 -1
View File
@@ -440,6 +440,6 @@ bump-packages-version: ## bump the version of the project - VERSION_TYPE can be
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-plugin-docs/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
.PHONY: bump-packages-version
+3 -2
View File
@@ -39,9 +39,10 @@ docker_build(
]
)
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('impress-docs-backend-migrate', resource_deps=['dev-backend-postgres'])
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate', 'dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'dev-backend-minio:statefulset'])
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
migration = '''
+4
View File
@@ -3,3 +3,7 @@ BURST_THROTTLE_RATES="200/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Throttle
API_DOCUMENT_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min
-1
View File
@@ -27,7 +27,6 @@
"@hocuspocus/provider",
"@hocuspocus/server",
"docx",
"eslint",
"fetch-mock",
"node",
"node-fetch",
+8
View File
@@ -128,3 +128,11 @@ class ListDocumentFilter(DocumentFilter):
queryset_method = queryset.filter if bool(value) else queryset.exclude
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
class UserSearchFilter(django_filters.FilterSet):
"""
Custom filter for searching users.
"""
q = django_filters.CharFilter(min_length=5, max_length=254)
+21
View File
@@ -0,0 +1,21 @@
"""Throttling modules for the API."""
from rest_framework.throttling import UserRateThrottle
from sentry_sdk import capture_message
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
+90 -16
View File
@@ -1,6 +1,7 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import base64
import json
import logging
import uuid
@@ -33,16 +34,25 @@ from lasuite.malware_detection import malware_detection
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import authentication, choices, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError,
)
from core.services.converter_services import (
ValidationError as YProviderValidationError,
)
from core.services.converter_services import (
YdocConverter,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
from .throttling import UserListThrottleBurst, UserListThrottleSustained
logger = logging.getLogger(__name__)
@@ -136,18 +146,6 @@ class Pagination(drf.pagination.PageNumberPagination):
page_size_query_param = "page_size"
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
class UserViewSet(
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
@@ -178,12 +176,18 @@ class UserViewSet(
if self.action != "list":
return queryset
filterset = UserSearchFilter(
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
# Exclude all users already in the given document
if document_id := self.request.query_params.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
filter_data = filterset.form.cleaned_data
query = filter_data["q"]
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
@@ -360,6 +364,7 @@ class DocumentViewSet(
permission_classes = [
permissions.DocumentPermission,
]
throttle_scope = "document"
queryset = models.Document.objects.select_related("creator").all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
@@ -1505,6 +1510,69 @@ class DocumentViewSet(
status=status.HTTP_400_BAD_REQUEST,
)
@drf.decorators.action(
detail=True,
methods=["get"],
url_path="content",
name="Get document content in different formats",
)
def content(self, request, pk=None):
"""
Retrieve document content in different formats (JSON, Markdown, HTML).
Query parameters:
- content_format: The desired output format (json, markdown, html)
Returns:
JSON response with content in the specified format.
"""
document = self.get_object()
content_format = request.query_params.get("content_format", "json").lower()
if content_format not in {"json", "markdown", "html"}:
raise drf.exceptions.ValidationError(
"Invalid format. Must be one of: json, markdown, html"
)
# Get the base64 content from the document
content = None
base64_content = document.content
if base64_content is not None:
# Convert using the y-provider service
try:
yprovider = YdocConverter()
result = yprovider.convert(
base64.b64decode(base64_content),
"application/vnd.yjs.doc",
{
"markdown": "text/markdown",
"html": "text/html",
"json": "application/json",
}[content_format],
)
content = result
except YProviderValidationError as e:
return drf_response.Response(
{"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
)
except YProviderServiceUnavailableError as e:
logger.error("Error getting content for document %s: %s", pk, e)
return drf_response.Response(
{"error": "Failed to get document content"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"id": str(document.id),
"title": document.title,
"content": content,
"created_at": document.created_at,
"updated_at": document.updated_at,
}
)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
@@ -1555,6 +1623,7 @@ class DocumentAccessViewSet(
"document__depth",
)
resource_field_name = "document"
throttle_scope = "document_access"
@cached_property
def document(self):
@@ -1714,6 +1783,7 @@ class TemplateViewSet(
permissions.IsAuthenticatedOrSafe,
permissions.ResourceWithAccessPermission,
]
throttle_scope = "template"
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer
@@ -1804,6 +1874,7 @@ class TemplateAccessViewSet(
lookup_field = "pk"
permission_classes = [permissions.ResourceAccessPermission]
throttle_scope = "template_access"
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
@@ -1886,6 +1957,7 @@ class InvitationViewset(
permissions.CanCreateInvitationPermission,
permissions.ResourceWithAccessPermission,
]
throttle_scope = "invitation"
queryset = (
models.Invitation.objects.all()
.select_related("document")
@@ -1964,6 +2036,7 @@ class DocumentAskForAccessViewSet(
permissions.IsAuthenticated,
permissions.ResourceWithAccessPermission,
]
throttle_scope = "document_ask_for_access"
queryset = models.DocumentAskForAccess.objects.all()
serializer_class = serializers.DocumentAskForAccessSerializer
_document = None
@@ -2036,6 +2109,7 @@ class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
throttle_scope = "config"
def get(self, request):
"""
+1
View File
@@ -783,6 +783,7 @@ class Document(MP_Node, BaseModel):
"children_list": can_get,
"children_create": can_create_children,
"collaboration_auth": can_get,
"content": can_get,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": can_destroy,
+31 -12
View File
@@ -1,4 +1,4 @@
"""Converter services."""
"""Y-Provider API services."""
from base64 import b64encode
@@ -28,25 +28,44 @@ class YdocConverter:
# Note: Yprovider microservice accepts only raw token, which is not recommended
return f"Bearer {settings.Y_PROVIDER_API_KEY}"
def convert(self, text):
def _request(self, url, data, content_type, accept):
"""Make a request to the Y-Provider API."""
response = requests.post(
url,
data=data,
headers={
"Authorization": self.auth_header,
"Content-Type": content_type,
"Accept": accept,
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
response.raise_for_status()
return response
def convert(
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
):
"""Convert a Markdown text into our internal format using an external microservice."""
if not text:
raise ValidationError("Input text cannot be empty")
try:
response = requests.post(
response = self._request(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
data=text,
headers={
"Authorization": self.auth_header,
"Content-Type": "text/markdown",
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
text,
content_type,
accept,
)
response.raise_for_status()
return b64encode(response.content).decode("utf-8")
if accept == "application/vnd.yjs.doc":
return b64encode(response.content).decode("utf-8")
if accept in {"text/markdown", "text/html"}:
return response.text
if accept == "application/json":
return response.json()
raise ValidationError("Unsupported format")
except requests.RequestException as err:
raise ServiceUnavailableError(
"Failed to connect to conversion service",
@@ -4,6 +4,7 @@ Test document accesses API endpoints for users in impress's core app.
# pylint: disable=too-many-lines
import random
from unittest import mock
from uuid import uuid4
import pytest
@@ -1344,3 +1345,24 @@ def test_api_document_accesses_delete_owners_last_owner_child_team(
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
def test_api_document_accesses_throttling(settings):
"""Test api document accesses throttling."""
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_access"] = "2/minute"
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document_access", "warning"
)
@@ -824,3 +824,29 @@ def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user
response.json()["detail"]
== "You do not have permission to perform this action."
)
def test_api_document_invitations_throttling(settings):
"""Test api document ask for access throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = "2/minute"
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
factories.InvitationFactory(document=document, issuer=user)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope invitation", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = current_rate
@@ -1,6 +1,7 @@
"""Test API for document ask for access."""
import uuid
from unittest import mock
from django.core import mail
@@ -768,3 +769,35 @@ def test_api_documents_ask_for_access_accept_authenticated_non_root_document(rol
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 404
def test_api_document_ask_for_access_throttling(settings):
"""Test api document ask for access throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"][
"document_ask_for_access"
]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
"2/minute"
)
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
user = UserFactory()
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document_ask_for_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
current_rate
)
@@ -0,0 +1,176 @@
"""
Tests for Documents API endpoint in impress's core app: content
"""
import base64
from unittest.mock import patch
import pytest
import requests
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach, role",
[
("public", "reader"),
("public", "editor"),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_public(mock_content, reach, role):
"""Anonymous users should be allowed to access content of public documents."""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
mock_content.return_value = {"some": "data"}
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content),
"application/vnd.yjs.doc",
"application/json",
)
@pytest.mark.parametrize(
"reach, doc_role, user_role",
[
("restricted", "reader", "reader"),
("restricted", "reader", "editor"),
("restricted", "reader", "administrator"),
("restricted", "reader", "owner"),
("restricted", "editor", "reader"),
("restricted", "editor", "editor"),
("restricted", "editor", "administrator"),
("restricted", "editor", "owner"),
("authenticated", "reader", None),
("authenticated", "editor", None),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_not_public(mock_content, reach, doc_role, user_role):
"""Authenticated users need access to get non-public document content."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, link_role=doc_role)
mock_content.return_value = {"some": "data"}
# First anonymous request should fail
client = APIClient()
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mock_content.assert_not_called()
# Login and try again
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
# If restricted, we still should not have access
if user_role is not None:
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_content.assert_not_called()
# Create an access as a reader. This should unlock the access.
factories.UserDocumentAccessFactory(
document=document, user=user, role=user_role
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content),
"application/vnd.yjs.doc",
"application/json",
)
@pytest.mark.parametrize(
"content_format, accept",
[
("markdown", "text/markdown"),
("html", "text/html"),
("json", "application/json"),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_format(mock_content, content_format, accept):
"""Test that the content endpoint returns a specific format."""
document = factories.DocumentFactory(link_reach="public")
mock_content.return_value = {"some": "data"}
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/content/?content_format={content_format}"
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content), "application/vnd.yjs.doc", accept
)
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_invalid_format(mock_request):
"""Test that the content endpoint rejects invalid formats."""
document = factories.DocumentFactory(link_reach="public")
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/content/?content_format=invalid"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_request.assert_not_called()
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_yservice_error(mock_request):
"""Test that service errors are handled properly."""
document = factories.DocumentFactory(link_reach="public")
mock_request.side_effect = requests.RequestException()
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
mock_request.assert_called_once()
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_nonexistent_document(mock_request):
"""Test that accessing a nonexistent document returns 404."""
client = APIClient()
response = client.get(
"/api/v1.0/documents/00000000-0000-0000-0000-000000000000/content/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_request.assert_not_called()
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_empty_document(mock_request):
"""Test that accessing an empty document returns empty content."""
document = factories.DocumentFactory(link_reach="public", content="")
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] is None
mock_request.assert_not_called()
@@ -427,3 +427,20 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
def test_api_documents_list_throttling(settings):
"""Test api documents throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/documents/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
@@ -37,6 +37,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_list": True,
"collaboration_auth": True,
"cors_proxy": True,
"content": True,
"descendants": True,
"destroy": False,
"duplicate": False,
@@ -113,6 +114,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": False,
# Anonymous user can't favorite a document even with read access
@@ -218,6 +220,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": True,
"favorite": True,
@@ -300,6 +303,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": True,
"favorite": True,
@@ -494,6 +498,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": access.role in ["administrator", "owner"],
"duplicate": True,
"favorite": True,
@@ -81,6 +81,7 @@ def test_api_documents_trashbin_format():
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": True,
"duplicate": True,
"favorite": True,
@@ -3,6 +3,7 @@ Test template accesses API endpoints for users in impress's core app.
"""
import random
from unittest import mock
from uuid import uuid4
import pytest
@@ -773,3 +774,26 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
def test_api_template_accesses_throttling(settings):
"""Test api template accesses throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = "2/minute"
template = factories.TemplateFactory()
user = factories.UserFactory()
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = current_rate
@@ -218,3 +218,20 @@ def test_api_templates_list_order_param():
assert response_template_ids == templates_ids, (
"created_at values are not sorted from oldest to newest"
)
def test_api_template_throttling(settings):
"""Test api template throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/templates/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate
+18
View File
@@ -3,6 +3,7 @@ Test config API endpoints in the Impress core app.
"""
import json
from unittest.mock import patch
from django.test import override_settings
@@ -174,3 +175,20 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings
theme_customization = json.load(f)
assert content["theme_customization"] == theme_customization
def test_api_config_throttling(settings):
"""Test api config throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/config/")
assert response.status_code == 200
with patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/config/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope config", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"] = current_rate
+27 -4
View File
@@ -194,18 +194,41 @@ def test_api_users_list_query_short_queries():
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 2)."]
}
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 4)."]
}
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert len(response.json()) == 2
def test_api_users_list_query_long_queries():
"""
Queries longer than 255 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
query = "a" * 244
response = client.get(f"/api/v1.0/users/?q={query}@example.com")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at most 254 characters (it has 256)."]
}
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
@@ -161,6 +161,7 @@ def test_models_documents_get_abilities_forbidden(
"collaboration_auth": False,
"descendants": False,
"cors_proxy": False,
"content": False,
"destroy": False,
"duplicate": False,
"favorite": False,
@@ -224,6 +225,7 @@ def test_models_documents_get_abilities_reader(
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": is_authenticated,
"favorite": is_authenticated,
@@ -289,6 +291,7 @@ def test_models_documents_get_abilities_editor(
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": is_authenticated,
"favorite": is_authenticated,
@@ -343,6 +346,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": True,
"duplicate": True,
"favorite": True,
@@ -394,6 +398,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": True,
"favorite": True,
@@ -448,6 +453,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": True,
"favorite": True,
@@ -509,6 +515,7 @@ def test_models_documents_get_abilities_reader_user(
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": True,
"favorite": True,
@@ -568,6 +575,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": True,
"favorite": True,
@@ -1,4 +1,4 @@
"""Test converter services."""
"""Test y-provider services."""
from base64 import b64decode
from unittest.mock import MagicMock, patch
@@ -84,6 +84,42 @@ def test_convert_full_integration(mock_post, settings):
headers={
"Authorization": "Bearer test-key",
"Content-Type": "text/markdown",
"Accept": "application/vnd.yjs.doc",
},
timeout=5,
verify=False,
)
@patch("requests.post")
def test_convert_full_integration_with_specific_headers(mock_post, settings):
"""Test successful conversion with specific content type and accept headers."""
settings.Y_PROVIDER_API_BASE_URL = "http://test.com/"
settings.Y_PROVIDER_API_KEY = "test-key"
settings.CONVERSION_API_ENDPOINT = "conversion-endpoint"
settings.CONVERSION_API_TIMEOUT = 5
settings.CONVERSION_API_SECURE = False
converter = YdocConverter()
expected_response = "# Test Document\n\nThis is test content."
mock_response = MagicMock()
mock_response.text = expected_response
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
result = converter.convert(
b"test_content", "application/vnd.yjs.doc", "text/markdown"
)
assert result == expected_response
mock_post.assert_called_once_with(
"http://test.com/conversion-endpoint/",
data=b"test_content",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "application/vnd.yjs.doc",
"Accept": "text/markdown",
},
timeout=5,
verify=False,
+11 -3
View File
@@ -8,11 +8,19 @@ NB_OBJECTS = {
DEV_USERS = [
{"username": "impress", "email": "impress@impress.world", "language": "en-us"},
{"username": "user-e2e-webkit", "email": "user@webkit.test", "language": "en-us"},
{"username": "user-e2e-firefox", "email": "user@firefox.test", "language": "en-us"},
{
"username": "user-e2e-webkit",
"email": "user.test@webkit.test",
"language": "en-us",
},
{
"username": "user-e2e-firefox",
"email": "user.test@firefox.test",
"language": "en-us",
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.test",
"email": "user.test@chromium.test",
"language": "en-us",
},
]
@@ -119,8 +119,8 @@ def create_demo(stdout):
first_name = random.choice(first_names)
queue.push(
models.User(
admin_email=f"user{i:d}@example.com",
email=f"user{i:d}@example.com",
admin_email=f"user.test{i:d}@example.com",
email=f"user.test{i:d}@example.com",
password="!",
is_superuser=False,
is_active=True,
@@ -33,9 +33,9 @@ def test_commands_create_demo():
# assert dev users have doc accesses
user = models.User.objects.get(email="impress@impress.world")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@webkit.test")
user = models.User.objects.get(email="user.test@webkit.test")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@firefox.test")
user = models.User.objects.get(email="user.test@firefox.test")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@chromium.test")
user = models.User.objects.get(email="user.test@chromium.test")
assert models.DocumentAccess.objects.filter(user=user).exists()
+41
View File
@@ -356,6 +356,9 @@ class Base(Configuration):
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_CLASSES": [
"lasuite.drf.throttling.MonitoredScopedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"user_list_sustained": values.Value(
default="180/hour",
@@ -367,8 +370,46 @@ class Base(Configuration):
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
environ_prefix=None,
),
"document": values.Value(
default="80/minute",
environ_name="API_DOCUMENT_THROTTLE_RATE",
environ_prefix=None,
),
"document_access": values.Value(
default="50/minute",
environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"template": values.Value(
default="30/minute",
environ_name="API_TEMPLATE_THROTTLE_RATE",
environ_prefix=None,
),
"template_access": values.Value(
default="30/minute",
environ_name="API_TEMPLATE_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"invitation": values.Value(
default="60/minute",
environ_name="API_INVITATION_THROTTLE_RATE",
environ_prefix=None,
),
"document_ask_for_access": values.Value(
default="30/minute",
environ_name="API_DOCUMENT_ASK_FOR_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"config": values.Value(
default="30/minute",
environ_name="API_CONFIG_THROTTLE_RATE",
environ_prefix=None,
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
"core.api.throttling.sentry_monitoring_throttle_failure"
)
SPECTACULAR_SETTINGS = {
"TITLE": "Impress API",
+3 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.5.0"
version = "3.6.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -34,12 +34,12 @@ dependencies = [
"django-countries==7.6.1",
"django-csp==4.0",
"django-filter==25.1",
"django-lasuite[all]==0.0.11",
"django-lasuite[all]==0.0.14",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.4",
"django==5.2.6",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
+2 -2
View File
@@ -10,13 +10,13 @@ WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/impress/package.json ./apps/impress/package.json
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
COPY ./src/frontend/packages/eslint-plugin-docs/package.json ./packages/eslint-plugin-docs/package.json
RUN yarn install --frozen-lockfile
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/.prettierrc.js ./.prettierrc.js
COPY ./src/frontend/packages/eslint-config-impress ./packages/eslint-config-impress
COPY ./src/frontend/packages/eslint-plugin-docs ./packages/eslint-plugin-docs
COPY ./src/frontend/apps/impress ./apps/impress
### ---- Front-end builder image ----
-9
View File
@@ -1,9 +0,0 @@
module.exports = {
root: true,
extends: ['impress/playwright'],
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
ignorePatterns: ['node_modules'],
};
@@ -5,14 +5,19 @@ import { keyCloakSignIn } from './utils-common';
const saveStorageState = async (
browserConfig: FullProject<unknown, unknown>,
) => {
const browserName = browserConfig?.name || 'chromium';
if (!browserConfig) {
throw new Error('No browser config found');
}
const { storageState, ...useConfig } = browserConfig?.use;
const browserName = browserConfig.name || 'chromium';
const { storageState, ...useConfig } = browserConfig.use;
const browser = await chromium.launch();
const context = await browser.newContext(useConfig);
const page = await context.newPage();
try {
// eslint-disable-next-line playwright/no-networkidle
await page.goto('/', { waitUntil: 'networkidle' });
await page.content();
await expect(page.getByText('Docs').first()).toBeVisible();
@@ -45,11 +50,9 @@ const saveStorageState = async (
};
async function globalSetup(config: FullConfig) {
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const chromeConfig = config.projects.find((p) => p.name === 'chromium')!;
const firefoxConfig = config.projects.find((p) => p.name === 'firefox')!;
const webkitConfig = config.projects.find((p) => p.name === 'webkit')!;
/* eslint-enable @typescript-eslint/no-non-null-assertion */
await saveStorageState(chromeConfig);
await saveStorageState(webkitConfig);
@@ -50,7 +50,7 @@ test.describe('Config', () => {
await expect(image).toBeVisible();
// Wait for the media-check to be processed
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
// Check src of image
@@ -45,8 +45,8 @@ test.describe('Doc Create', () => {
})
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toHaveText('');
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('', { timeout: 10000 });
await expect(
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
@@ -67,8 +67,8 @@ test.describe('Doc Create', () => {
.getByText('New sub-doc')
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toHaveText('');
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('', { timeout: 10000 });
await expect(
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
@@ -1,3 +1,4 @@
/* eslint-disable playwright/no-conditional-expect */
import path from 'path';
import { chromium, expect, test } from '@playwright/test';
@@ -214,7 +215,6 @@ test.describe('Doc Editor', () => {
});
test('it saves the doc when we quit pages', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
// Check the first doc
@@ -226,9 +226,13 @@ test.describe('Doc Editor', () => {
await editor.fill('Hello World Doc persisted 2');
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
await page.waitForTimeout(1000);
const urlDoc = page.url();
await page.goto(urlDoc);
// Wait for editor to load
await expect(editor).toBeVisible();
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
@@ -279,7 +283,7 @@ test.describe('Doc Editor', () => {
await expect(image).toBeVisible();
// Wait for the media-check to be processed
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
// Check src of image
@@ -397,8 +401,6 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
return;
@@ -425,8 +427,6 @@ test.describe('Doc Editor', () => {
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
/* eslint-enable playwright/no-conditional-expect */
/* eslint-enable playwright/no-conditional-in-test */
});
});
@@ -467,12 +467,14 @@ test.describe('Doc Editor', () => {
await expect(
page.getByRole('button', {
name: 'Download',
exact: true,
}),
).toBeVisible();
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
@@ -29,12 +29,7 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page
.locator('div')
.filter({ hasText: /^Download$/ })
.first(),
).toBeVisible();
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx or .pdf format.'),
).toBeVisible();
@@ -43,9 +38,11 @@ test.describe('Doc Export', () => {
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Close the modal' }),
page.getByRole('button', {
name: 'Close the download modal',
}),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
});
test('it exports the doc with pdf line break', async ({
@@ -86,12 +83,7 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
@@ -136,23 +128,13 @@ test.describe('Doc Export', () => {
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Docx' }).click();
await expect(
page.getByRole('button', {
name: 'Download',
exact: true,
}),
).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
@@ -218,11 +200,7 @@ test.describe('Doc Export', () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await expect(
page.getByRole('button', {
name: 'Download',
}),
).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const responseCorsPromise = page.waitForResponse(
(response) =>
@@ -233,11 +211,7 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
})
.click();
void page.getByTestId('doc-export-download-button').click();
const responseCors = await responseCorsPromise;
expect(responseCors.ok()).toBe(true);
@@ -279,21 +253,13 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByRole('button', {
name: 'Download',
}),
).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
@@ -330,22 +296,14 @@ test.describe('Doc Export', () => {
.click();
await expect(
page.getByRole('button', {
name: 'Download',
exact: true,
}),
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
@@ -392,22 +350,14 @@ test.describe('Doc Export', () => {
.click();
await expect(
page.getByRole('button', {
name: 'Download',
exact: true,
}),
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
@@ -443,14 +393,9 @@ test.describe('Doc Export', () => {
})
.click();
await page.waitForURL('**/docs/**', {
timeout: 10000,
waitUntil: 'domcontentloaded',
});
const input = page.getByLabel('doc title input');
const input = page.locator('.--docs--doc-title-input[role="textbox"]');
await expect(input).toBeVisible();
await expect(input).toHaveText('');
await expect(input).toHaveText('', { timeout: 10000 });
await input.click();
await input.fill(randomDocFrench);
await input.blur();
@@ -461,7 +406,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'Export the document',
name: 'Exporter le document',
})
.click();
@@ -469,12 +414,7 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDocFrench}.pdf`);
});
void page
.getByRole('button', {
name: 'Télécharger',
exact: true,
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDocFrench}.pdf`);
@@ -509,19 +449,23 @@ test.describe('Doc Export', () => {
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
await page
.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
)
.fill('interlink-child');
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await page
.locator('.quick-search-container')
.getByText('interlink-child')
.click();
await input.fill('export-interlink');
const interlink = page.getByRole('link', {
name: 'interlink-child',
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('link', {
name: randomDoc,
});
await expect(interlink).toBeVisible();
@@ -536,12 +480,7 @@ test.describe('Doc Export', () => {
})
.click();
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
@@ -549,6 +488,6 @@ test.describe('Doc Export', () => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('interlink-child'); // This is the pdf text
expect(pdfData.text).toContain(randomDoc);
});
});
@@ -36,9 +36,8 @@ test.describe('Doc grid dnd', () => {
expect(draggableBoundingBox).toBeDefined();
expect(dropZoneBoundingBox).toBeDefined();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!draggableBoundingBox || !dropZoneBoundingBox) {
throw new Error('Impossible de déterminer la position des éléments');
throw new Error('Unable to determine the position of the elements');
}
await page.mouse.move(
@@ -86,9 +85,8 @@ test.describe('Doc grid dnd', () => {
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
throw new Error('Impossible de déterminer la position des éléments');
throw new Error('Unable to determine the position of the elements');
}
await page.mouse.move(
@@ -137,9 +135,8 @@ test.describe('Doc grid dnd', () => {
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
throw new Error('Impossible de déterminer la position des éléments');
throw new Error('Unable to determine the position of the elements');
}
await page.mouse.move(
@@ -80,9 +80,7 @@ test.describe('Documents Grid mobile', () => {
hasText: 'My mocked document',
});
await expect(
row.locator('[aria-describedby="doc-title"]').nth(0),
).toHaveText('My mocked document');
await expect(row.getByTestId('doc-title')).toHaveText('My mocked document');
});
});
@@ -149,7 +147,7 @@ test.describe('Document grid item options', () => {
await page
.getByRole('button', {
name: 'Confirm deletion',
name: 'Delete document',
})
.click();
@@ -295,7 +293,7 @@ test.describe('Documents Grid', () => {
docs = result.results as SmallDoc[];
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(page.locator('h4').getByText('All docs')).toBeVisible();
await expect(page.locator('h2').getByText('All docs')).toBeVisible();
const thead = page.getByTestId('docs-grid-header');
await expect(thead.getByText(/Name/i)).toBeVisible();
@@ -25,7 +25,7 @@ test.describe('Doc Header', () => {
'It is the card information about the document.',
);
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
const docTitle = card.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
@@ -54,7 +54,7 @@ test.describe('Doc Header', () => {
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
@@ -66,7 +66,7 @@ test.describe('Doc Header', () => {
browserName,
}) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('👍 Hello Emoji World');
await docTitle.blur();
@@ -100,7 +100,7 @@ test.describe('Doc Header', () => {
await page
.getByRole('button', {
name: 'Confirm deletion',
name: 'Delete document',
})
.click();
@@ -155,7 +155,9 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',
});
await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible();
@@ -226,23 +228,27 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',
});
await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
invitationCard.getByRole('button', { name: 'more_horiz' }),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
).toBeHidden();
@@ -294,17 +300,18 @@ test.describe('Doc Header', () => {
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
invitationCard.getByRole('button', { name: 'more_horiz' }),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
).toBeHidden();
@@ -314,7 +321,6 @@ test.describe('Doc Header', () => {
page,
browserName,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
@@ -349,7 +355,6 @@ test.describe('Doc Header', () => {
});
test('It checks the copy as HTML button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
@@ -384,7 +389,6 @@ test.describe('Doc Header', () => {
});
test('it checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
@@ -581,7 +585,10 @@ test.describe('Documents Header mobile', () => {
await page.getByLabel('Open the document options').click();
await page.getByLabel('Share').click();
await expect(page.getByLabel('Share modal')).toBeVisible();
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',
});
await expect(shareModal).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await expect(page.getByLabel('Share modal')).toBeHidden();
});
@@ -52,6 +52,12 @@ test.describe('Inherited share accesses', () => {
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
await docVisibilityCard.getByText('Reading').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
// Verify inherited link
await docVisibilityCard.getByText('Connected').click();
await expect(
@@ -61,17 +67,13 @@ test.describe('Inherited share accesses', () => {
// Update child link
await page.getByRole('menuitem', { name: 'Public' }).click();
await docVisibilityCard.getByText('Reading').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(
docVisibilityCard.getByText('Public', {
exact: true,
}),
).toBeVisible();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
await expect(
docVisibilityCard.getByText(
'The link sharing rules differ from the parent document',
@@ -15,7 +15,7 @@ test.describe('Document create member', () => {
});
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const inputFill = 'user ';
const inputFill = 'user.test';
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/users/?q=${encodeURIComponent(inputFill)}`) &&
@@ -151,7 +151,6 @@ test.describe('Document list members', () => {
await expect(soloOwner).toBeVisible();
await list.click({
// eslint-disable-next-line playwright/no-force-option
force: true, // Force click to close the dropdown
});
const newUserEmail = await addNewMember(page, 0, 'Owner');
@@ -163,13 +162,11 @@ test.describe('Document list members', () => {
await currentUserRole.click();
await expect(soloOwner).toBeHidden();
await list.click({
// eslint-disable-next-line playwright/no-force-option
force: true, // Force click to close the dropdown
});
await newUserRoles.click();
await list.click({
// eslint-disable-next-line playwright/no-force-option
force: true, // Force click to close the dropdown
});
@@ -40,7 +40,6 @@ test.describe('Doc Routing', () => {
});
test('checks 404 on docs/[id] page', async ({ page }) => {
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.goto('/docs/some-unknown-doc');
@@ -61,32 +60,39 @@ test.describe('Doc Routing', () => {
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const responsePromise = page.route(
/.*\/documents\/.*\/$|users\/me\/$/,
async (route) => {
const request = route.request();
// Wait for the doc link (via its dynamic title) to be visible
const docLink = page.getByRole('link', { name: docTitle });
await expect(docLink).toBeVisible();
if (
request.method().includes('PATCH') ||
request.method().includes('GET')
) {
await route.fulfill({
status: 401,
json: {
detail: 'Log in to access the document',
},
});
} else {
await route.continue();
}
},
// Intercept GET/PATCH requests to return 401
await page.route(/.*\/documents\/.*\/$|users\/me\/$/, async (route) => {
const request = route.request();
if (
request.method().includes('PATCH') ||
request.method().includes('GET')
) {
await route.fulfill({
status: 401,
json: { detail: 'Log in to access the document' },
});
} else {
await route.continue();
}
});
// Explicitly wait for a 401 response after clicking
const wait401 = page.waitForResponse(
(resp) =>
resp.status() === 401 &&
/\/(documents\/[^/]+\/|users\/me\/)$/.test(resp.url()),
);
await page.getByRole('link', { name: '401-doc-parent' }).click();
await docLink.click();
await wait401;
await responsePromise;
await expect(page.getByText('Log in to access the document')).toBeVisible();
await expect(page.getByText('Log in to access the document.')).toBeVisible({
timeout: 10000,
});
});
});
@@ -33,7 +33,7 @@ test.describe('Document search', () => {
).toBeVisible();
await expect(
page.getByLabel('Search modal').getByText('search'),
page.getByRole('heading', { name: 'Search docs' }),
).toBeVisible();
const inputSearch = page.getByPlaceholder('Type the name of a document');
@@ -79,7 +79,7 @@ test.describe('Document search', () => {
await page.keyboard.press('Control+k');
await expect(
page.getByLabel('Search modal').getByText('search'),
page.getByRole('heading', { name: 'Search docs' }),
).toBeVisible();
await page.keyboard.press('Escape');
@@ -173,12 +173,13 @@ test.describe('Document search', () => {
.getByRole('combobox', { name: 'Quick search input' })
.fill('sub page search');
// Expect to find the first doc
// Expect to find the first and second docs in the results list
const resultsList = page.getByRole('listbox');
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
resultsList.getByRole('option', { name: firstDocTitle }),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
resultsList.getByRole('option', { name: secondDocTitle }),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -195,14 +196,15 @@ test.describe('Document search', () => {
.fill('second');
// Now there is a sub page - expect to have the focus on the current doc
const updatedResultsList = page.getByRole('listbox');
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
updatedResultsList.getByRole('option', { name: secondDocTitle }),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondChildDocTitle),
updatedResultsList.getByRole('option', { name: secondChildDocTitle }),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
updatedResultsList.getByRole('option', { name: firstDocTitle }),
).toBeHidden();
});
});
@@ -1,4 +1,3 @@
/* eslint-disable playwright/no-conditional-in-test */
import { expect, test } from '@playwright/test';
import {
@@ -51,7 +50,7 @@ test.describe('Doc Tree', () => {
await expect(subPageItem).toBeVisible();
await subPageItem.click();
await verifyDocName(page, '');
const input = page.getByRole('textbox', { name: 'doc title input' });
const input = page.getByRole('textbox', { name: 'Document title' });
await input.click();
const [randomDocName] = randomName('doc-tree-test', browserName, 1);
await input.fill(randomDocName);
@@ -197,7 +196,7 @@ test.describe('Doc Tree', () => {
await page.getByText('Move to my docs').click();
await expect(
page.getByRole('textbox', { name: 'doc title input' }),
page.getByRole('textbox', { name: 'Document title' }),
).not.toHaveText(docChild);
const header = page.locator('header').first();
@@ -15,7 +15,6 @@ test.describe('Doc Visibility', () => {
});
test('It checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
@@ -119,8 +118,11 @@ test.describe('Doc Visibility: Restricted', () => {
.click();
const otherBrowser = BROWSERS.find((b) => b !== browserName);
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
await keyCloakSignIn(page, otherBrowser!);
await keyCloakSignIn(page, otherBrowser);
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
@@ -151,6 +153,9 @@ test.describe('Doc Visibility: Restricted', () => {
});
const otherBrowser = BROWSERS.find((b) => b !== browserName);
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
const username = `user@${otherBrowser}.test`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
@@ -174,7 +179,7 @@ test.describe('Doc Visibility: Restricted', () => {
})
.click();
await keyCloakSignIn(page, otherBrowser!);
await keyCloakSignIn(page, otherBrowser);
await expect(page.getByTestId('header-logo-link')).toBeVisible();
@@ -449,7 +454,10 @@ test.describe('Doc Visibility: Authenticated', () => {
.click();
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
await keyCloakSignIn(page, otherBrowser);
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
@@ -538,7 +546,10 @@ test.describe('Doc Visibility: Authenticated', () => {
.click();
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
await keyCloakSignIn(page, otherBrowser);
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
@@ -76,7 +76,6 @@ test.describe('Header', () => {
* La gaufre load a js file from a remote server,
* it takes some time to load the file and have the interaction available
*/
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1500);
await header
@@ -27,7 +27,7 @@ export const CONFIG = {
export const overrideConfig = async (
page: Page,
newConfig: { [K in keyof typeof CONFIG]?: unknown },
newConfig: { [_K in keyof typeof CONFIG]?: unknown },
) =>
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
@@ -101,10 +101,9 @@ export const createDoc = async (
waitUntil: 'networkidle',
});
const input = page.getByLabel('doc title input');
const input = page.getByLabel('Document title');
await expect(input).toBeVisible();
await expect(input).toHaveText('');
await input.click();
await input.fill(randomDocs[i]);
await input.blur();
@@ -120,10 +119,11 @@ export const verifyDocName = async (page: Page, docName: string) => {
timeout: 10000,
});
/*replace toHaveText with toContainText to handle cases where emojis or other characters might be added*/
try {
await expect(
page.getByRole('textbox', { name: 'doc title input' }),
).toHaveText(docName);
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName);
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
}
@@ -172,7 +172,7 @@ export const goToGridDoc = async (
await expect(row).toBeVisible();
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
const docTitleContent = row.getByTestId('doc-title').first();
const docTitle = await docTitleContent.textContent();
expect(docTitle).toBeDefined();
@@ -182,9 +182,9 @@ export const goToGridDoc = async (
};
export const updateDocTitle = async (page: Page, title: string) => {
const input = page.getByLabel('doc title input');
await expect(input).toBeVisible();
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title);
await input.click();
@@ -8,7 +8,7 @@ export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
fillText: string = 'user ',
fillText: string = 'user.test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from '@eslint/config-helpers';
import docsPlugin from 'eslint-plugin-docs';
const eslintConfig = defineConfig([
{
files: ['**/*.ts', '**/*.mjs'],
plugins: {
docs: docsPlugin,
},
extends: ['docs/playwright'],
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
project: ['./tsconfig.json'],
},
},
},
]);
export default eslintConfig;
+3 -3
View File
@@ -1,9 +1,9 @@
{
"name": "app-e2e",
"version": "3.5.0",
"version": "3.6.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
"lint": "eslint",
"install-playwright": "playwright install --with-deps",
"test": "playwright test",
"test:ui": "yarn test --ui",
@@ -15,7 +15,7 @@
"@playwright/test": "1.55.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-config-impress": "*",
"eslint-plugin-docs": "*",
"typescript": "*"
},
"dependencies": {
+2 -2
View File
@@ -12,8 +12,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"incremental": true
},
"include": ["**/*.ts", "**/*.d.ts"],
"include": ["**/*.ts", "**/*.d.ts", "**/*.mjs"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
declare module 'convert-stream' {
export function toBuffer(
readableStream: NodeJS.ReadableStream,
_readableStream: NodeJS.ReadableStream,
): Promise<Buffer>;
}
-14
View File
@@ -1,14 +0,0 @@
module.exports = {
root: true,
extends: ['impress/next'],
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
settings: {
next: {
rootDir: __dirname,
},
},
ignorePatterns: ['node_modules', '.eslintrc.js', 'service-worker.js'],
};
@@ -0,0 +1,24 @@
import { defineConfig } from '@eslint/config-helpers';
import docsPlugin from 'eslint-plugin-docs';
const eslintConfig = defineConfig([
{
plugins: {
docs: docsPlugin,
},
extends: ['docs/next'],
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
project: ['./tsconfig.json'],
},
},
settings: {
next: {
rootDir: import.meta.dirname,
},
},
},
]);
export default eslintConfig;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.5.0",
"version": "3.6.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -76,7 +76,7 @@
"@vitejs/plugin-react": "5.0.2",
"cross-env": "10.0.0",
"dotenv": "17.2.1",
"eslint-config-impress": "*",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "26.1.0",
"node-fetch": "2.7.0",
@@ -28,17 +28,18 @@ const StyledButton = styled(Button)<StyledButtonProps>`
border: none;
background: none;
outline: none;
transition: all 0.2s ease-in-out;
font-weight: 500;
font-size: 0.938rem;
padding: 0;
${({ $css }) => $css};
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400);
border-radius: 4px;
transition: none;
}
`;
@@ -110,7 +110,6 @@ export const DropdownMenu = ({
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Icon
@@ -125,9 +124,7 @@ export const DropdownMenu = ({
/>
</Box>
) : (
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
<Box ref={blockButtonRef}>{children}</Box>
)
}
>
@@ -207,14 +204,13 @@ export const DropdownMenu = ({
}
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline: 2px solid var(--c--theme--colors--primary-400);
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);
`}
@@ -32,7 +32,7 @@ export const useDropdownKeyboardNav = ({
.filter((index) => index !== -1);
switch (event.key) {
case 'ArrowDown':
case 'ArrowDown': {
event.preventDefault();
const nextIndex =
focusedIndex < enabledIndices.length - 1 ? focusedIndex + 1 : 0;
@@ -40,8 +40,9 @@ export const useDropdownKeyboardNav = ({
setFocusedIndex(nextIndex);
menuItemRefs.current[nextEnabledIndex]?.focus();
break;
}
case 'ArrowUp':
case 'ArrowUp': {
event.preventDefault();
const prevIndex =
focusedIndex > 0 ? focusedIndex - 1 : enabledIndices.length - 1;
@@ -49,9 +50,10 @@ export const useDropdownKeyboardNav = ({
setFocusedIndex(prevIndex);
menuItemRefs.current[prevEnabledIndex]?.focus();
break;
}
case 'Enter':
case ' ':
case ' ': {
event.preventDefault();
if (focusedIndex >= 0 && focusedIndex < enabledIndices.length) {
const selectedOptionIndex = enabledIndices[focusedIndex];
@@ -62,6 +64,7 @@ export const useDropdownKeyboardNav = ({
}
}
break;
}
case 'Escape':
event.preventDefault();
@@ -1,4 +1,4 @@
export * from './AlertModal';
export * from './modal/AlertModal';
export * from './Box';
export * from './BoxButton';
export * from './Card';
@@ -9,7 +9,7 @@ export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';
export * from './Loading';
export * from './SideModal';
export * from './modal/SideModal';
export * from './separators';
export * from './Text';
export * from './TextErrors';
@@ -2,8 +2,8 @@ import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from './Box';
import { Text } from './Text';
import { Box } from '../Box';
import { Text } from '../Text';
export type AlertModalProps = {
description: ReactNode;
@@ -30,15 +30,23 @@ export const AlertModal = ({
isOpen={isOpen}
size={ModalSize.MEDIUM}
onClose={onClose}
aria-describedby="alert-modal-title"
title={
<Text $size="h6" $align="flex-start" $variation="1000">
<Text
$size="h6"
as="h1"
$margin="0"
id="alert-modal-title"
$align="flex-start"
$variation="1000"
>
{title}
</Text>
}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
aria-label={`${t('Cancel')} - ${title}`}
color="secondary"
fullWidth
onClick={() => onClose()}
@@ -55,12 +63,11 @@ export const AlertModal = ({
</>
}
>
<Box
aria-label={t('Confirmation button')}
className="--docs--alert-modal"
>
<Box className="--docs--alert-modal">
<Box>
<Text $variation="600">{description}</Text>
<Text $variation="600" as="p">
{description}
</Text>
</Box>
</Box>
</Modal>
@@ -0,0 +1,22 @@
import { Button, type ButtonProps } from '@openfun/cunningham-react';
import React from 'react';
import { Box } from '@/components';
const ButtonCloseModal = (props: ButtonProps) => {
return (
<Button
type="button"
size="small"
color="primary-text"
icon={
<Box as="span" aria-hidden="true" className="material-icons-filled">
close
</Box>
}
{...props}
/>
);
};
export default ButtonCloseModal;
@@ -55,7 +55,6 @@ export const QuickSearchInput = ({
</div>
)}
<Command.Input
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus={true}
aria-label={t('Quick search input')}
onClick={(e) => {
@@ -65,6 +64,7 @@ export const QuickSearchInput = ({
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
/>
</Box>
{separator && <HorizontalSeparator $withPadding={false} />}
-2
View File
@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
declare module '*.svg' {
import * as React from 'react';
@@ -2,7 +2,8 @@ import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { BoxButton } from '@/components';
import { Box, BoxButton } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import ProConnectImg from '../assets/button-proconnect.svg';
import { useAuth } from '../hooks';
@@ -11,6 +12,7 @@ import { gotoLogin, gotoLogout } from '../utils';
export const ButtonLogin = () => {
const { t } = useTranslation();
const { authenticated } = useAuth();
const { colorsTokens } = useCunninghamTheme();
if (!authenticated) {
return (
@@ -26,14 +28,23 @@ export const ButtonLogin = () => {
}
return (
<Button
onClick={gotoLogout}
color="primary-text"
aria-label={t('Logout')}
className="--docs--button-logout"
<Box
$css={css`
.--docs--button-logout:focus-visible {
box-shadow: 0 0 0 2px ${colorsTokens['primary-400']} !important;
border-radius: 4px;
}
`}
>
{t('Logout')}
</Button>
<Button
onClick={gotoLogout}
color="primary-text"
aria-label={t('Logout')}
className="--docs--button-logout"
>
{t('Logout')}
</Button>
</Box>
);
};
@@ -205,7 +205,7 @@ type ItemProps = Omit<ItemDefault, 'onClick'> & {
onClick: (e: React.MouseEvent) => void;
};
interface AIMenuItemTransform {
interface AIMenuItemTransformProps {
action: AITransformActions;
docId: string;
icon?: ReactNode;
@@ -216,7 +216,7 @@ const AIMenuItemTransform = ({
action,
children,
icon,
}: PropsWithChildren<AIMenuItemTransform>) => {
}: PropsWithChildren<AIMenuItemTransformProps>) => {
const { mutateAsync: requestAI, isPending } = useDocAITransform();
const editor = useBlockNoteEditor();
@@ -244,7 +244,7 @@ const AIMenuItemTransform = ({
);
};
interface AIMenuItemTranslate {
interface AIMenuItemTranslateProps {
language: string;
docId: string;
icon?: ReactNode;
@@ -255,7 +255,7 @@ const AIMenuItemTranslate = ({
docId,
icon,
language,
}: PropsWithChildren<AIMenuItemTranslate>) => {
}: PropsWithChildren<AIMenuItemTranslateProps>) => {
const { mutateAsync: requestAI, isPending } = useDocAITranslate();
const editor = useBlockNoteEditor();
@@ -19,10 +19,11 @@ export const ModalConfirmDownloadUnsafe = ({
isOpen
closeOnClickOutside
onClose={() => onClose()}
aria-describedby="modal-confirm-download-unsafe-title"
rightActions={
<>
<Button
aria-label={t('Close the modal')}
aria-label={t('Cancel the download')}
color="secondary"
onClick={() => onClose()}
>
@@ -31,6 +32,7 @@ export const ModalConfirmDownloadUnsafe = ({
<Button
aria-label={t('Download')}
color="danger"
data-testid="modal-download-unsafe-button"
onClick={() => {
if (onConfirm) {
void onConfirm();
@@ -45,11 +47,14 @@ export const ModalConfirmDownloadUnsafe = ({
size={ModalSize.SMALL}
title={
<Text
as="h1"
id="modal-confirm-download-unsafe-title"
$gap="0.7rem"
$size="h6"
$align="flex-start"
$variation="1000"
$direction="row"
$margin="0"
>
<Icon iconName="warning" $theme="warning" />
{t('Warning')}
@@ -1,4 +1,3 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { createReactInlineContentSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
@@ -1,4 +1,3 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
PartialCustomInlineContentFromConfig,
StyleSchema,
@@ -7,6 +7,19 @@ export const cssEditor = (readonly: boolean) => css`
height: 100%;
padding-bottom: 2rem;
/**
* WCAG Accessibility contrast fixes for BlockNote editor
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
color: #767676 !important;
font-weight: 400;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
}
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
}
@@ -1,4 +1,3 @@
/* eslint-disable jsx-a11y/alt-text */
import { DefaultProps } from '@blocknote/core';
import { Image, Text, View } from '@react-pdf/renderer';
@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { useEditorStore } from '@/docs/doc-editor';
import { Doc, useTrans } from '@/docs/doc-management';
@@ -131,10 +132,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
isOpen
closeOnClickOutside
onClose={() => onClose()}
hideCloseButton
aria-describedby="modal-export-title"
rightActions={
<>
<Button
aria-label={t('Close the modal')}
aria-label={t('Cancel the download')}
color="secondary"
fullWidth
onClick={() => onClose()}
@@ -143,6 +146,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
{t('Cancel')}
</Button>
<Button
data-testid="doc-export-download-button"
aria-label={t('Download')}
color="primary"
fullWidth
@@ -155,9 +159,29 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
}
size={ModalSize.MEDIUM}
title={
<Text $size="h6" $variation="1000" $align="flex-start">
{t('Download')}
</Text>
<Box
$direction="row"
$justify="space-between"
$align="center"
$width="100%"
>
<Text
as="h1"
$margin="0"
id="modal-export-title"
$size="h6"
$variation="1000"
$align="flex-start"
data-testid="modal-export-title"
>
{t('Download')}
</Text>
<ButtonCloseModal
aria-label={t('Close the download modal')}
onClick={() => onClose()}
disabled={isExporting}
/>
</Box>
}
>
<Box
@@ -166,7 +190,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
$gap="1rem"
className="--docs--modal-export-content"
>
<Text $variation="600" $size="sm">
<Text $variation="600" $size="sm" as="p">
{t('Download your document in a .docx or .pdf format.')}
</Text>
<Select
@@ -1,4 +1,3 @@
/* eslint-disable jsx-a11y/alt-text */
import { Image, Link, Text } from '@react-pdf/renderer';
import DocSelectedIcon from '../assets/doc-selected.png';
@@ -1,5 +1,3 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Tooltip } from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useState } from 'react';
@@ -107,7 +105,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}, [doc]);
return (
<Tooltip content={t('Rename')} placement="top">
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
<Box
as="span"
role="textbox"
@@ -116,7 +114,8 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
aria-label={`${t('Document title')}`}
aria-multiline={false}
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
@@ -215,7 +215,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
>
<Button
color="tertiary"
aria-label="Share button"
aria-label={t('Share button')}
icon={
<Icon iconName="group" $theme="primary" $variation="800" />
}
@@ -233,9 +233,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{!isSmallMobile && ModalExport && (
<Button
data-testid="doc-open-modal-download-button"
color="tertiary-text"
icon={
<Icon iconName="download" $theme="primary" $variation="800" />
<Icon
iconName="download"
$theme="primary"
$variation="800"
aria-hidden={true}
/>
}
onClick={() => {
setIsModalExportOpen(true);
@@ -65,24 +65,20 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
return useMutation<DuplicateDocResponse, APIError, DuplicateDocParams>({
mutationFn: async (variables) => {
try {
// Save the document if we can first, to ensure the latest state is duplicated
if (
variables.canSave &&
provider &&
provider.document.guid === variables.docId
) {
await updateDoc({
id: variables.docId,
content: toBase64(Y.encodeStateAsUpdate(provider.document)),
});
}
// Save the document if we can first, to ensure the latest state is duplicated
const canSave =
variables.canSave &&
provider &&
provider.document.guid === variables.docId;
return await duplicateDoc(variables);
} catch (error) {
// If save fails, throw the error to prevent duplication
throw error;
if (canSave) {
await updateDoc({
id: variables.docId,
content: toBase64(Y.encodeStateAsUpdate(provider.document)),
});
}
return await duplicateDoc(variables);
},
onSuccess: (data, variables, context) => {
void queryClient.resetQueries({
@@ -6,8 +6,8 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc, KEY_DOC } from '@/docs/doc-management';
import { useBroadcastStore } from '@/stores';
export type UpdateDocLinkParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'link_role' | 'link_reach'>>;
export type UpdateDocLinkParams = Pick<Doc, 'id' | 'link_reach'> &
Partial<Pick<Doc, 'link_role'>>;
export const updateDocLink = async ({
id,
@@ -10,6 +10,7 @@ import { useRouter } from 'next/router';
import { Trans, useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { useRemoveDoc } from '../api/useRemoveDoc';
import { Doc } from '../types';
@@ -53,11 +54,13 @@ export const ModalRemoveDoc = ({
<Modal
isOpen
closeOnClickOutside
hideCloseButton
onClose={() => onClose()}
aria-describedby="modal-remove-doc-title"
rightActions={
<>
<Button
aria-label={t('Close the modal')}
aria-label={t('Cancel the deletion')}
color="secondary"
fullWidth
onClick={() => onClose()}
@@ -65,7 +68,7 @@ export const ModalRemoveDoc = ({
{t('Cancel')}
</Button>
<Button
aria-label={t('Confirm deletion')}
aria-label={t('Delete document')}
color="danger"
fullWidth
onClick={() =>
@@ -80,23 +83,32 @@ export const ModalRemoveDoc = ({
}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
<Box
$direction="row"
$justify="space-between"
$align="center"
$width="100%"
>
{t('Delete a doc')}
</Text>
<Text
$size="h6"
as="h1"
id="modal-remove-doc-title"
$margin="0"
$align="flex-start"
$variation="1000"
>
{t('Delete a doc')}
</Text>
<ButtonCloseModal
aria-label={t('Close the delete modal')}
onClick={() => onClose()}
/>
</Box>
}
>
<Box
aria-label={t('Content modal to delete document')}
className="--docs--modal-remove-doc"
>
<Box className="--docs--modal-remove-doc">
{!isError && (
<Text $size="sm" $variation="600" $display="inline-block">
<Text $size="sm" $variation="600" $display="inline-block" as="p">
<Trans t={t}>
This document and <strong>any sub-documents</strong> will be
permanently deleted. This action is irreversible.
@@ -82,12 +82,11 @@ export const SimpleDocItem = ({
</Box>
<Box $justify="center" $overflow="auto">
<Text
aria-describedby="doc-title"
aria-label={displayTitle}
$size="sm"
$variation="1000"
$weight="500"
$css={ItemTextCss}
data-testid="doc-title"
>
{displayTitle}
</Text>
@@ -5,7 +5,8 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedCallback } from 'use-debounce';
import { Box } from '@/components';
import { Box, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { QuickSearch } from '@/components/quick-search';
import { Doc, useDocUtils } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
@@ -63,6 +64,8 @@ const DocSearchModalGlobal = ({
{...modalProps}
closeOnClickOutside
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
hideCloseButton
aria-describedby="doc-search-modal-title"
>
<Box
aria-label={t('Search modal')}
@@ -70,6 +73,22 @@ const DocSearchModalGlobal = ({
$justify="space-between"
className="--docs--doc-search-modal"
>
<Text
as="h1"
$margin="0"
id="doc-search-modal-title"
className="sr-only"
>
{t('Search docs')}
</Text>
<Box $position="absolute" $css="top: 12px; right: 12px;">
<ButtonCloseModal
aria-label={t('Close the search modal')}
onClick={modalProps.onClose}
size="small"
color="primary-text"
/>
</Box>
<QuickSearch
placeholder={t('Type the name of a document')}
loading={loading}
@@ -102,7 +102,7 @@ export const DocRoleDropdown = ({
if (!canUpdate) {
return (
<Text aria-label="doc-role-text" $variation="600">
<Text aria-label={t('Document role text')} $variation="600">
{transRole(currentRole)}
</Text>
);
@@ -100,6 +100,7 @@ const DocShareAccessRequestItem = ({ doc, accessRequest }: Props) => {
docId: doc.id,
})
}
aria-label={t('Close the access request modal')}
>
<Icon iconName="close" $variation="600" $size="16px" />
</BoxButton>
@@ -5,6 +5,7 @@ import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box, HorizontalSeparator, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import {
QuickSearch,
QuickSearchData,
@@ -134,15 +135,35 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
isOpen
closeOnClickOutside
data-testid="doc-share-modal"
aria-label={t('Share modal')}
aria-describedby="doc-share-modal-title"
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
onClose={onClose}
title={<Box $align="flex-start">{t('Share the document')}</Box>}
title={
<Box $direction="row" $justify="space-between" $align="center">
<Text
as="h1"
id="doc-share-modal-title"
$align="flex-start"
$size="small"
$weight="600"
$margin="0"
>
{t('Share the document')}
</Text>
<ButtonCloseModal
aria-label={t('Close the share modal')}
onClick={onClose}
/>
</Box>
}
hideCloseButton
>
<ShareModalStyle />
<Box
aria-label={t('Share modal')}
$height={canViewAccesses ? modalContentHeight : 'auto'}
role="dialog"
aria-label={t('Share modal content')}
$height="auto"
$maxHeight={canViewAccesses ? modalContentHeight : 'none'}
$overflow="hidden"
className="--docs--doc-share-modal noPadding "
$justify="space-between"
@@ -187,6 +208,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
$textAlign="center"
$variation="600"
$size="sm"
as="p"
>
{t(
'You can view this document but need additional access to see its members or modify settings.',
@@ -17,6 +17,7 @@ import {
LinkReach,
LinkRole,
getDocLinkReach,
getDocLinkRole,
useDocUtils,
useUpdateDocLink,
} from '@/docs/doc-management';
@@ -36,7 +37,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const canManage = doc.abilities.accesses_manage;
const docLinkReach = getDocLinkReach(doc);
const docLinkRole = doc.computed_link_role ?? LinkRole.READER;
const docLinkRole = getDocLinkRole(doc);
const { isDesynchronized } = useDocUtils(doc);
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
useTranslatedShareSettings();
@@ -85,7 +86,12 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const isDisabled = !options.includes(key);
return {
label: linkModeTranslations[key],
callback: () => updateDocLink({ id: doc.id, link_role: key }),
callback: () =>
updateDocLink({
id: doc.id,
link_role: key,
link_reach: docLinkReach,
}),
isSelected: docLinkRole === key,
disabled: isDisabled,
};
@@ -43,7 +43,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
docId: currentDoc.id,
},
{
enabled: !!!treeContext?.root?.id,
enabled: !treeContext?.root?.id,
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
},
);
@@ -69,10 +69,11 @@ export const ModalConfirmationVersion = ({
isOpen
closeOnClickOutside
onClose={() => onClose()}
aria-describedby="modal-confirmation-version-title"
rightActions={
<>
<Button
aria-label={t('Close the modal')}
aria-label={`${t('Cancel')} - ${t('Warning')}`}
color="secondary"
fullWidth
onClick={() => onClose()}
@@ -102,20 +103,24 @@ export const ModalConfirmationVersion = ({
}
size={ModalSize.SMALL}
title={
<Text $size="h6" $align="flex-start" $variation="1000">
<Text
as="h1"
$margin="0"
id="modal-confirmation-version-title"
$size="h6"
$align="flex-start"
$variation="1000"
>
{t('Warning')}
</Text>
}
>
<Box
aria-label={t('Modal confirmation to restore the version')}
className="--docs--modal-confirmation-version"
>
<Box className="--docs--modal-confirmation-version">
<Box>
<Text $variation="600">
<Text $variation="600" as="p">
{t('Your current document will revert to this version.')}
</Text>
<Text $variation="600">
<Text $variation="600" as="p">
{t('If a member is editing, his works can be lost.')}
</Text>
</Box>
@@ -3,7 +3,8 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Box, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { DocEditor } from '@/docs/doc-editor';
import { Doc } from '@/docs/doc-management';
@@ -47,6 +48,7 @@ export const ModalSelectVersion = ({
closeOnClickOutside={true}
size={ModalSize.EXTRA_LARGE}
onClose={onClose}
aria-describedby="modal-select-version-title"
>
<NoPaddingStyle />
<Box
@@ -57,6 +59,14 @@ export const ModalSelectVersion = ({
$maxHeight="calc(100vh - 2em - 12px)"
$overflow="hidden"
>
<Text
as="h1"
$margin="0"
id="modal-select-version-title"
className="sr-only"
>
{t('Version history')}
</Text>
<Box
$css={css`
display: flex;
@@ -114,11 +124,10 @@ export const ModalSelectVersion = ({
<Text $size="h6" $variation="1000" $weight="bold">
{t('History')}
</Text>
<Button
<ButtonCloseModal
aria-label={t('Close the version history modal')}
onClick={onClose}
size="nano"
color="primary-text"
icon={<Icon iconName="close" />}
/>
</Box>
@@ -70,7 +70,6 @@ export const DocsGrid = ({
>
<DocsGridLoader isLoading={isRefetching || loading} />
<Card
role="grid"
data-testid="docs-grid"
$height="100%"
$width="100%"
@@ -84,7 +83,7 @@ export const DocsGrid = ({
}}
>
<Text
as="h4"
as="h2"
$size="h4"
$variation="1000"
$margin={{ top: '0px', bottom: '10px' }}
@@ -101,48 +100,57 @@ export const DocsGrid = ({
)}
{hasDocs && (
<Box $gap="6px" $overflow="auto">
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
$gap="10px"
data-testid="docs-grid-header"
>
<Box $flex={flexLeft} $padding="3xs">
<Text $size="xs" $variation="600" $weight="500">
{t('Name')}
</Text>
</Box>
{isDesktop && (
<Box $flex={flexRight} $padding={{ vertical: '3xs' }}>
<Text $size="xs" $weight="500" $variation="600">
{t('Updated at')}
</Text>
<Box role="grid">
<Box role="rowgroup">
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
$gap="10px"
data-testid="docs-grid-header"
role="row"
>
<Box $flex={flexLeft} $padding="3xs" role="columnheader">
<Text $size="xs" $variation="600" $weight="500">
{t('Name')}
</Text>
</Box>
{isDesktop && (
<Box
$flex={flexRight}
$padding={{ vertical: '3xs' }}
role="columnheader"
>
<Text $size="xs" $weight="500" $variation="600">
{t('Updated at')}
</Text>
</Box>
)}
</Box>
</Box>
<Box role="rowgroup">
{isDesktop ? (
<DraggableDocGridContentList docs={docs} />
) : (
<DocGridContentList docs={docs} />
)}
</Box>
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button
onClick={() => void fetchNextPage()}
color="primary-text"
>
{t('More docs')}
</Button>
)}
</InView>
)}
</Box>
{isDesktop ? (
<DraggableDocGridContentList docs={docs} />
) : (
<DocGridContentList docs={docs} />
)}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button
onClick={() => void fetchNextPage()}
color="primary-text"
>
{t('More docs')}
</Button>
)}
</InView>
)}
</Box>
)}
</Card>
@@ -53,67 +53,76 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
`}
className="--docs--doc-grid-item"
>
<StyledLink
<Box
$flex={flexLeft}
role="gridcell"
$css={css`
flex: ${flexLeft};
align-items: center;
min-width: 0;
`}
href={`/docs/${doc.id}`}
>
<Box
data-testid={`docs-grid-name-${doc.id}`}
$direction="row"
$align="center"
$gap={spacingsTokens.xs}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
<StyledLink
$css={css`
width: 100%;
align-items: center;
min-width: 0;
`}
href={`/docs/${doc.id}`}
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{isShared && (
<Box
$padding={{ top: !isDesktop ? '4xs' : undefined }}
$css={
!isDesktop
? css`
align-self: flex-start;
`
: undefined
}
>
{dragMode && (
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
)}
{!dragMode && (
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<div>
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
</div>
</Tooltip>
)}
</Box>
)}
</Box>
</StyledLink>
<Box
data-testid={`docs-grid-name-${doc.id}`}
$direction="row"
$align="center"
$gap={spacingsTokens.xs}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{isShared && (
<Box
$padding={{ top: !isDesktop ? '4xs' : undefined }}
$css={
!isDesktop
? css`
align-self: flex-start;
`
: undefined
}
>
{dragMode && (
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
)}
{!dragMode && (
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<div>
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
</div>
</Tooltip>
)}
</Box>
)}
</Box>
</StyledLink>
</Box>
<Box
$flex={flexRight}
@@ -121,6 +130,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
$align="center"
$justify={isDesktop ? 'space-between' : 'flex-end'}
$gap="32px"
role="gridcell"
>
{isDesktop && (
<StyledLink href={`/docs/${doc.id}`}>
@@ -1,5 +1,5 @@
import { Loader } from '@openfun/cunningham-react';
import { createGlobalStyle } from 'styled-components';
import { createGlobalStyle, css } from 'styled-components';
import { Box } from '@/components';
@@ -32,6 +32,9 @@ export const DocsGridLoader = ({ isLoading }: DocsGridLoaderProps) => {
$zIndex={998}
$position="absolute"
className="--docs--doc-grid-loader"
$css={css`
pointer-events: none;
`}
>
<Loader />
</Box>
@@ -19,6 +19,7 @@ export const Draggable = <T,>(props: DraggableProps<T>) => {
{...attributes}
data-testid={`draggable-doc-${props.id}`}
className="--docs--grid-draggable"
role="presentation"
>
{props.children}
</div>
@@ -35,6 +35,7 @@ export const Droppable = ({
<Box
ref={setNodeRef}
data-testid={`droppable-doc-${id}`}
role="presentation"
$css={css`
border-radius: 4px;
background-color: ${enableHover
@@ -43,6 +43,13 @@ export const Header = () => {
href="/"
data-testid="header-logo-link"
aria-label={t('Back to homepage')}
$css={css`
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400) !important;
border-radius: 4px;
}
`}
>
<Box
$align="center"
@@ -41,15 +41,7 @@ export const LanguagePicker = () => {
showArrow
label={t('Select language')}
buttonCss={css`
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
}
transition: all 0.15s ease-in-out !important;
border-radius: 4px;
padding: 0.5rem 0.6rem;
& > div {
@@ -85,8 +85,9 @@ export const LeftPanelTargetFilters = () => {
background-color: ${colorsTokens['greyscale-100']};
}
&:focus-visible {
outline: 2px solid ${colorsTokens['primary-500']};
outline-offset: 2px;
outline: none !important;
box-shadow: 0 0 0 2px ${colorsTokens['primary-500']} !important;
border-radius: 4px;
}
`}
>
@@ -31,27 +31,26 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => {
.pinned-actions {
opacity: ${isDesktop ? 0 : 1};
}
&:hover,
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
&:focus-within {
cursor: pointer;
background-color: var(--c--theme--colors--greyscale-100);
box-shadow: 0 0 0 2px ${colorsTokens['primary-500']} !important;
.pinned-actions {
opacity: 1;
}
}
&:focus-visible {
outline: 2px solid ${colorsTokens['primary-500']};
outline-offset: 2px;
border-radius: ${spacingsTokens['3xs']};
}
`}
key={doc.id}
className="--docs--left-panel-favorite-item"
>
<StyledLink
href={`/docs/${doc.id}`}
$css="overflow: auto;"
$css={css`
overflow: auto;
outline: none !important;
`}
aria-label={`${doc.title}, ${t('Updated')} ${DateTime.fromISO(doc.updated_at).toRelative()}`}
>
<SimpleDocItem showAccesses doc={doc} />
@@ -44,17 +44,21 @@ export const LeftPanelFavorites = () => {
>
{t('Pinned documents')}
</Text>
<InfiniteScroll
as="ul"
hasMore={docs.hasNextPage}
isLoading={docs.isFetchingNextPage}
next={() => void docs.fetchNextPage()}
$padding="none"
>
{favoriteDocs.map((doc) => (
<LeftPanelFavoriteItem key={doc.id} doc={doc} />
))}
</InfiniteScroll>
<Box>
<Box as="ul" $padding="none">
{favoriteDocs.map((doc) => (
<LeftPanelFavoriteItem key={doc.id} doc={doc} />
))}
</Box>
{docs.hasNextPage && (
<InfiniteScroll
hasMore={docs.hasNextPage}
isLoading={docs.isFetchingNextPage}
next={() => void docs.fetchNextPage()}
$padding="none"
/>
)}
</Box>
</Box>
</Box>
);
@@ -4,7 +4,6 @@ import { Doc, DocsResponse } from '@/docs/doc-management';
import { RequestData, RequestSerializer } from './RequestSerializer';
// eslint-disable-next-line import/order
import pkg from '@/../package.json';
export type DBRequest = {
@@ -129,7 +129,6 @@ describe('ApiPlugin', () => {
const request = await apiPlugin.requestWillFetch?.(requestInit);
if (withClone) {
// eslint-disable-next-line jest/no-conditional-expect
expect(mockedClone).toHaveBeenCalled();
}

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