Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f2e07f949 | |||
| 8c1e95c587 | |||
| 20161fd6db | |||
| e827cfeee1 | |||
| eab2a75bff | |||
| cd84751cb9 | |||
| 1d20a8b0a7 | |||
| 8a310d004b | |||
| 9f9fae96e5 | |||
| 9cb2b6a6fb | |||
| 0a1eaa3c40 | |||
| da72a1601a | |||
| 9a51e02cd7 | |||
| 4184c339eb | |||
| 3688591dd1 | |||
| 25783182b8 | |||
| 80a62bcbc1 | |||
| ede0a77665 | |||
| 8a8a1460e5 | |||
| 0ac9f059b6 | |||
| 179a84150b | |||
| 084d0c1089 | |||
| c9a6c4d4c6 | |||
| 9db7d0af8d |
@@ -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
|
||||
|
||||
+20
-1
@@ -8,6 +8,23 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
@@ -27,8 +44,10 @@ and this project adheres to
|
||||
- ♿️(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
|
||||
|
||||
@@ -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
@@ -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 = '''
|
||||
|
||||
@@ -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
|
||||
@@ -27,7 +27,6 @@
|
||||
"@hocuspocus/provider",
|
||||
"@hocuspocus/server",
|
||||
"docx",
|
||||
"eslint",
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"node-fetch",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,37 @@ 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) =>
|
||||
|
||||
@@ -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,7 +3,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
declare module 'convert-stream' {
|
||||
export function toBuffer(
|
||||
readableStream: NodeJS.ReadableStream,
|
||||
_readableStream: NodeJS.ReadableStream,
|
||||
): Promise<Buffer>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
`}
|
||||
|
||||
+6
-3
@@ -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';
|
||||
|
||||
+16
-9
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+4
-4
@@ -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();
|
||||
|
||||
|
||||
+6
-1
@@ -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
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { createReactInlineContentSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
|
||||
-1
@@ -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
@@ -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);
|
||||
|
||||
+12
-16
@@ -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({
|
||||
|
||||
+27
-15
@@ -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.
|
||||
|
||||
+1
-2
@@ -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>
|
||||
|
||||
+20
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
|
||||
+1
@@ -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.',
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
);
|
||||
|
||||
+13
-8
@@ -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>
|
||||
|
||||
+13
-4
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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;
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
+8
-9
@@ -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} />
|
||||
|
||||
+15
-11
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ export const isApiUrl = (href: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const isDocumentApiUrl = (url: URL) =>
|
||||
isApiUrl(url.href) && /.*\/documents\/([a-z0-9-]+)\/$/g.test(url.href);
|
||||
|
||||
/**
|
||||
* API routes
|
||||
*/
|
||||
@@ -45,8 +48,7 @@ registerRoute(
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
isApiUrl(url.href) && url.href.match(/.*\/documents\/([a-z0-9\-]+)\/$/g),
|
||||
({ url }) => isDocumentApiUrl(url),
|
||||
new NetworkOnly({
|
||||
plugins: [
|
||||
new ApiPlugin({
|
||||
@@ -61,8 +63,7 @@ registerRoute(
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
isApiUrl(url.href) && url.href.match(/.*\/documents\/([a-z0-9\-]+)\/$/g),
|
||||
({ url }) => isDocumentApiUrl(url),
|
||||
new NetworkOnly({
|
||||
plugins: [
|
||||
new ApiPlugin({
|
||||
@@ -90,8 +91,7 @@ registerRoute(
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
isApiUrl(url.href) && url.href.match(/.*\/documents\/([a-z0-9\-]+)\/$/g),
|
||||
({ url }) => isDocumentApiUrl(url),
|
||||
new NetworkOnly({
|
||||
plugins: [
|
||||
new ApiPlugin({
|
||||
|
||||
@@ -18,13 +18,11 @@ import {
|
||||
StrategyOptions,
|
||||
} from 'workbox-strategies';
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import { DAYS_EXP, SW_DEV_URL, SW_VERSION, getCacheNameVersion } from './conf';
|
||||
import { ApiPlugin } from './plugins/ApiPlugin';
|
||||
import { OfflinePlugin } from './plugins/OfflinePlugin';
|
||||
import { isApiUrl } from './service-worker-api';
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import pkg from '@/../package.json';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
@@ -130,12 +128,14 @@ setCatchHandler(async ({ request, url, event }) => {
|
||||
case isApiUrl(url.href):
|
||||
return ApiPlugin.getApiCatchHandler();
|
||||
|
||||
case request.destination === 'document':
|
||||
if (url.pathname.match(/^\/docs\/([a-z0-9\-]+)\/$/g)) {
|
||||
case request.destination === 'document': {
|
||||
const isDocPath = /^\/docs\/([a-z0-9-]+)\/$/g.test(url.pathname);
|
||||
if (isDocPath) {
|
||||
return precacheStrategy.handle({ event, request: FALLBACK.docs });
|
||||
}
|
||||
|
||||
return precacheStrategy.handle({ event, request: FALLBACK.offline });
|
||||
}
|
||||
|
||||
case request.destination === 'image':
|
||||
return precacheStrategy.handle({ event, request: FALLBACK.images });
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Select language": "Dibab ur yezh",
|
||||
"Share": "Rannañ",
|
||||
"Share modal": "Modal rannañ",
|
||||
"Share modal content": "Endalc'h modal rannañ",
|
||||
"Share the document": "Rannañ an teul",
|
||||
"Share with {{count}} users_many": "Rannañ gant {{count}} implijer",
|
||||
"Share with {{count}} users_one": "Rannañ gant {{count}} implijer",
|
||||
@@ -266,6 +267,12 @@
|
||||
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||
"Cancel": "Abbrechen",
|
||||
"Close the modal": "Pop-up schließen",
|
||||
"Close the access request modal": "Zugriffsanfrage-Modal schließen",
|
||||
"Close the delete modal": "Lösch-Modal schließen",
|
||||
"Close the search modal": "Such-Modal schließen",
|
||||
"Close the share modal": "Freigabe-Modal schließen",
|
||||
"Close the version history modal": "Versionsverlauf-Modal schließen",
|
||||
"Close the download modal": "Download-Modal schließen",
|
||||
"Collaborate": "Zusammenarbeiten",
|
||||
"Collaborate and write in real time, without layout constraints.": "In Echtzeit und ohne Layout-Beschränkungen schreiben und zusammenarbeiten.",
|
||||
"Collaborative writing, Simplified.": "Kollaboratives Schreiben, vereinfacht.",
|
||||
@@ -296,8 +303,11 @@
|
||||
"Document accessible to any connected person": "Dokument für jeden angemeldeten Benutzer zugänglich",
|
||||
"Document duplicated successfully!": "Dokument erfolgreich dupliziert!",
|
||||
"Document owner": "Besitzer des Dokuments",
|
||||
"Document title": "Dokumenttitel",
|
||||
"Document sections": "Dokumentabschnitte",
|
||||
"Docx": "Docx",
|
||||
"Download": "Herunterladen",
|
||||
"Download the document": "Dokument herunterladen",
|
||||
"Download anyway": "Trotzdem herunterladen",
|
||||
"Download your document in a .docx or .pdf format.": "Ihr Dokument als .docx- oder .pdf-Datei herunterladen.",
|
||||
"Duplicate": "Duplizieren",
|
||||
@@ -393,6 +403,7 @@
|
||||
"Select a version on the right to restore": "Wählen Sie rechts eine Version zum Wiederherstellen aus",
|
||||
"Share": "Teilen",
|
||||
"Share modal": "Teilen-Modal",
|
||||
"Share modal content": "Teilen-Modal Inhalt",
|
||||
"Share the document": "Dokument teilen",
|
||||
"Share with {{count}} users_many": "Teilen mit {{count}} Benutzern",
|
||||
"Share with {{count}} users_one": "Mit {{count}} Benutzern teilen",
|
||||
@@ -443,6 +454,21 @@
|
||||
},
|
||||
"en": {
|
||||
"translation": {
|
||||
"Back to homepage": "Back to Docs homepage",
|
||||
"Document title": "Document title",
|
||||
"Search docs": "Search docs",
|
||||
"More options": "More options",
|
||||
"Pinned documents": "Pinned documents",
|
||||
"Close the access request modal": "Close the access request modal",
|
||||
"Close the delete modal": "Close the delete modal",
|
||||
"Close the search modal": "Close the search modal",
|
||||
"Close the share modal": "Close the share modal",
|
||||
"Close the version history modal": "Close the version history modal",
|
||||
"Close the download modal": "Close the download modal",
|
||||
"Cancel the deletion": "Cancel the deletion",
|
||||
"Cancel the download": "Cancel the download",
|
||||
"Open actions menu for document: {{title}}": "Open actions menu for document: {{title}}",
|
||||
"Open the menu of actions for the document: {{title}}": "Open the menu of actions for the document: {{title}}",
|
||||
"Share with {{count}} users_one": "Share with {{count}} user",
|
||||
"Shared with {{count}} users_many": "Shared with {{count}} users",
|
||||
"Shared with {{count}} users_one": "Shared with {{count}} user",
|
||||
@@ -478,10 +504,18 @@
|
||||
"Can't load this page, please check your internet connection.": "No se puede cargar esta página, por favor compruebe su conexión a Internet.",
|
||||
"Cancel": "Cancelar",
|
||||
"Close the modal": "Cerrar modal",
|
||||
"Close the access request modal": "Cerrar modal de solicitud de acceso",
|
||||
"Close the delete modal": "Cerrar modal de eliminación",
|
||||
"Close the search modal": "Cerrar modal de búsqueda",
|
||||
"Close the share modal": "Cerrar modal de compartir",
|
||||
"Close the version history modal": "Cerrar modal de historial de versiones",
|
||||
"Close the download modal": "Cerrar modal de descarga",
|
||||
"Collaborate": "Colabora",
|
||||
"Collaborate and write in real time, without layout constraints.": "Colaborar y escribir en tiempo real, sin restricciones de diseño.",
|
||||
"Collaborative writing, Simplified.": "Escritura colaborativa, más sencilla.",
|
||||
"Confirm deletion": "Confirmar borrado",
|
||||
"Cancel the deletion": "Cancelar la eliminación",
|
||||
"Cancel the download": "Cancelar la descarga",
|
||||
"Connected": "Conectado",
|
||||
"Content modal to delete document": "Modal para eliminar el documento",
|
||||
"Content modal to export the document": "Ventana emergente para exportar el documento",
|
||||
@@ -504,8 +538,11 @@
|
||||
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs: su nuevo compañero para colaborar en documentos de forma eficiente, intuitiva y segura.",
|
||||
"Document accessible to any connected person": "Documento accesible a cualquier persona conectada",
|
||||
"Document owner": "Propietario del documento",
|
||||
"Document title": "Título del documento",
|
||||
"Document sections": "Secciones del documento",
|
||||
"Docx": "Docx",
|
||||
"Download": "Descargar",
|
||||
"Download the document": "Descargar el documento",
|
||||
"Download anyway": "Descargar de todos modos",
|
||||
"Download your document in a .docx or .pdf format.": "Descargue su documento en formato .docx o .pdf.",
|
||||
"Editor": "Editor",
|
||||
@@ -587,6 +624,7 @@
|
||||
"Select a version on the right to restore": "Seleccione una versión a la derecha para restaurarlo",
|
||||
"Share": "Compartir",
|
||||
"Share modal": "Modal para compartir el documento",
|
||||
"Share modal content": "Contenido del modal para compartir",
|
||||
"Share the document": "Compartir el documento",
|
||||
"Share with {{count}} users_many": "Compartir con {{count}} usuarios",
|
||||
"Share with {{count}} users_one": "Comparte con {{count}} usuario",
|
||||
@@ -662,11 +700,19 @@
|
||||
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
|
||||
"Cancel": "Annuler",
|
||||
"Close the modal": "Fermer la modale",
|
||||
"Close the share modal": "Fermer la modale de partage de document",
|
||||
"Close the access request modal": "Fermer la modale de demande d'accès",
|
||||
"Close the delete modal": "Fermer la modale de suppression",
|
||||
"Close the download modal": "Fermer la modale de téléchargement",
|
||||
"Close the search modal": "Fermer la modale de recherche de document",
|
||||
"Close the version history modal": "Fermer la modale d'historique des versions",
|
||||
"Collaborate": "Collaborer",
|
||||
"Collaborate and write in real time, without layout constraints.": "Collaborez et rédigez en temps réel, sans contrainte de mise en page.",
|
||||
"Collaborative writing, Simplified.": "L'écriture collaborative simplifiée.",
|
||||
"Confirm": "Confirmez",
|
||||
"Confirm deletion": "Confirmer la suppression",
|
||||
"Cancel the deletion": "Annuler la suppression",
|
||||
"Cancel the download": "Annuler le téléchargement",
|
||||
"Confirmation button": "Bouton de confirmation",
|
||||
"Connected": "Connecté",
|
||||
"Content modal to delete document": "Contenu modal pour supprimer le document",
|
||||
@@ -696,10 +742,12 @@
|
||||
"Document duplicated successfully!": "Document dupliqué avec succès !",
|
||||
"Document emoji icon": "Émoticônes du document",
|
||||
"Document owner": "Propriétaire du document",
|
||||
"Document title": "Titre du document",
|
||||
"Document sections": "Sections du document",
|
||||
"Document visibility": "Visibilité du document",
|
||||
"Docx": "Docx",
|
||||
"Download": "Télécharger",
|
||||
"Download the document": "Télécharger le document",
|
||||
"Download anyway": "Télécharger malgré tout",
|
||||
"Download your document in a .docx or .pdf format.": "Téléchargez votre document au format .docx ou .pdf.",
|
||||
"Duplicate": "Dupliquer",
|
||||
@@ -813,6 +861,7 @@
|
||||
"Select language": "Sélectionner la langue",
|
||||
"Share": "Partager",
|
||||
"Share modal": "Modale de partage",
|
||||
"Share modal content": "Contenu de la modale de partage",
|
||||
"Share the document": "Partager le document",
|
||||
"Share with {{count}} users_many": "Partagé entre {{count}} utilisateurs",
|
||||
"Share with {{count}} users_one": "Partager avec {{count}} utilisateur",
|
||||
@@ -829,6 +878,10 @@
|
||||
"Summarize": "Résumer",
|
||||
"Summary": "Sommaire",
|
||||
"Template": "Modèle",
|
||||
"Document template": "Modèle de document",
|
||||
"File format": "Format de fichier",
|
||||
"Choose a template to apply to your document before download": "Choisissez un modèle à appliquer à votre document avant le téléchargement",
|
||||
"Choose the file format for your download": "Choisissez le format de fichier pour votre téléchargement",
|
||||
"The antivirus has detected an anomaly in your file.": "L'antivirus a détecté une anomalie dans votre fichier.",
|
||||
"The document has been deleted.": "Le document a bien été supprimé.",
|
||||
"The document visibility has been updated.": "La visibilité du document a été mise à jour.",
|
||||
@@ -1039,10 +1092,18 @@
|
||||
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
|
||||
"Cancel": "Breek af",
|
||||
"Close the modal": "Sluit het venster",
|
||||
"Close the access request modal": "Sluit het toegangsverzoek venster",
|
||||
"Close the delete modal": "Sluit het verwijder venster",
|
||||
"Close the search modal": "Sluit het zoek venster",
|
||||
"Close the share modal": "Sluit het delen venster",
|
||||
"Close the version history modal": "Sluit het versiehistorie venster",
|
||||
"Close the download modal": "Sluit het download venster",
|
||||
"Collaborate": "Samenwerken",
|
||||
"Collaborate and write in real time, without layout constraints.": "Samenwerken en schrijven in realtime, zonder lay-out beperkingen.",
|
||||
"Collaborative writing, Simplified.": "Vereenvoudigd samenwerkend",
|
||||
"Confirm deletion": "Bevestig verwijdering",
|
||||
"Cancel the deletion": "Annuleer de verwijdering",
|
||||
"Cancel the download": "Annuleer de download",
|
||||
"Connected": "Verbonden",
|
||||
"Content modal to delete document": "Content venster om het document te verwijderen",
|
||||
"Content modal to export the document": "Content venster om document te exporteren",
|
||||
@@ -1065,8 +1126,11 @@
|
||||
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs: Je nieuwe metgezel om efficiënt, intuïtief en veilig samen te werken aan documenten.",
|
||||
"Document accessible to any connected person": "Document is toegankelijk voor ieder verbonden persoon",
|
||||
"Document owner": "Document eigenaar",
|
||||
"Document title": "Documenttitel",
|
||||
"Document sections": "Document secties",
|
||||
"Docx": "Docx",
|
||||
"Download": "Download",
|
||||
"Download the document": "Document downloaden",
|
||||
"Download anyway": "Download alsnog",
|
||||
"Download your document in a .docx or .pdf format.": "Download jouw document in .docx of .pdf formaat.",
|
||||
"Editor": "Bewerker",
|
||||
@@ -1145,6 +1209,7 @@
|
||||
"Select a version on the right to restore": "Selecteer een versie rechts om te herstellen",
|
||||
"Share": "Deel",
|
||||
"Share modal": "Deel model",
|
||||
"Share modal content": "Deel model inhoud",
|
||||
"Share the document": "Deel dit document",
|
||||
"Share with {{count}} users_many": "Gedeeld met {{count}} gebruikers",
|
||||
"Share with {{count}} users_one": "Delen met {{count}} gebruiker",
|
||||
@@ -1160,7 +1225,7 @@
|
||||
"Start Writing": "Begin met schrijven",
|
||||
"Summarize": "Vat samen",
|
||||
"Summary": "Samenvatting",
|
||||
"Template": "Template",
|
||||
"Template": "Sjabloon",
|
||||
"The document has been deleted.": "Het document is verwijderd",
|
||||
"The document visibility has been updated.": "De zichtbaarheid van het document is bijgewerkt",
|
||||
"The export failed": "Het exporteren is mislukt",
|
||||
@@ -1804,6 +1869,7 @@
|
||||
"Can't load this page, please check your internet connection.": "无法加载此页面,请检查您的网络连接。",
|
||||
"Cancel": "取消",
|
||||
"Close the modal": "关闭对话框",
|
||||
"Close the share modal": "关闭分享文档对话框",
|
||||
"Collaborate": "协作",
|
||||
"Collaborate and write in real time, without layout constraints.": "实时协作和写入,不受布局限制。",
|
||||
"Collaborative writing, Simplified.": "协作写入,简体字样。",
|
||||
@@ -1910,6 +1976,7 @@
|
||||
"Select a version on the right to restore": "选择要恢复的版本",
|
||||
"Share": "共享",
|
||||
"Share modal": "共享模式",
|
||||
"Share modal content": "共享模式内容",
|
||||
"Share the document": "共享文档",
|
||||
"Share with {{count}} users_many": "与{{count}}共享",
|
||||
"Share with {{count}} users_one": "与{{count}}共享",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user