Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7d0ba7bdd | |||
| 0d09f761dc | |||
| ce5f9a1417 | |||
| 83a24c3796 | |||
| 4a269e6b0e | |||
| d9d7b70b71 | |||
| a4326366c2 | |||
| 1d7b57e03d | |||
| c4c6c22e42 | |||
| 10a8eccc71 | |||
| 728332f8f7 | |||
| 487b95c207 | |||
| d23b38e478 | |||
| d6333c9b81 | |||
| 03b6c6a206 | |||
| aadabf8d3c | |||
| 2a708d6e46 |
+26
-4
@@ -8,11 +8,32 @@ and this project adheres to
|
||||
|
||||
### Changed
|
||||
|
||||
- 💫(frontend) fix the help button to the bottom in tree #2073
|
||||
- 💄(frontend) improve comments highlights #1961
|
||||
♿️(frontend) fix sidebar resize handle for screen readers #2122
|
||||
|
||||
## [v4.8.3] - 2026-03-23
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) improve version history list accessibility #2033
|
||||
- ♿️(frontend) fix more options menu feedback for screen readers #2071
|
||||
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983
|
||||
- ♿️(frontend) fix search modal accessibility issues #2054
|
||||
- ♿️(frontend) add sr-only format to export download button #2088
|
||||
- ♿️(frontend) announce formatting shortcuts for screen readers #2070
|
||||
- ✨(frontend) add markdown copy icon for Copy as Markdown option #2096
|
||||
- ♻️(backend) skip saving in database a document when payload is empty #2062
|
||||
- ♻️(frontend) refacto Version modal to fit with the design system #2091
|
||||
- ⚡️(frontend) add debounce WebSocket reconnect #2104
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿️(frontend) fix more options menu feedback for screen readers #2071
|
||||
- ♿️(frontend) fix more options menu feedback for screen readers #2071
|
||||
- 💫(frontend) fix the help button to the bottom in tree #2073
|
||||
- ♿️(frontend) fix aria-labels for table of contents #2065
|
||||
- 🐛(backend) allow using search endpoint without refresh token enabled #2097
|
||||
- 🐛(frontend) fix close panel when click on subdoc #2094
|
||||
- 🐛(frontend) fix leftpanel button in doc version #9238
|
||||
- 🐛(y-provider) fix loop when no cookies #2101
|
||||
|
||||
## [v4.8.2] - 2026-03-19
|
||||
|
||||
@@ -1157,7 +1178,8 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.2...main
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.3...main
|
||||
[v4.8.3]: https://github.com/suitenumerique/docs/releases/v4.8.3
|
||||
[v4.8.2]: https://github.com/suitenumerique/docs/releases/v4.8.2
|
||||
[v4.8.1]: https://github.com/suitenumerique/docs/releases/v4.8.1
|
||||
[v4.8.0]: https://github.com/suitenumerique/docs/releases/v4.8.0
|
||||
|
||||
@@ -61,8 +61,8 @@ OIDC_RS_AUDIENCE_CLAIM="client_id" # The claim used to identify the audience
|
||||
OIDC_RS_ALLOWED_AUDIENCES=""
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
OIDC_STORE_ACCESS_TOKEN=True
|
||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
|
||||
# OIDC_STORE_ACCESS_TOKEN=True
|
||||
# OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# To create one, use the bin/fernetkey command.
|
||||
|
||||
@@ -300,6 +300,15 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
|
||||
return file
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
When no data is sent on the update, skip making the update in the database and return
|
||||
directly the instance unchanged.
|
||||
"""
|
||||
if not validated_data:
|
||||
return instance # No data provided, skip the update
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""
|
||||
Process the content field to extract attachment keys and update the document's
|
||||
|
||||
@@ -6,8 +6,10 @@ from abc import ABC, abstractmethod
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils.decorators import method_decorator
|
||||
|
||||
import botocore
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
|
||||
@@ -91,6 +93,19 @@ def generate_s3_authorization_headers(key):
|
||||
return request
|
||||
|
||||
|
||||
def conditional_refresh_oidc_token(func):
|
||||
"""
|
||||
Conditionally apply refresh_oidc_access_token decorator.
|
||||
|
||||
The decorator is only applied if OIDC_STORE_REFRESH_TOKEN is True, meaning
|
||||
we can actually refresh something. Broader settings checks are done in settings.py.
|
||||
"""
|
||||
if settings.OIDC_STORE_REFRESH_TOKEN:
|
||||
return method_decorator(refresh_oidc_access_token)(func)
|
||||
|
||||
return func
|
||||
|
||||
|
||||
class AIBaseRateThrottle(BaseThrottle, ABC):
|
||||
"""Base throttle class for AI-related rate limiting with backoff."""
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from django.db.models.functions import Greatest, Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.http import content_disposition_header
|
||||
from django.utils.text import capfirst, slugify
|
||||
@@ -38,7 +37,6 @@ from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from lasuite.tools.email import get_domain_from_email
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import filters, status, viewsets
|
||||
@@ -1415,7 +1413,7 @@ class DocumentViewSet(
|
||||
return duplicated_document
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
@utils.conditional_refresh_oidc_token
|
||||
def search(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns an ordered list of documents best matching the search query parameter 'q'.
|
||||
@@ -1426,7 +1424,6 @@ class DocumentViewSet(
|
||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||
params.is_valid(raise_exception=True)
|
||||
search_type = self._get_search_type()
|
||||
|
||||
if search_type == SearchType.TITLE:
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -70,17 +70,20 @@ def test_api_documents_search_anonymous(search_query, indexer_settings):
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet.list")
|
||||
def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settings):
|
||||
def test_api_documents_search_fall_back_on_search_list(mock_list, settings):
|
||||
"""
|
||||
When indexer is not configured and no path is provided,
|
||||
should fall back on list method
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = None
|
||||
assert get_document_indexer() is None
|
||||
assert settings.OIDC_STORE_REFRESH_TOKEN is False
|
||||
assert settings.OIDC_STORE_ACCESS_TOKEN is False
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(
|
||||
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||
)
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
@@ -93,6 +96,8 @@ def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settin
|
||||
q = "alpha"
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert mock_list.call_count == 1
|
||||
assert mock_list.call_args[0][0].GET.get("q") == q
|
||||
assert response.json() == mocked_response
|
||||
@@ -100,18 +105,21 @@ def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settin
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
|
||||
def test_api_documents_search_fallback_on_search_list_sub_docs(
|
||||
mock_list_descendants, indexer_settings
|
||||
mock_list_descendants, settings
|
||||
):
|
||||
"""
|
||||
When indexer is not configured and path parameter is provided,
|
||||
should call _list_descendants() method
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
assert get_document_indexer() is None
|
||||
assert settings.OIDC_STORE_REFRESH_TOKEN is False
|
||||
assert settings.OIDC_STORE_ACCESS_TOKEN is False
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(
|
||||
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
|
||||
@@ -128,9 +136,9 @@ def test_api_documents_search_fallback_on_search_list_sub_docs(
|
||||
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
|
||||
)
|
||||
|
||||
assert mock_list_descendants.call_count == 1
|
||||
assert mock_list_descendants.call_args[0][0].GET.get("q") == q
|
||||
assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path
|
||||
mock_list_descendants.assert_called_with(
|
||||
mock.ANY, {"q": "alpha", "path": parent.path}
|
||||
)
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@@ -152,7 +160,9 @@ def test_api_documents_search_indexer_crashes(mock_title_search, indexer_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(
|
||||
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||
)
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
@@ -185,7 +195,9 @@ def test_api_documents_search_invalid_params(indexer_settings):
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(
|
||||
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/")
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: update
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import random
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
@@ -17,6 +19,25 @@ from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# A valid Yjs document derived from YDOC_HELLO_WORLD_BASE64 with "Hello" replaced by "World",
|
||||
# used in PATCH tests to guarantee a real content change distinct from what DocumentFactory
|
||||
# produces.
|
||||
YDOC_UPDATED_CONTENT_BASE64 = (
|
||||
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
|
||||
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVX"
|
||||
"b3JsZIb17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
|
||||
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
|
||||
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
|
||||
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
|
||||
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
|
||||
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
|
||||
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
|
||||
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
|
||||
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
|
||||
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
|
||||
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via_parent", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
@@ -330,6 +351,7 @@ def test_api_documents_update_authenticated_no_websocket(settings):
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
@@ -338,6 +360,8 @@ def test_api_documents_update_authenticated_no_websocket(settings):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.path == old_path
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
@@ -446,6 +470,7 @@ def test_api_documents_update_user_connected_to_websocket(settings):
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
@@ -453,6 +478,9 @@ def test_api_documents_update_user_connected_to_websocket(settings):
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.path == old_path
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
@@ -486,6 +514,7 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
@@ -494,6 +523,8 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.path == old_path
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
@@ -605,6 +636,7 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
@@ -613,6 +645,8 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.path == old_path
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 0
|
||||
|
||||
@@ -643,6 +677,7 @@ def test_api_documents_update_feature_flag_disabled(settings):
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
@@ -651,6 +686,8 @@ def test_api_documents_update_feature_flag_disabled(settings):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.path == old_path
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 0
|
||||
|
||||
@@ -716,3 +753,724 @@ def test_api_documents_update_invalid_content():
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"content": ["Invalid base64 content."]}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PATCH tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via_parent", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("authenticated", "editor"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_patch_anonymous_forbidden(reach, role, via_parent):
|
||||
"""
|
||||
Anonymous users should not be allowed to patch a document when link
|
||||
configuration does not allow it.
|
||||
"""
|
||||
if via_parent:
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
else:
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = APIClient().patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
assert serializers.DocumentSerializer(instance=document).data == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via_parent", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"reach,role",
|
||||
[
|
||||
("public", "reader"),
|
||||
("authenticated", "reader"),
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_patch_authenticated_unrelated_forbidden(reach, role, via_parent):
|
||||
"""
|
||||
Authenticated users should not be allowed to patch a document to which
|
||||
they are not related if the link configuration does not allow it.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if via_parent:
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
else:
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
assert serializers.DocumentSerializer(instance=document).data == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via_parent", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"is_authenticated,reach,role",
|
||||
[
|
||||
(False, "public", "editor"),
|
||||
(True, "public", "editor"),
|
||||
(True, "authenticated", "editor"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_patch_anonymous_or_authenticated_unrelated(
|
||||
is_authenticated, reach, role, via_parent
|
||||
):
|
||||
"""
|
||||
Anonymous and authenticated users should be able to patch a document to which
|
||||
they are not related if the link configuration allows it.
|
||||
"""
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client.force_login(user)
|
||||
|
||||
if via_parent:
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
else:
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
old_path = document.path
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content, "websocket": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Using document.refresh_from_db does not wirk because the content is in cache.
|
||||
# Force reloading it by fetching the document in the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key in [
|
||||
"id",
|
||||
"title",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"creator",
|
||||
"depth",
|
||||
"numchild",
|
||||
"path",
|
||||
]:
|
||||
assert document_values[key] == old_document_values[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via_parent", [True, False])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_patch_authenticated_reader(via, via_parent, mock_user_teams):
|
||||
"""Users who are reader of a document should not be allowed to patch it."""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if via_parent:
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
access_document = grand_parent
|
||||
else:
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
access_document = document
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=access_document, user=user, role="reader"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=access_document, team="lasuite", role="reader"
|
||||
)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
assert serializers.DocumentSerializer(instance=document).data == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via_parent", [True, False])
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_patch_authenticated_editor_administrator_or_owner(
|
||||
via, role, via_parent, mock_user_teams
|
||||
):
|
||||
"""A user who is editor, administrator or owner of a document should be allowed to patch it."""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if via_parent:
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
access_document = grand_parent
|
||||
else:
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
access_document = document
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=access_document, user=user, role=role
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=access_document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
old_path = document.path
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content, "websocket": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Using document.refresh_from_db does not wirk because the content is in cache.
|
||||
# Force reloading it by fetching the document in the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key in [
|
||||
"id",
|
||||
"title",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"creator",
|
||||
"depth",
|
||||
"numchild",
|
||||
"path",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
]:
|
||||
assert document_values[key] == old_document_values[key]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_authenticated_no_websocket(settings):
|
||||
"""
|
||||
When a user patches the document, not connected to the websocket and is the first to update,
|
||||
the document should be updated.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Using document.refresh_from_db does not work because the content is cached.
|
||||
# Force reloading it by fetching the document from the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_authenticated_no_websocket_user_already_editing(settings):
|
||||
"""
|
||||
When a user patches the document, not connected to the websocket and is not the first to
|
||||
update, the document should not be updated.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||
|
||||
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "You are not allowed to edit this document."}
|
||||
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(settings):
|
||||
"""
|
||||
When a user patches the document, not connected to the websocket and another user is connected
|
||||
to the websocket, the document should not be updated.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "You are not allowed to edit this document."}
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_user_connected_to_websocket(settings):
|
||||
"""
|
||||
When a user patches the document while connected to the websocket, the document should be
|
||||
updated.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Using document.refresh_from_db does not wirk because the content is in cache.
|
||||
# Force reloading it by fetching the document in the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
When the websocket server is unreachable, the patch should be applied like if the user was
|
||||
not connected to the websocket.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Using document.refresh_from_db does not work because the content is cached.
|
||||
# Force reloading it by fetching the document from the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket_other_users(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
When the websocket server is unreachable, the behavior falls back to no-websocket.
|
||||
If another user is already editing, the patch must be denied.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
When the WebSocket server does not have the room created, the logic should fallback to
|
||||
no-WebSocket. If another user is already editing, the patch must be denied.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=404)
|
||||
|
||||
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_force_websocket_param_to_true(settings):
|
||||
"""
|
||||
When the websocket parameter is set to true, the patch should be applied without any check.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content, "websocket": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Using document.refresh_from_db does not work because the content is cached.
|
||||
# Force reloading it by fetching the document from the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_feature_flag_disabled(settings):
|
||||
"""
|
||||
When the feature flag is disabled, the patch should be applied without any check.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
old_path = document.path
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Using document.refresh_from_db does not work because the content is cached.
|
||||
# Force reloading it by fetching the document from the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_teams):
|
||||
"""
|
||||
Being administrator or owner of a document should not grant authorization to patch
|
||||
another document.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document,
|
||||
team="lasuite",
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
|
||||
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
|
||||
old_document_values = serializers.DocumentSerializer(instance=other_document).data
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{other_document.id!s}/",
|
||||
{"content": new_content},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
other_document.refresh_from_db()
|
||||
assert (
|
||||
serializers.DocumentSerializer(instance=other_document).data
|
||||
== old_document_values
|
||||
)
|
||||
|
||||
|
||||
def test_api_documents_patch_invalid_content():
|
||||
"""
|
||||
Patching a document with a non base64 encoded content should raise a validation error.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[[user, "owner"]])
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": "invalid content"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"content": ["Invalid base64 content."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_empty_body(settings):
|
||||
"""
|
||||
Test when data is empty the document should not be updated.
|
||||
The `updated_at` property should not change asserting that no update in the database is made.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")], creator=user)
|
||||
document_updated_at = document.updated_at
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
with patch("core.models.Document.save") as mock_document_save:
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
content_type="application/json",
|
||||
)
|
||||
mock_document_save.assert_not_called()
|
||||
assert response.status_code == 200
|
||||
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
new_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
assert new_document_values == old_document_values
|
||||
assert document_updated_at == document.updated_at
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""module testing the conditional_refresh_oidc_token utils."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from core.api import utils
|
||||
|
||||
|
||||
def test_refresh_oidc_access_token_storing_refresh_token_disabled(settings):
|
||||
"""The method_decorator must not be called when OIDC_STORE_REFRESH_TOKEN is False."""
|
||||
|
||||
settings.OIDC_STORE_REFRESH_TOKEN = False
|
||||
|
||||
callback = mock.MagicMock()
|
||||
|
||||
with mock.patch.object(utils, "method_decorator") as mock_method_decorator:
|
||||
result = utils.conditional_refresh_oidc_token(callback)
|
||||
|
||||
mock_method_decorator.assert_not_called()
|
||||
assert result == callback
|
||||
|
||||
|
||||
def test_refresh_oidc_access_token_storing_refresh_token_enabled(settings):
|
||||
"""The method_decorator must not be called when OIDC_STORE_REFRESH_TOKEN is False."""
|
||||
|
||||
settings.OIDC_STORE_REFRESH_TOKEN = True
|
||||
|
||||
callback = mock.MagicMock()
|
||||
|
||||
with mock.patch.object(utils, "method_decorator") as mock_method_decorator:
|
||||
utils.conditional_refresh_oidc_token(callback)
|
||||
|
||||
mock_method_decorator.assert_called_with(utils.refresh_oidc_access_token)
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.8.2"
|
||||
version = "4.8.3"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
@@ -131,12 +131,13 @@ test.describe('Doc Comments', () => {
|
||||
await thread.getByRole('paragraph').first().fill('This is a comment');
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
await expect(thread.getByText('This is a comment').first()).toBeHidden();
|
||||
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
|
||||
|
||||
// Check background color changed
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(237, 180, 0, 0.4)',
|
||||
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||
);
|
||||
|
||||
await editor.first().click();
|
||||
await editor.getByText('Hello').click();
|
||||
|
||||
@@ -185,6 +186,7 @@ test.describe('Doc Comments', () => {
|
||||
await thread.getByText('This is an edited comment').first().hover();
|
||||
await thread.locator('[data-test="resolve"]').click();
|
||||
await expect(thread).toBeHidden();
|
||||
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
@@ -196,11 +198,13 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
await thread.getByRole('paragraph').first().fill('This is a new comment');
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
|
||||
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(237, 180, 0, 0.4)',
|
||||
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||
);
|
||||
|
||||
await editor.first().click();
|
||||
await editor.getByText('Hello').click();
|
||||
|
||||
@@ -208,6 +212,7 @@ test.describe('Doc Comments', () => {
|
||||
await thread.locator('[data-test="moreactions"]').first().click();
|
||||
await getMenuItem(thread, 'Delete comment').click();
|
||||
|
||||
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
@@ -263,7 +268,7 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
await expect(otherEditor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(237, 180, 0, 0.4)',
|
||||
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||
);
|
||||
|
||||
// We change the role of the second user to reader
|
||||
@@ -298,7 +303,7 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
await expect(otherEditor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(237, 180, 0, 0.4)',
|
||||
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||
);
|
||||
await otherEditor.getByText('Hello').click();
|
||||
await expect(
|
||||
@@ -344,7 +349,7 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
await expect(editor1.getByText('Document One')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(237, 180, 0, 0.4)',
|
||||
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||
);
|
||||
|
||||
await editor1.getByText('Document One').click();
|
||||
|
||||
@@ -19,7 +19,9 @@ test.describe('Doc Table Content', () => {
|
||||
|
||||
await page.locator('.ProseMirror').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Summary' })).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Show the table of contents' }),
|
||||
).toBeHidden();
|
||||
|
||||
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3');
|
||||
|
||||
|
||||
@@ -164,11 +164,16 @@ test.describe('Doc Version', () => {
|
||||
await expect(modal.getByText('World')).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await expect(page.getByText('Your current document will')).toBeVisible();
|
||||
await page.getByText('If a member is editing, his').click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The current document will be replaced, but you'll still find it in the version history.",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Restore', { exact: true }).click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.getByText('Hello')).toBeVisible();
|
||||
await expect(page.getByText('World')).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Left panel desktop', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -121,6 +122,47 @@ test.describe('Left panel mobile', () => {
|
||||
await expect(logoutButton).toBeInViewport();
|
||||
});
|
||||
|
||||
test('checks panel closes when clicking on a subdoc', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [docTitle] = await createDoc(
|
||||
page,
|
||||
'mobile-doc-test',
|
||||
browserName,
|
||||
1,
|
||||
true,
|
||||
);
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'mobile-doc-test-child',
|
||||
true,
|
||||
);
|
||||
|
||||
const { name: docChild2 } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'mobile-doc-test-child-2',
|
||||
true,
|
||||
);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByLabel('Open the header menu').click();
|
||||
|
||||
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText(docTitle)).toBeVisible();
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
await expect(docTree.getByText(docChild2)).toBeVisible();
|
||||
|
||||
await docTree.getByText(docChild).click();
|
||||
await verifyDocName(page, docChild);
|
||||
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
|
||||
});
|
||||
|
||||
test('checks resize handle is not present on mobile', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -64,7 +64,7 @@
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.23",
|
||||
"luxon": "3.7.2",
|
||||
"next": "16.1.6",
|
||||
"next": "16.1.7",
|
||||
"posthog-js": "1.347.2",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.15.1",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm210-360h60v-180h40v120h60v-120h40v180h60v-200q0-17-11.5-28.5T630-680H450q-17 0-28.5 11.5T410-640v200Zm-50 120v-480 480Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Box, Loading } from '@/components';
|
||||
import { DocHeader, FloatingBar } from '@/docs/doc-header/';
|
||||
import { DocHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
LinkReach,
|
||||
@@ -35,7 +35,6 @@ export const DocEditorContainer = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDesktop && <FloatingBar />}
|
||||
<Box
|
||||
$maxWidth="868px"
|
||||
$width="100%"
|
||||
|
||||
+29
-4
@@ -8,12 +8,37 @@ export const cssComments = (
|
||||
& .--docs--main-editor .ProseMirror {
|
||||
// Comments marks in the editor
|
||||
.bn-editor {
|
||||
.bn-thread-mark:not([data-orphan='true']),
|
||||
.bn-thread-mark-selected:not([data-orphan='true']) {
|
||||
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
|
||||
color: var(--c--globals--colors--gray-700);
|
||||
// Resets blocknote comments styles
|
||||
.bn-thread-mark,
|
||||
.bn-thread-mark-selected {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
${canSeeComment &&
|
||||
css`
|
||||
.bn-thread-mark:not([data-orphan='true']) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
|
||||
transparent
|
||||
);
|
||||
border-bottom: 2px solid
|
||||
var(--c--contextuals--background--palette--yellow--secondary);
|
||||
|
||||
mix-blend-mode: multiply;
|
||||
|
||||
transition:
|
||||
background-color var(--c--globals--transitions--duration),
|
||||
border-bottom-color var(--c--globals--transitions--duration);
|
||||
|
||||
&:has(.bn-thread-mark-selected) {
|
||||
background-color: var(
|
||||
--c--contextuals--background--palette--yellow--tertiary
|
||||
);
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
[data-show-selection] {
|
||||
color: HighlightText;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,83 @@
|
||||
import { useEffect } from 'react';
|
||||
import { announce } from '@react-aria/live-announcer';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
const getFormattingShortcutLabel = (
|
||||
event: KeyboardEvent,
|
||||
t: (key: string) => string,
|
||||
): string | null => {
|
||||
const isMod = event.ctrlKey || event.metaKey;
|
||||
if (!isMod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
switch (event.code) {
|
||||
case 'Digit1':
|
||||
return t('Heading 1 applied');
|
||||
case 'Digit2':
|
||||
return t('Heading 2 applied');
|
||||
case 'Digit3':
|
||||
return t('Heading 3 applied');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
switch (event.code) {
|
||||
case 'Digit0':
|
||||
return t('Paragraph applied');
|
||||
case 'Digit6':
|
||||
return t('Toggle list applied');
|
||||
case 'Digit7':
|
||||
return t('Numbered list applied');
|
||||
case 'Digit8':
|
||||
return t('Bulleted list applied');
|
||||
case 'Digit9':
|
||||
return t('Checklist applied');
|
||||
case 'KeyC':
|
||||
return t('Code block applied');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useShortcuts = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
el: HTMLDivElement | null,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFormattingShortcut = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!editor?.isFocused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = getFormattingShortcutLabel(event, t);
|
||||
if (label) {
|
||||
setTimeout(() => {
|
||||
announce(label, 'assertive');
|
||||
}, 150);
|
||||
}
|
||||
},
|
||||
[editor, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
el?.addEventListener('keydown', handleFormattingShortcut, true);
|
||||
|
||||
return () => {
|
||||
el?.removeEventListener('keydown', handleFormattingShortcut, true);
|
||||
};
|
||||
}, [el, handleFormattingShortcut]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted
|
||||
if (!editor || !el) {
|
||||
|
||||
@@ -60,6 +60,23 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
const { untitledDocument } = useTrans();
|
||||
const mediaUrl = useMediaUrl();
|
||||
|
||||
const formatOptions = [
|
||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
||||
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
||||
{ label: t('HTML'), value: DocDownloadFormat.HTML },
|
||||
{ label: t('Print'), value: DocDownloadFormat.PRINT },
|
||||
];
|
||||
|
||||
const formatLabels = Object.fromEntries(
|
||||
formatOptions.map((opt) => [opt.value, opt.label]),
|
||||
);
|
||||
|
||||
const downloadButtonAriaLabel =
|
||||
format === DocDownloadFormat.PRINT
|
||||
? t('Print')
|
||||
: t('Download {{format}}', { format: formatLabels[format] });
|
||||
|
||||
async function onSubmit() {
|
||||
if (!editor) {
|
||||
toast(t('The export failed'), VariantType.ERROR);
|
||||
@@ -211,9 +228,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="doc-export-download-button"
|
||||
aria-label={
|
||||
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
|
||||
}
|
||||
aria-label={downloadButtonAriaLabel}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => void onSubmit()}
|
||||
@@ -260,13 +275,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
clearable={false}
|
||||
fullWidth
|
||||
label={t('Format')}
|
||||
options={[
|
||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
||||
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
||||
{ label: t('HTML'), value: DocDownloadFormat.HTML },
|
||||
{ label: t('Print'), value: DocDownloadFormat.PRINT },
|
||||
]}
|
||||
options={formatOptions}
|
||||
value={format}
|
||||
onChange={(options) =>
|
||||
setFormat(options.target.value as DocDownloadFormat)
|
||||
|
||||
@@ -17,6 +17,7 @@ import GroupSVG from '@/assets/icons/ui-kit/group.svg';
|
||||
import HistorySVG from '@/assets/icons/ui-kit/history.svg';
|
||||
import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
|
||||
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
|
||||
import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg';
|
||||
import {
|
||||
Box,
|
||||
DropdownMenu,
|
||||
@@ -193,7 +194,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
},
|
||||
{
|
||||
label: t('Copy as {{format}}', { format: 'Markdown' }),
|
||||
icon: <ContentCopySVG width={24} height={24} />,
|
||||
icon: <MarkdownCopySVG width={24} height={24} />,
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('markdown');
|
||||
},
|
||||
|
||||
+17
-1
@@ -30,6 +30,8 @@ const defaultValues = {
|
||||
|
||||
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
|
||||
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
...defaultValues,
|
||||
createProvider: (wsUrl, storeId, initialDoc) => {
|
||||
@@ -48,7 +50,20 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
onDisconnect(data) {
|
||||
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
|
||||
if ((data.event as ExtendedCloseEvent).wasClean) {
|
||||
void provider.connect();
|
||||
if (data.event.reason === 'No cookies' && data.event.code === 4001) {
|
||||
console.error(
|
||||
'Disconnection due to missing cookies. Not attempting to reconnect.',
|
||||
);
|
||||
void provider.disconnect();
|
||||
set({
|
||||
isReady: true,
|
||||
isConnected: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = setTimeout(() => void provider.connect(), 1000);
|
||||
}
|
||||
},
|
||||
onAuthenticationFailed() {
|
||||
@@ -107,6 +122,7 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
return provider;
|
||||
},
|
||||
destroyProvider: () => {
|
||||
clearTimeout(reconnectTimeout);
|
||||
const provider = get().provider;
|
||||
if (provider) {
|
||||
provider.destroy();
|
||||
|
||||
+3
-3
@@ -91,7 +91,7 @@ export const TableContent = () => {
|
||||
$height="100%"
|
||||
$justify="center"
|
||||
$align="center"
|
||||
aria-label={t('Summary')}
|
||||
aria-label={t('Show the table of contents')}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="toc-list"
|
||||
$css={css`
|
||||
@@ -218,8 +218,8 @@ const TableContentOpened = ({
|
||||
onClick={onClose}
|
||||
$justify="center"
|
||||
$align="center"
|
||||
aria-label={t('Summary')}
|
||||
aria-expanded="true"
|
||||
aria-label={t('Hide the table of contents')}
|
||||
aria-expanded={true}
|
||||
aria-controls="toc-list"
|
||||
$css={css`
|
||||
transition: none !important;
|
||||
|
||||
+23
-13
@@ -5,8 +5,8 @@ import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import {
|
||||
@@ -21,15 +21,22 @@ import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
|
||||
import { Versions } from '../types';
|
||||
import { revertUpdate } from '../utils';
|
||||
|
||||
interface ModalConfirmationVersionProps {
|
||||
onClose: () => void;
|
||||
docId: Doc['id'];
|
||||
const ModalStyle = createGlobalStyle`
|
||||
.c__modal__title {
|
||||
margin-bottom: var(--c--globals--spacings--sm);
|
||||
}
|
||||
`;
|
||||
|
||||
interface ModalConfirmationVersionProps {
|
||||
docId: Doc['id'];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
versionId: Versions['version_id'];
|
||||
}
|
||||
|
||||
export const ModalConfirmationVersion = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
docId,
|
||||
versionId,
|
||||
}: ModalConfirmationVersionProps) => {
|
||||
@@ -39,14 +46,13 @@ export const ModalConfirmationVersion = ({
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { push } = useRouter();
|
||||
const { provider } = useProviderStore();
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
onSuccess: () => {
|
||||
const onDisplaySuccess = () => {
|
||||
toast(t('Version restored successfully'), VariantType.SUCCESS);
|
||||
void push(`/docs/${docId}`);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
if (!provider || !version?.content) {
|
||||
@@ -64,6 +70,10 @@ export const ModalConfirmationVersion = ({
|
||||
},
|
||||
});
|
||||
|
||||
if (!version) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
@@ -102,7 +112,7 @@ export const ModalConfirmationVersion = ({
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.SMALL}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Text
|
||||
as="h1"
|
||||
@@ -111,17 +121,17 @@ export const ModalConfirmationVersion = ({
|
||||
$size="h6"
|
||||
$align="flex-start"
|
||||
>
|
||||
{t('Warning')}
|
||||
{t('Restoring an older version')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ModalStyle />
|
||||
<Box className="--docs--modal-confirmation-version">
|
||||
<Box>
|
||||
<Text $variation="secondary" as="p">
|
||||
{t('Your current document will revert to this version.')}
|
||||
</Text>
|
||||
<Text $variation="secondary" as="p">
|
||||
{t('If a member is editing, his works can be lost.')}
|
||||
<Text $variation="secondary" as="p" $margin="none">
|
||||
{t(
|
||||
"The current document will be replaced, but you'll still find it in the version history.",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
+9
-3
@@ -114,7 +114,8 @@ export const ModalSelectVersion = ({
|
||||
$height="calc(100vh - 2em - 12px)"
|
||||
$css={css`
|
||||
overflow-y: hidden;
|
||||
border-left: 1px solid var(--c--globals--colors--gray-200);
|
||||
border-left: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
@@ -130,7 +131,8 @@ export const ModalSelectVersion = ({
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
border-bottom: 1px solid var(--c--globals--colors--gray-200);
|
||||
border-bottom: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
$padding="sm"
|
||||
>
|
||||
@@ -155,7 +157,8 @@ export const ModalSelectVersion = ({
|
||||
<Box
|
||||
$padding="xs"
|
||||
$css={css`
|
||||
border-top: 1px solid var(--c--globals--colors--gray-200);
|
||||
border-top: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
@@ -174,6 +177,9 @@ export const ModalSelectVersion = ({
|
||||
<ModalConfirmationVersion
|
||||
onClose={() => {
|
||||
restoreModal.close();
|
||||
}}
|
||||
onSuccess={() => {
|
||||
restoreModal.close();
|
||||
onClose();
|
||||
setSelectedVersionId(undefined);
|
||||
}}
|
||||
|
||||
+23
-79
@@ -1,94 +1,38 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, BoxButton, Text } from '@/components';
|
||||
import { BoxButton, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
|
||||
import { Versions } from '../types';
|
||||
|
||||
const ModalConfirmationVersion = dynamic(
|
||||
() =>
|
||||
import('./ModalConfirmationVersion').then((mod) => ({
|
||||
default: mod.ModalConfirmationVersion,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface VersionItemProps {
|
||||
docId: Doc['id'];
|
||||
text: string;
|
||||
versionId?: Versions['version_id'];
|
||||
isActive: boolean;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
export const VersionItem = ({
|
||||
docId,
|
||||
versionId,
|
||||
text,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: VersionItemProps) => {
|
||||
export const VersionItem = ({ text, isActive, onSelect }: VersionItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoxButton
|
||||
aria-label={t('Restore version of {{date}}', { date: text })}
|
||||
aria-pressed={isActive}
|
||||
$width="100%"
|
||||
$css={`
|
||||
&:focus-visible {
|
||||
background: var(--c--globals--colors--gray-100);
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
className="version-item"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Box
|
||||
$width="100%"
|
||||
as="span"
|
||||
$background={isActive ? colorsTokens['gray-100'] : 'transparent'}
|
||||
$radius={spacingsTokens['3xs']}
|
||||
$css={`
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: ${colorsTokens['gray-100']};
|
||||
}
|
||||
`}
|
||||
$hasTransition
|
||||
$minWidth="13rem"
|
||||
className="--docs--version-item"
|
||||
>
|
||||
<Box
|
||||
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
|
||||
$align="center"
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$width="100%"
|
||||
>
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<Text $weight="bold" $size="sm">
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</BoxButton>
|
||||
{isModalVersionOpen && versionId && (
|
||||
<ModalConfirmationVersion
|
||||
onClose={() => setIsModalVersionOpen(false)}
|
||||
docId={docId}
|
||||
versionId={versionId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<BoxButton
|
||||
aria-label={t('Restore version of {{date}}', { date: text })}
|
||||
aria-pressed={isActive}
|
||||
$width="100%"
|
||||
$css={`
|
||||
background: ${isActive ? 'var(--c--contextuals--background--semantic--overlay--primary)' : 'transparent'};
|
||||
&:focus-visible, &:hover {
|
||||
background: var(--c--contextuals--background--semantic--overlay--primary);
|
||||
}
|
||||
`}
|
||||
className="version-item --docs--version-item"
|
||||
onClick={onSelect}
|
||||
$radius={spacingsTokens['3xs']}
|
||||
$padding={{ vertical: 'm', horizontal: 'xs' }}
|
||||
$hasTransition
|
||||
>
|
||||
<Text $weight="bold" $size="sm" $textAlign="left">
|
||||
{text}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-10
@@ -16,7 +16,6 @@ interface VersionListStateProps {
|
||||
isLoading: boolean;
|
||||
error: APIError<unknown> | null;
|
||||
versions?: Versions[];
|
||||
doc: Doc;
|
||||
selectedVersionId?: Versions['version_id'];
|
||||
onSelectVersion?: (versionId: Versions['version_id']) => void;
|
||||
}
|
||||
@@ -24,13 +23,11 @@ interface VersionListStateProps {
|
||||
const VersionListState = ({
|
||||
onSelectVersion,
|
||||
selectedVersionId,
|
||||
|
||||
isLoading,
|
||||
error,
|
||||
versions,
|
||||
doc,
|
||||
}: VersionListStateProps) => {
|
||||
const { formatDate } = useDate();
|
||||
const { formatDateSpecial } = useDate();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -41,19 +38,17 @@ const VersionListState = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box $gap="10px" $padding="xs">
|
||||
<Box $gap="xxs" $padding="xs">
|
||||
{versions?.map((version) => {
|
||||
const formattedDate = formatDate(
|
||||
const formattedDate = formatDateSpecial(
|
||||
version.last_modified,
|
||||
DateTime.DATETIME_MED,
|
||||
'dd MMMM · HH:mm',
|
||||
);
|
||||
const isSelected = version.version_id === selectedVersionId;
|
||||
return (
|
||||
<Box as="li" key={version.version_id} $css="list-style: none;">
|
||||
<VersionItem
|
||||
versionId={version.version_id}
|
||||
text={formattedDate}
|
||||
docId={doc.id}
|
||||
isActive={isSelected}
|
||||
onSelect={() => onSelectVersion?.(version.version_id)}
|
||||
/>
|
||||
@@ -142,7 +137,6 @@ export const VersionList = ({
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
versions={versions}
|
||||
doc={doc}
|
||||
selectedVersionId={selectedVersionId}
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
@@ -23,11 +25,15 @@ const MobileLeftPanelStyle = createGlobalStyle`
|
||||
|
||||
export const LeftPanel = () => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { t } = useTranslation();
|
||||
if (isDesktop) {
|
||||
return <LeftPanelDesktop />;
|
||||
}
|
||||
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { isPanelOpen, isPanelOpenMobile } = useLeftPanelStore();
|
||||
const isPanelOpenState = isDesktop ? isPanelOpen : isPanelOpenMobile;
|
||||
return <LeftPanelMobile />;
|
||||
};
|
||||
|
||||
export const LeftPanelDesktop = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
/**
|
||||
* The onboarding can be disable, so we need to check if it's enabled before displaying the help menu.
|
||||
@@ -36,42 +42,51 @@ export const LeftPanel = () => {
|
||||
*/
|
||||
const showHelpMenu = config?.theme_customization?.onboarding?.enabled;
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
return (
|
||||
<Box
|
||||
data-testid="left-panel-desktop"
|
||||
$css={css`
|
||||
height: calc(100vh - ${HEADER_HEIGHT}px);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--c--contextuals--background--surface--primary);
|
||||
`}
|
||||
className="--docs--left-panel-desktop"
|
||||
as="nav"
|
||||
aria-label={t('Document sections')}
|
||||
>
|
||||
<Box
|
||||
data-testid="left-panel-desktop"
|
||||
$css={css`
|
||||
height: calc(100vh - ${HEADER_HEIGHT}px);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--c--contextuals--background--surface--primary);
|
||||
flex: 0 0 auto;
|
||||
`}
|
||||
className="--docs--left-panel-desktop"
|
||||
as="nav"
|
||||
aria-label={t('Document sections')}
|
||||
>
|
||||
<Box
|
||||
$css={css`
|
||||
flex: 0 0 auto;
|
||||
`}
|
||||
>
|
||||
<LeftPanelHeader />
|
||||
</Box>
|
||||
<LeftPanelContent />
|
||||
{showHelpMenu && (
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<Box $padding={{ horizontal: 'sm' }} $justify="flex-start">
|
||||
<HelpMenu />
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
)}
|
||||
<LeftPanelHeader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
<LeftPanelContent />
|
||||
{showHelpMenu && (
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<Box $padding={{ horizontal: 'sm' }} $justify="flex-start">
|
||||
<HelpMenu />
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const LeftPanelMobile = () => {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { closePanel, isPanelOpenMobile } = useLeftPanelStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
closePanel({ type: 'mobile' });
|
||||
}, [pathname, closePanel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPanelOpenState && <MobileLeftPanelStyle />}
|
||||
{isPanelOpenMobile && <MobileLeftPanelStyle />}
|
||||
<Box
|
||||
$hasTransition
|
||||
$height="100vh"
|
||||
@@ -81,7 +96,7 @@ export const LeftPanel = () => {
|
||||
height: calc(100dvh - 52px);
|
||||
border-right: 1px solid var(--c--globals--colors--gray-200);
|
||||
position: fixed;
|
||||
transform: translateX(${isPanelOpenState ? '0' : '-100dvw'});
|
||||
transform: translateX(${isPanelOpenMobile ? '0' : '-100dvw'});
|
||||
background-color: var(--c--contextuals--background--surface--primary);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
+39
-1
@@ -18,8 +18,46 @@ export const LeftPanelCollapseButton = () => {
|
||||
const { isPanelOpen, togglePanel } = useLeftPanelStore();
|
||||
const { currentDoc } = useDocStore();
|
||||
const [isDocTitleVisible, setIsDocTitleVisible] = useState(true);
|
||||
const [isDocTitleInDom, setIsDocTitleInDom] = useState(true);
|
||||
|
||||
/**
|
||||
* CLASS_DOC_TITLE is not every time in the DOM when
|
||||
* this component is rendered, we need to observe the DOM
|
||||
* to know when it is added, then we can observe
|
||||
* its visibility.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setIsDocTitleInDom(false);
|
||||
|
||||
const docTitleEl = document.querySelector(`.${CLASS_DOC_TITLE}`);
|
||||
if (docTitleEl) {
|
||||
setIsDocTitleInDom(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
if (document.querySelector(`.${CLASS_DOC_TITLE}`)) {
|
||||
mutationObserver.disconnect();
|
||||
setIsDocTitleInDom(true);
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [currentDoc?.id]);
|
||||
|
||||
/**
|
||||
* When the doc title is in the DOM, we observe its
|
||||
* visibility to show or hide the collapse button accordingly
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isDocTitleInDom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
||||
const docTitleEl = document.querySelector(`.${CLASS_DOC_TITLE}`);
|
||||
|
||||
@@ -43,7 +81,7 @@ export const LeftPanelCollapseButton = () => {
|
||||
observer.disconnect();
|
||||
setIsDocTitleVisible(true);
|
||||
};
|
||||
}, [currentDoc?.id]);
|
||||
}, [isDocTitleInDom]);
|
||||
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
|
||||
+50
-1
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ImperativePanelHandle,
|
||||
Panel,
|
||||
@@ -22,12 +23,15 @@ type ResizableLeftPanelProps = {
|
||||
maxPanelSizePx?: number;
|
||||
};
|
||||
|
||||
const RESIZE_HANDLE_ID = 'left-panel-resize-handle';
|
||||
|
||||
export const ResizableLeftPanel = ({
|
||||
leftPanel,
|
||||
children,
|
||||
minPanelSizePx = 300,
|
||||
maxPanelSizePx = 450,
|
||||
}: ResizableLeftPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { isPanelOpen } = useLeftPanelStore();
|
||||
const ref = useRef<ImperativePanelHandle>(null);
|
||||
@@ -96,6 +100,50 @@ export const ResizableLeftPanel = ({
|
||||
};
|
||||
}, [isDesktop]);
|
||||
|
||||
/**
|
||||
* Workaround: NVDA does not enter focus mode for role="separator"
|
||||
* intercepted by browse-mode navigation and never reach the handle.
|
||||
* Changing the role to "slider" makes NVDA reliably switch to focus
|
||||
* mode, restoring progressive keyboard resize with arrow keys.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isPanelOpen) {
|
||||
return;
|
||||
}
|
||||
const handle = document.getElementById(RESIZE_HANDLE_ID);
|
||||
if (!handle) {
|
||||
return;
|
||||
}
|
||||
|
||||
handle.setAttribute('role', 'slider');
|
||||
handle.setAttribute('aria-orientation', 'vertical');
|
||||
handle.setAttribute('aria-label', t('Resize sidebar'));
|
||||
|
||||
const updateValueText = () => {
|
||||
const value = handle.getAttribute('aria-valuenow');
|
||||
if (value) {
|
||||
const widthPx = Math.round(
|
||||
(parseFloat(value) / 100) * window.innerWidth,
|
||||
);
|
||||
handle.setAttribute(
|
||||
'aria-valuetext',
|
||||
t('Sidebar width: {{widthPx}} pixels', { widthPx }),
|
||||
);
|
||||
}
|
||||
};
|
||||
updateValueText();
|
||||
|
||||
const observer = new MutationObserver(updateValueText);
|
||||
observer.observe(handle, {
|
||||
attributes: true,
|
||||
attributeFilter: ['aria-valuenow'],
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isPanelOpen, t]);
|
||||
|
||||
const handleResize = (sizePercent: number) => {
|
||||
const widthPx = (sizePercent / 100) * window.innerWidth;
|
||||
savedWidthPxRef.current = widthPx;
|
||||
@@ -103,7 +151,7 @@ export const ResizableLeftPanel = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelGroup direction="horizontal">
|
||||
<PanelGroup direction="horizontal" keyboardResizeBy={1}>
|
||||
<Panel
|
||||
ref={ref}
|
||||
className="--docs--resizable-left-panel"
|
||||
@@ -132,6 +180,7 @@ export const ResizableLeftPanel = ({
|
||||
</Panel>
|
||||
{isPanelOpen && (
|
||||
<PanelResizeHandle
|
||||
id={RESIZE_HANDLE_ID}
|
||||
style={{
|
||||
borderRightWidth: '1px',
|
||||
borderRightStyle: 'solid',
|
||||
|
||||
@@ -21,6 +21,10 @@ export const useDate = () => {
|
||||
.toLocaleString(format);
|
||||
};
|
||||
|
||||
const formatDateSpecial = (date: string, format: string): string => {
|
||||
return DateTime.fromISO(date).setLocale(i18n.language).toFormat(format);
|
||||
};
|
||||
|
||||
const relativeDate = (date: string): string => {
|
||||
const dateToCompare = DateTime.fromISO(date);
|
||||
|
||||
@@ -45,5 +49,5 @@ export const useDate = () => {
|
||||
),
|
||||
);
|
||||
|
||||
return { formatDate, relativeDate, calculateDaysLeft };
|
||||
return { formatDate, formatDateSpecial, relativeDate, calculateDaysLeft };
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useTrans,
|
||||
} from '@/docs/doc-management/';
|
||||
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
|
||||
import { FloatingBar } from '@/features/docs/doc-header/components/FloatingBar';
|
||||
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
|
||||
import { DocEditorSkeleton, useSkeletonStore } from '@/features/skeletons';
|
||||
import { MainLayout } from '@/layouts';
|
||||
@@ -60,6 +61,7 @@ export function DocLayout() {
|
||||
}}
|
||||
>
|
||||
<MainLayout enableResizablePanel={true}>
|
||||
<FloatingBar />
|
||||
<DocPage id={id} />
|
||||
</MainLayout>
|
||||
</TreeProvider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "impress",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"private": true,
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-plugin-docs",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
+56
-56
@@ -2868,10 +2868,10 @@
|
||||
"@emnapi/runtime" "^1.4.3"
|
||||
"@tybys/wasm-util" "^0.10.0"
|
||||
|
||||
"@next/env@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/env/-/env-16.1.6.tgz#0f85979498249a94ef606ef535042a831f905e89"
|
||||
integrity sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==
|
||||
"@next/env@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/env/-/env-16.1.7.tgz#169482a9a76aab0d9360813df898a88667d79ffc"
|
||||
integrity sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==
|
||||
|
||||
"@next/eslint-plugin-next@16.1.6":
|
||||
version "16.1.6"
|
||||
@@ -2880,45 +2880,45 @@
|
||||
dependencies:
|
||||
fast-glob "3.3.1"
|
||||
|
||||
"@next/swc-darwin-arm64@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz#fbe1e360efdcc9ebd0a10301518275bc59e12a91"
|
||||
integrity sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==
|
||||
"@next/swc-darwin-arm64@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz#6022bec143c23837ca4744fee4ab48b0f74b0faa"
|
||||
integrity sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==
|
||||
|
||||
"@next/swc-darwin-x64@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz#0e3781ef3abc8251c2a21addc733d9a87f44829b"
|
||||
integrity sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==
|
||||
"@next/swc-darwin-x64@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz#3f1604e5a59645a0394b74e50791ff2c477167b8"
|
||||
integrity sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==
|
||||
|
||||
"@next/swc-linux-arm64-gnu@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz#b24511af2c6129f2deaf5c8c04d297fe09cd40d7"
|
||||
integrity sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==
|
||||
"@next/swc-linux-arm64-gnu@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz#458c98c8b790efa30bc2866f715352ba3cb47cb5"
|
||||
integrity sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==
|
||||
|
||||
"@next/swc-linux-arm64-musl@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz#9d4ed0565689fc6a867250f994736a5b8c542ccb"
|
||||
integrity sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==
|
||||
"@next/swc-linux-arm64-musl@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz#1a6ae4d0894f8751f8951ca0fed6e103e27bfc7a"
|
||||
integrity sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==
|
||||
|
||||
"@next/swc-linux-x64-gnu@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz#cc757f4384e7eab7d3dba704a97f737518bae0d2"
|
||||
integrity sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==
|
||||
"@next/swc-linux-x64-gnu@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz#183126cfbf45a96e3b3b37e35b5429e4c48795bd"
|
||||
integrity sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==
|
||||
|
||||
"@next/swc-linux-x64-musl@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz#ef1341740f29717deea7c6ec27ae6269386e20d1"
|
||||
integrity sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==
|
||||
"@next/swc-linux-x64-musl@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz#59906d387aa934fc2d066ff6c0ba695ebc904381"
|
||||
integrity sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==
|
||||
|
||||
"@next/swc-win32-arm64-msvc@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz#fee8719242aecf9c39c3a66f1f73821f7884dd16"
|
||||
integrity sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==
|
||||
"@next/swc-win32-arm64-msvc@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz#e97a31605ca10e5ca493555f840e4972496ce350"
|
||||
integrity sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==
|
||||
|
||||
"@next/swc-win32-x64-msvc@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz#60c27323c30f35722b20fd6d62449fbb768e46d9"
|
||||
integrity sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==
|
||||
"@next/swc-win32-x64-msvc@16.1.7":
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz#eeaf9fb75de437232e1c8b46d16d1ba8b0c635c2"
|
||||
integrity sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==
|
||||
|
||||
"@noble/hashes@^2.0.1":
|
||||
version "2.0.1"
|
||||
@@ -4241,7 +4241,7 @@
|
||||
"@react-types/shared" "^3.33.0"
|
||||
"@swc/helpers" "^0.5.0"
|
||||
|
||||
"@react-aria/live-announcer@^3.4.2", "@react-aria/live-announcer@^3.4.4":
|
||||
"@react-aria/live-announcer@3.4.4", "@react-aria/live-announcer@^3.4.2", "@react-aria/live-announcer@^3.4.4":
|
||||
version "3.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@react-aria/live-announcer/-/live-announcer-3.4.4.tgz#0e6533940222208b323b71d56ac8e115b2121e6a"
|
||||
integrity sha512-PTTBIjNRnrdJOIRTDGNifY2d//kA7GUAwRFJNOEwSNG4FW+Bq9awqLiflw0JkpyB0VNIwou6lqKPHZVLsGWOXA==
|
||||
@@ -8585,11 +8585,6 @@ base64-js@^1.1.2, base64-js@^1.3.0, base64-js@^1.3.1:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.8.3:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9"
|
||||
integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==
|
||||
|
||||
baseline-browser-mapping@^2.8.9:
|
||||
version "2.8.19"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz#8d99bb7f06bc6ea5c9c1b961e631a1713069bbe0"
|
||||
@@ -8600,6 +8595,11 @@ baseline-browser-mapping@^2.9.0:
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz#fbed5f37edf24b708e6e0b1fb26c70982a577dfc"
|
||||
integrity sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==
|
||||
|
||||
baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.9"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz#8614229add633061c001a0b7c7c85d4b7c44e6ca"
|
||||
integrity sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==
|
||||
|
||||
bidi-js@^1.0.2, bidi-js@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2"
|
||||
@@ -13350,26 +13350,26 @@ neo-async@^2.6.2:
|
||||
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
||||
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
||||
|
||||
next@16.1.6:
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/next/-/next-16.1.6.tgz#24a861371cbe211be7760d9a89ddf2415e3824de"
|
||||
integrity sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==
|
||||
next@16.1.7:
|
||||
version "16.1.7"
|
||||
resolved "https://registry.yarnpkg.com/next/-/next-16.1.7.tgz#fccdda75050ffc11ace27526b8a9ac7c308c8c48"
|
||||
integrity sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==
|
||||
dependencies:
|
||||
"@next/env" "16.1.6"
|
||||
"@next/env" "16.1.7"
|
||||
"@swc/helpers" "0.5.15"
|
||||
baseline-browser-mapping "^2.8.3"
|
||||
baseline-browser-mapping "^2.9.19"
|
||||
caniuse-lite "^1.0.30001579"
|
||||
postcss "8.4.31"
|
||||
styled-jsx "5.1.6"
|
||||
optionalDependencies:
|
||||
"@next/swc-darwin-arm64" "16.1.6"
|
||||
"@next/swc-darwin-x64" "16.1.6"
|
||||
"@next/swc-linux-arm64-gnu" "16.1.6"
|
||||
"@next/swc-linux-arm64-musl" "16.1.6"
|
||||
"@next/swc-linux-x64-gnu" "16.1.6"
|
||||
"@next/swc-linux-x64-musl" "16.1.6"
|
||||
"@next/swc-win32-arm64-msvc" "16.1.6"
|
||||
"@next/swc-win32-x64-msvc" "16.1.6"
|
||||
"@next/swc-darwin-arm64" "16.1.7"
|
||||
"@next/swc-darwin-x64" "16.1.7"
|
||||
"@next/swc-linux-arm64-gnu" "16.1.7"
|
||||
"@next/swc-linux-arm64-musl" "16.1.7"
|
||||
"@next/swc-linux-x64-gnu" "16.1.7"
|
||||
"@next/swc-linux-x64-musl" "16.1.7"
|
||||
"@next/swc-win32-arm64-msvc" "16.1.7"
|
||||
"@next/swc-win32-x64-msvc" "16.1.7"
|
||||
sharp "^0.34.4"
|
||||
|
||||
no-case@^3.0.4:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
environments:
|
||||
dev:
|
||||
values:
|
||||
- version: 4.8.2
|
||||
- version: 4.8.3
|
||||
feature:
|
||||
values:
|
||||
- version: 4.8.2
|
||||
- version: 4.8.3
|
||||
feature: ci
|
||||
domain: example.com
|
||||
imageTag: demo
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 4.8.2
|
||||
version: 4.8.3
|
||||
appVersion: latest
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user