mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-05 06:32:21 +02:00
Compare commits
42 Commits
v4.8.2-pre
...
config/inc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cbd43caae | ||
|
|
525d8c8417 | ||
|
|
c886cbb41d | ||
|
|
98f3ca2763 | ||
|
|
fb92a43755 | ||
|
|
03fd1fe50e | ||
|
|
fc803226ac | ||
|
|
fb725edda3 | ||
|
|
6838b387a2 | ||
|
|
87f570582f | ||
|
|
37f56fcc22 | ||
|
|
19aa3a36bc | ||
|
|
0d09f761dc | ||
|
|
ce5f9a1417 | ||
|
|
83a24c3796 | ||
|
|
4a269e6b0e | ||
|
|
d9d7b70b71 | ||
|
|
a4326366c2 | ||
|
|
1d7b57e03d | ||
|
|
c4c6c22e42 | ||
|
|
10a8eccc71 | ||
|
|
728332f8f7 | ||
|
|
487b95c207 | ||
|
|
d23b38e478 | ||
|
|
d6333c9b81 | ||
|
|
03b6c6a206 | ||
|
|
aadabf8d3c | ||
|
|
2a708d6e46 | ||
|
|
b47c730e19 | ||
|
|
cef83067e6 | ||
|
|
4cabfcc921 | ||
|
|
b8d4b0a044 | ||
|
|
71c4d2921b | ||
|
|
d1636dee13 | ||
|
|
bf93640af8 | ||
|
|
da79c310ae | ||
|
|
99c486571d | ||
|
|
cdf3161869 | ||
|
|
ef108227b3 | ||
|
|
9991820cb1 | ||
|
|
2801ece358 | ||
|
|
0b37996899 |
59
CHANGELOG.md
59
CHANGELOG.md
@@ -6,12 +6,58 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 🚸(frontend) hint min char search users #2064
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 💄(frontend) improve comments highlights #1961
|
||||||
|
- ♿️(frontend) improve BoxButton a11y and native button semantics #2103
|
||||||
|
- ♿️(frontend) improve language picker accessibility #2069
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129
|
||||||
|
|
||||||
|
## [v4.8.3] - 2026-03-23
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♿️(frontend) improve version history list accessibility #2033
|
||||||
|
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983
|
||||||
|
- ♿️(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
|
## [v4.8.2] - 2026-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) add resource server api #1923
|
||||||
|
- ✨(frontend) activate Find search #1834
|
||||||
|
- ✨ handle searching on subdocuments #1834
|
||||||
|
- ✨(backend) add search feature flags #1897
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ♿️(frontend) ensure doc title is h1 for accessibility #2006
|
- ♿️(frontend) ensure doc title is h1 for accessibility #2006
|
||||||
- ♿️(frontend) add nb accesses in share button aria-label #2017
|
- ♿️(frontend) add nb accesses in share button aria-label #2017
|
||||||
|
- ✨(backend) improve fallback logic on search endpoint #1834
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -25,6 +71,11 @@ and this project adheres to
|
|||||||
- 🐛(backend) stop using add_sibling method to create sandbox document #2084
|
- 🐛(backend) stop using add_sibling method to create sandbox document #2084
|
||||||
- 🐛(backend) duplicate a document as last-sibling #2084
|
- 🐛(backend) duplicate a document as last-sibling #2084
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
|
||||||
|
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
|
||||||
|
|
||||||
## [v4.8.1] - 2026-03-17
|
## [v4.8.1] - 2026-03-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -41,7 +92,6 @@ and this project adheres to
|
|||||||
|
|
||||||
- ✨(backend) add a is_first_connection flag to the User model #1938
|
- ✨(backend) add a is_first_connection flag to the User model #1938
|
||||||
- ✨(frontend) add onboarding modal with help menu button #1868
|
- ✨(frontend) add onboarding modal with help menu button #1868
|
||||||
- ✨(backend) add resource server api #1923
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -127,16 +177,12 @@ and this project adheres to
|
|||||||
- ✨(frontend) Add stat for Crisp #1824
|
- ✨(frontend) Add stat for Crisp #1824
|
||||||
- ✨(auth) add silent login #1690
|
- ✨(auth) add silent login #1690
|
||||||
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
||||||
- ✨(frontend) activate Find search #1834
|
|
||||||
- ✨ handle searching on subdocuments #1834
|
|
||||||
- ✨(backend) add search feature flags #1897
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ♿(frontend) improve accessibility:
|
- ♿(frontend) improve accessibility:
|
||||||
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
|
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
|
||||||
- ✨(backend) add field for button label in email template #1817
|
- ✨(backend) add field for button label in email template #1817
|
||||||
- ✨(backend) improve fallback logic on search endpoint #1834
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -1141,7 +1187,8 @@ and this project adheres to
|
|||||||
- ✨(frontend) Coming Soon page (#67)
|
- ✨(frontend) Coming Soon page (#67)
|
||||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
- 🚀 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.2]: https://github.com/suitenumerique/docs/releases/v4.8.2
|
||||||
[v4.8.1]: https://github.com/suitenumerique/docs/releases/v4.8.1
|
[v4.8.1]: https://github.com/suitenumerique/docs/releases/v4.8.1
|
||||||
[v4.8.0]: https://github.com/suitenumerique/docs/releases/v4.8.0
|
[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=""
|
OIDC_RS_ALLOWED_AUDIENCES=""
|
||||||
|
|
||||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||||
OIDC_STORE_ACCESS_TOKEN=True
|
# OIDC_STORE_ACCESS_TOKEN=True
|
||||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
|
# 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)
|
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||||
# To create one, use the bin/fernetkey command.
|
# To create one, use the bin/fernetkey command.
|
||||||
|
|||||||
@@ -60,10 +60,13 @@
|
|||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
|
"@react-pdf/renderer",
|
||||||
"fetch-mock",
|
"fetch-mock",
|
||||||
"node",
|
"node",
|
||||||
"node-fetch",
|
"node-fetch",
|
||||||
"react-resizable-panels",
|
"react-resizable-panels",
|
||||||
|
"stylelint",
|
||||||
|
"stylelint-config-standard",
|
||||||
"workbox-webpack-plugin"
|
"workbox-webpack-plugin"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,6 +300,15 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
|
|
||||||
return file
|
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):
|
def save(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Process the content field to extract attachment keys and update the document's
|
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.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
|
||||||
import botocore
|
import botocore
|
||||||
|
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||||
from rest_framework.throttling import BaseThrottle
|
from rest_framework.throttling import BaseThrottle
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +93,19 @@ def generate_s3_authorization_headers(key):
|
|||||||
return request
|
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):
|
class AIBaseRateThrottle(BaseThrottle, ABC):
|
||||||
"""Base throttle class for AI-related rate limiting with backoff."""
|
"""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.http import Http404, StreamingHttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.http import content_disposition_header
|
from django.utils.http import content_disposition_header
|
||||||
from django.utils.text import capfirst, slugify
|
from django.utils.text import capfirst, slugify
|
||||||
@@ -38,7 +37,6 @@ from botocore.exceptions import ClientError
|
|||||||
from csp.constants import NONE
|
from csp.constants import NONE
|
||||||
from csp.decorators import csp_update
|
from csp.decorators import csp_update
|
||||||
from lasuite.malware_detection import malware_detection
|
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 lasuite.tools.email import get_domain_from_email
|
||||||
from pydantic import ValidationError as PydanticValidationError
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from rest_framework import filters, status, viewsets
|
from rest_framework import filters, status, viewsets
|
||||||
@@ -1415,7 +1413,7 @@ class DocumentViewSet(
|
|||||||
return duplicated_document
|
return duplicated_document
|
||||||
|
|
||||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
@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):
|
def search(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Returns an ordered list of documents best matching the search query parameter 'q'.
|
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 = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||||
params.is_valid(raise_exception=True)
|
params.is_valid(raise_exception=True)
|
||||||
search_type = self._get_search_type()
|
search_type = self._get_search_type()
|
||||||
|
|
||||||
if search_type == SearchType.TITLE:
|
if search_type == SearchType.TITLE:
|
||||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
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")
|
@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,
|
When indexer is not configured and no path is provided,
|
||||||
should fall back on list method
|
should fall back on list method
|
||||||
"""
|
"""
|
||||||
indexer_settings.SEARCH_URL = None
|
|
||||||
assert get_document_indexer() is 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()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(
|
||||||
|
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||||
|
)
|
||||||
|
|
||||||
mocked_response = {
|
mocked_response = {
|
||||||
"count": 0,
|
"count": 0,
|
||||||
@@ -93,6 +96,8 @@ def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settin
|
|||||||
q = "alpha"
|
q = "alpha"
|
||||||
response = client.get("/api/v1.0/documents/search/", data={"q": q})
|
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_count == 1
|
||||||
assert mock_list.call_args[0][0].GET.get("q") == q
|
assert mock_list.call_args[0][0].GET.get("q") == q
|
||||||
assert response.json() == mocked_response
|
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")
|
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
|
||||||
def test_api_documents_search_fallback_on_search_list_sub_docs(
|
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,
|
When indexer is not configured and path parameter is provided,
|
||||||
should call _list_descendants() method
|
should call _list_descendants() method
|
||||||
"""
|
"""
|
||||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
assert get_document_indexer() is None
|
||||||
assert get_document_indexer() is not None
|
assert settings.OIDC_STORE_REFRESH_TOKEN is False
|
||||||
|
assert settings.OIDC_STORE_ACCESS_TOKEN is False
|
||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(
|
||||||
|
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||||
|
)
|
||||||
|
|
||||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
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}
|
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert mock_list_descendants.call_count == 1
|
mock_list_descendants.assert_called_with(
|
||||||
assert mock_list_descendants.call_args[0][0].GET.get("q") == q
|
mock.ANY, {"q": "alpha", "path": parent.path}
|
||||||
assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path
|
)
|
||||||
assert response.json() == mocked_response
|
assert response.json() == mocked_response
|
||||||
|
|
||||||
|
|
||||||
@@ -152,7 +160,9 @@ def test_api_documents_search_indexer_crashes(mock_title_search, indexer_setting
|
|||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(
|
||||||
|
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||||
|
)
|
||||||
|
|
||||||
mocked_response = {
|
mocked_response = {
|
||||||
"count": 0,
|
"count": 0,
|
||||||
@@ -185,7 +195,9 @@ def test_api_documents_search_invalid_params(indexer_settings):
|
|||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(
|
||||||
|
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||||
|
)
|
||||||
|
|
||||||
response = client.get("/api/v1.0/documents/search/")
|
response = client.get("/api/v1.0/documents/search/")
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Tests for Documents API endpoint in impress's core app: update
|
Tests for Documents API endpoint in impress's core app: update
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -17,6 +19,25 @@ from core.tests.conftest import TEAM, USER, VIA
|
|||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
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("via_parent", [True, False])
|
||||||
@pytest.mark.parametrize(
|
@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})
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
old_path = document.path
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
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
|
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 cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||||
assert ws_resp.call_count == 1
|
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})
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||||
|
|
||||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
old_path = document.path
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
@@ -453,6 +478,9 @@ def test_api_documents_update_user_connected_to_websocket(settings):
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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 cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
assert ws_resp.call_count == 1
|
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)
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
old_path = document.path
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
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
|
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 cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||||
assert ws_resp.call_count == 1
|
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)
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
old_path = document.path
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
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
|
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 cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
assert ws_resp.call_count == 0
|
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)
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
old_path = document.path
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
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
|
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 cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
assert ws_resp.call_count == 0
|
assert ws_resp.call_count == 0
|
||||||
|
|
||||||
@@ -716,3 +753,724 @@ def test_api_documents_update_invalid_content():
|
|||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json() == {"content": ["Invalid base64 content."]}
|
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]
|
[project]
|
||||||
name = "impress"
|
name = "impress"
|
||||||
version = "4.8.2"
|
version = "4.8.3"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createDoc,
|
createDoc,
|
||||||
getMenuItem,
|
|
||||||
mockedDocument,
|
mockedDocument,
|
||||||
overrideConfig,
|
overrideConfig,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
@@ -47,9 +46,9 @@ test.describe('Doc AI feature', () => {
|
|||||||
|
|
||||||
await page.locator('.bn-block-outer').last().fill('Anything');
|
await page.locator('.bn-block-outer').last().fill('Anything');
|
||||||
await page.getByText('Anything').selectText();
|
await page.getByText('Anything').selectText();
|
||||||
expect(
|
await expect(
|
||||||
await page.locator('button[data-test="convertMarkdown"]').count(),
|
page.locator('button[data-test="convertMarkdown"]'),
|
||||||
).toBe(1);
|
).toHaveCount(1);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('button', { name: config.selector, exact: true }),
|
page.getByRole('button', { name: config.selector, exact: true }),
|
||||||
).toBeHidden();
|
).toBeHidden();
|
||||||
@@ -179,18 +178,32 @@ test.describe('Doc AI feature', () => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'AI', exact: true }).click();
|
await page.getByRole('button', { name: 'AI', exact: true }).click();
|
||||||
|
|
||||||
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
|
await expect(
|
||||||
await expect(getMenuItem(page, 'Rephrase')).toBeVisible();
|
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||||
await expect(getMenuItem(page, 'Summarize')).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(getMenuItem(page, 'Correct')).toBeVisible();
|
await expect(
|
||||||
await expect(getMenuItem(page, 'Language')).toBeVisible();
|
page.getByRole('menuitem', { name: 'Rephrase' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Summarize' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Language' }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await getMenuItem(page, 'Language').hover();
|
await page.getByRole('menuitem', { name: 'Language' }).hover();
|
||||||
await expect(getMenuItem(page, 'English', { exact: true })).toBeVisible();
|
await expect(
|
||||||
await expect(getMenuItem(page, 'French', { exact: true })).toBeVisible();
|
page.getByRole('menuitem', { name: 'English', exact: true }),
|
||||||
await expect(getMenuItem(page, 'German', { exact: true })).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'French', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'German', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await getMenuItem(page, 'German', { exact: true }).click();
|
await page.getByRole('menuitem', { name: 'German', exact: true }).click();
|
||||||
|
|
||||||
await expect(editor.getByText('Hallo Welt')).toBeVisible();
|
await expect(editor.getByText('Hallo Welt')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -256,15 +269,23 @@ test.describe('Doc AI feature', () => {
|
|||||||
await page.getByRole('button', { name: 'AI', exact: true }).click();
|
await page.getByRole('button', { name: 'AI', exact: true }).click();
|
||||||
|
|
||||||
if (ai_transform) {
|
if (ai_transform) {
|
||||||
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||||
|
).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(getMenuItem(page, 'Use as prompt')).toBeHidden();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||||
|
).toBeHidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ai_translate) {
|
if (ai_translate) {
|
||||||
await expect(getMenuItem(page, 'Language')).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Language' }),
|
||||||
|
).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(getMenuItem(page, 'Language')).toBeHidden();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Language' }),
|
||||||
|
).toBeHidden();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
import {
|
import {
|
||||||
closeHeaderMenu,
|
closeHeaderMenu,
|
||||||
createDoc,
|
createDoc,
|
||||||
getMenuItem,
|
|
||||||
getOtherBrowserName,
|
getOtherBrowserName,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
} from './utils-common';
|
} from './utils-common';
|
||||||
@@ -131,12 +130,13 @@ test.describe('Doc Comments', () => {
|
|||||||
await thread.getByRole('paragraph').first().fill('This is a comment');
|
await thread.getByRole('paragraph').first().fill('This is a comment');
|
||||||
await thread.locator('[data-test="save"]').click();
|
await thread.locator('[data-test="save"]').click();
|
||||||
await expect(thread.getByText('This is a comment').first()).toBeHidden();
|
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(
|
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||||
'background-color',
|
'background-color',
|
||||||
'rgba(237, 180, 0, 0.4)',
|
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||||
);
|
);
|
||||||
|
|
||||||
await editor.first().click();
|
await editor.first().click();
|
||||||
await editor.getByText('Hello').click();
|
await editor.getByText('Hello').click();
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ test.describe('Doc Comments', () => {
|
|||||||
// Edit Comment
|
// Edit Comment
|
||||||
await thread.getByText('This is a comment').first().hover();
|
await thread.getByText('This is a comment').first().hover();
|
||||||
await thread.locator('[data-test="moreactions"]').first().click();
|
await thread.locator('[data-test="moreactions"]').first().click();
|
||||||
await getMenuItem(thread, 'Edit comment').click();
|
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
|
||||||
const commentEditor = thread.getByText('This is a comment').first();
|
const commentEditor = thread.getByText('This is a comment').first();
|
||||||
await commentEditor.fill('This is an edited comment');
|
await commentEditor.fill('This is an edited comment');
|
||||||
const saveBtn = thread.locator('button[data-test="save"]').first();
|
const saveBtn = thread.locator('button[data-test="save"]').first();
|
||||||
@@ -176,7 +176,7 @@ test.describe('Doc Comments', () => {
|
|||||||
// Delete second comment
|
// Delete second comment
|
||||||
await thread.getByText('This is a second comment').first().hover();
|
await thread.getByText('This is a second comment').first().hover();
|
||||||
await thread.locator('[data-test="moreactions"]').first().click();
|
await thread.locator('[data-test="moreactions"]').first().click();
|
||||||
await getMenuItem(thread, 'Delete comment').click();
|
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
|
||||||
await expect(
|
await expect(
|
||||||
thread.getByText('This is a second comment').first(),
|
thread.getByText('This is a second comment').first(),
|
||||||
).toBeHidden();
|
).toBeHidden();
|
||||||
@@ -185,6 +185,7 @@ test.describe('Doc Comments', () => {
|
|||||||
await thread.getByText('This is an edited comment').first().hover();
|
await thread.getByText('This is an edited comment').first().hover();
|
||||||
await thread.locator('[data-test="resolve"]').click();
|
await thread.locator('[data-test="resolve"]').click();
|
||||||
await expect(thread).toBeHidden();
|
await expect(thread).toBeHidden();
|
||||||
|
|
||||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||||
'background-color',
|
'background-color',
|
||||||
'rgba(0, 0, 0, 0)',
|
'rgba(0, 0, 0, 0)',
|
||||||
@@ -196,18 +197,21 @@ test.describe('Doc Comments', () => {
|
|||||||
|
|
||||||
await thread.getByRole('paragraph').first().fill('This is a new comment');
|
await thread.getByRole('paragraph').first().fill('This is a new comment');
|
||||||
await thread.locator('[data-test="save"]').click();
|
await thread.locator('[data-test="save"]').click();
|
||||||
|
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
|
||||||
|
|
||||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||||
'background-color',
|
'background-color',
|
||||||
'rgba(237, 180, 0, 0.4)',
|
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||||
);
|
);
|
||||||
|
|
||||||
await editor.first().click();
|
await editor.first().click();
|
||||||
await editor.getByText('Hello').click();
|
await editor.getByText('Hello').click();
|
||||||
|
|
||||||
await thread.getByText('This is a new comment').first().hover();
|
await thread.getByText('This is a new comment').first().hover();
|
||||||
await thread.locator('[data-test="moreactions"]').first().click();
|
await thread.locator('[data-test="moreactions"]').first().click();
|
||||||
await getMenuItem(thread, 'Delete comment').click();
|
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
|
||||||
|
|
||||||
|
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
|
||||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||||
'background-color',
|
'background-color',
|
||||||
'rgba(0, 0, 0, 0)',
|
'rgba(0, 0, 0, 0)',
|
||||||
@@ -263,7 +267,7 @@ test.describe('Doc Comments', () => {
|
|||||||
|
|
||||||
await expect(otherEditor.getByText('Hello')).toHaveCSS(
|
await expect(otherEditor.getByText('Hello')).toHaveCSS(
|
||||||
'background-color',
|
'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
|
// We change the role of the second user to reader
|
||||||
@@ -298,7 +302,7 @@ test.describe('Doc Comments', () => {
|
|||||||
|
|
||||||
await expect(otherEditor.getByText('Hello')).toHaveCSS(
|
await expect(otherEditor.getByText('Hello')).toHaveCSS(
|
||||||
'background-color',
|
'background-color',
|
||||||
'rgba(237, 180, 0, 0.4)',
|
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||||
);
|
);
|
||||||
await otherEditor.getByText('Hello').click();
|
await otherEditor.getByText('Hello').click();
|
||||||
await expect(
|
await expect(
|
||||||
@@ -344,7 +348,7 @@ test.describe('Doc Comments', () => {
|
|||||||
|
|
||||||
await expect(editor1.getByText('Document One')).toHaveCSS(
|
await expect(editor1.getByText('Document One')).toHaveCSS(
|
||||||
'background-color',
|
'background-color',
|
||||||
'rgba(237, 180, 0, 0.4)',
|
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
|
||||||
);
|
);
|
||||||
|
|
||||||
await editor1.getByText('Document One').click();
|
await editor1.getByText('Document One').click();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import cs from 'convert-stream';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createDoc,
|
createDoc,
|
||||||
getMenuItem,
|
|
||||||
goToGridDoc,
|
goToGridDoc,
|
||||||
overrideConfig,
|
overrideConfig,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
@@ -148,20 +147,18 @@ test.describe('Doc Editor', () => {
|
|||||||
const wsClosePromise = webSocket.waitForEvent('close');
|
const wsClosePromise = webSocket.waitForEvent('close');
|
||||||
|
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
await getMenuItem(page, 'Connected').click();
|
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
|
||||||
|
|
||||||
// Assert that the doc reconnects to the ws
|
// Assert that the doc reconnects to the ws
|
||||||
const wsClose = await wsClosePromise;
|
const wsClose = await wsClosePromise;
|
||||||
expect(wsClose.isClosed()).toBeTruthy();
|
expect(wsClose.isClosed()).toBeTruthy();
|
||||||
|
|
||||||
// Check the ws is connected again
|
// Check the ws is connected again
|
||||||
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
webSocket = await page.waitForEvent('websocket', (webSocket) => {
|
||||||
return webSocket
|
return webSocket
|
||||||
.url()
|
.url()
|
||||||
.includes('ws://localhost:4444/collaboration/ws/?room=');
|
.includes('ws://localhost:4444/collaboration/ws/?room=');
|
||||||
});
|
});
|
||||||
|
|
||||||
webSocket = await webSocketPromise;
|
|
||||||
framesentPromise = webSocket.waitForEvent('framesent');
|
framesentPromise = webSocket.waitForEvent('framesent');
|
||||||
framesent = await framesentPromise;
|
framesent = await framesentPromise;
|
||||||
expect(framesent.payload).not.toBeNull();
|
expect(framesent.payload).not.toBeNull();
|
||||||
@@ -578,12 +575,10 @@ test.describe('Doc Editor', () => {
|
|||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
responseCanEditPromise = page.waitForResponse(
|
responseCanEdit = await page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes(`/can-edit/`) && response.status() === 200,
|
response.url().includes(`/can-edit/`) && response.status() === 200,
|
||||||
);
|
);
|
||||||
|
|
||||||
responseCanEdit = await responseCanEditPromise;
|
|
||||||
expect(responseCanEdit.ok()).toBeTruthy();
|
expect(responseCanEdit.ok()).toBeTruthy();
|
||||||
|
|
||||||
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
|
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
|
||||||
@@ -609,7 +604,7 @@ test.describe('Doc Editor', () => {
|
|||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
|
|
||||||
await page.getByTestId('doc-access-mode').click();
|
await page.getByTestId('doc-access-mode').click();
|
||||||
await getMenuItem(page, 'Reading').click();
|
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
|
||||||
|
|
||||||
// Close the modal
|
// Close the modal
|
||||||
await page.getByRole('button', { name: 'close' }).first().click();
|
await page.getByRole('button', { name: 'close' }).first().click();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
import {
|
import {
|
||||||
createDoc,
|
createDoc,
|
||||||
getGridRow,
|
getGridRow,
|
||||||
getMenuItem,
|
|
||||||
getOtherBrowserName,
|
getOtherBrowserName,
|
||||||
mockedListDocs,
|
mockedListDocs,
|
||||||
toggleHeaderMenu,
|
toggleHeaderMenu,
|
||||||
@@ -207,7 +206,7 @@ test.describe('Doc grid move', () => {
|
|||||||
const row = await getGridRow(page, titleDoc1);
|
const row = await getGridRow(page, titleDoc1);
|
||||||
await row.getByText(`more_horiz`).click();
|
await row.getByText(`more_horiz`).click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Move into a doc').click();
|
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
||||||
@@ -295,7 +294,7 @@ test.describe('Doc grid move', () => {
|
|||||||
const row = await getGridRow(page, titleDoc1);
|
const row = await getGridRow(page, titleDoc1);
|
||||||
await row.getByText(`more_horiz`).click();
|
await row.getByText(`more_horiz`).click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Move into a doc').click();
|
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
||||||
@@ -342,7 +341,9 @@ test.describe('Doc grid move', () => {
|
|||||||
`doc-share-access-request-row-${emailRequest}`,
|
`doc-share-access-request-row-${emailRequest}`,
|
||||||
);
|
);
|
||||||
await container.getByTestId('doc-role-dropdown').click();
|
await container.getByTestId('doc-role-dropdown').click();
|
||||||
await getMenuItem(otherPage, 'Administrator').click();
|
await otherPage
|
||||||
|
.getByRole('menuitemradio', { name: 'Administrator' })
|
||||||
|
.click();
|
||||||
await container.getByRole('button', { name: 'Approve' }).click();
|
await container.getByRole('button', { name: 'Approve' }).click();
|
||||||
|
|
||||||
await expect(otherPage.getByText('Access Requests')).toBeHidden();
|
await expect(otherPage.getByText('Access Requests')).toBeHidden();
|
||||||
@@ -353,7 +354,7 @@ test.describe('Doc grid move', () => {
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await row.getByText(`more_horiz`).click();
|
await row.getByText(`more_horiz`).click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Move into a doc').click();
|
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import {
|
import { createDoc, getGridRow, verifyDocName } from './utils-common';
|
||||||
createDoc,
|
|
||||||
getGridRow,
|
|
||||||
getMenuItem,
|
|
||||||
verifyDocName,
|
|
||||||
} from './utils-common';
|
|
||||||
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
||||||
|
|
||||||
type SmallDoc = {
|
type SmallDoc = {
|
||||||
@@ -104,7 +99,7 @@ test.describe('Document grid item options', () => {
|
|||||||
const row = await getGridRow(page, docTitle);
|
const row = await getGridRow(page, docTitle);
|
||||||
await row.getByText(`more_horiz`).click();
|
await row.getByText(`more_horiz`).click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Share').click();
|
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('dialog').getByText('Share the document'),
|
page.getByRole('dialog').getByText('Share the document'),
|
||||||
@@ -120,7 +115,7 @@ test.describe('Document grid item options', () => {
|
|||||||
|
|
||||||
// Pin
|
// Pin
|
||||||
await row.getByText(`more_horiz`).click();
|
await row.getByText(`more_horiz`).click();
|
||||||
await getMenuItem(page, 'Pin').click();
|
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||||
|
|
||||||
// Check is pinned
|
// Check is pinned
|
||||||
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
|
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
|
||||||
@@ -147,7 +142,7 @@ test.describe('Document grid item options', () => {
|
|||||||
const row = await getGridRow(page, docTitle);
|
const row = await getGridRow(page, docTitle);
|
||||||
await row.getByText(`more_horiz`).click();
|
await row.getByText(`more_horiz`).click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Delete').click();
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
import {
|
import {
|
||||||
createDoc,
|
createDoc,
|
||||||
getGridRow,
|
getGridRow,
|
||||||
getMenuItem,
|
|
||||||
goToGridDoc,
|
goToGridDoc,
|
||||||
mockedDocument,
|
mockedDocument,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
@@ -79,7 +78,7 @@ test.describe('Doc Header', () => {
|
|||||||
|
|
||||||
await page.getByTestId('doc-visibility').click();
|
await page.getByTestId('doc-visibility').click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Public').click();
|
await page.getByRole('menuitemradio', { name: 'Public' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'close' }).first().click();
|
await page.getByRole('button', { name: 'close' }).first().click();
|
||||||
|
|
||||||
@@ -153,8 +152,10 @@ test.describe('Doc Header', () => {
|
|||||||
|
|
||||||
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
|
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
|
||||||
const optionMenu = page.getByLabel('Open the document options');
|
const optionMenu = page.getByLabel('Open the document options');
|
||||||
const addEmojiMenuItem = getMenuItem(page, 'Add emoji');
|
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
|
||||||
const removeEmojiMenuItem = getMenuItem(page, 'Remove emoji');
|
const removeEmojiMenuItem = page.getByRole('menuitem', {
|
||||||
|
name: 'Remove emoji',
|
||||||
|
});
|
||||||
|
|
||||||
// Top parent should not have emoji picker
|
// Top parent should not have emoji picker
|
||||||
await expect(emojiPicker).toBeHidden();
|
await expect(emojiPicker).toBeHidden();
|
||||||
@@ -208,7 +209,7 @@ test.describe('Doc Header', () => {
|
|||||||
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
|
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
|
||||||
|
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await getMenuItem(page, 'Delete document').click();
|
await page.getByRole('menuitem', { name: 'Delete document' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||||
@@ -236,7 +237,7 @@ test.describe('Doc Header', () => {
|
|||||||
hasText: randomDoc,
|
hasText: randomDoc,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await row.count()).toBe(0);
|
await expect(row).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it checks the options available if administrator', async ({ page }) => {
|
test('it checks the options available if administrator', async ({ page }) => {
|
||||||
@@ -270,10 +271,12 @@ test.describe('Doc Header', () => {
|
|||||||
|
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
|
|
||||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
// Click somewhere else to close the options
|
// Click somewhere else to close the options
|
||||||
await page.click('body', { position: { x: 0, y: 0 } });
|
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
|
|
||||||
@@ -293,7 +296,7 @@ test.describe('Doc Header', () => {
|
|||||||
|
|
||||||
await invitationRole.click();
|
await invitationRole.click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Remove access').click();
|
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
|
||||||
await expect(invitationCard).toBeHidden();
|
await expect(invitationCard).toBeHidden();
|
||||||
|
|
||||||
const memberCard = shareModal.getByLabel('List members card');
|
const memberCard = shareModal.getByLabel('List members card');
|
||||||
@@ -305,7 +308,9 @@ test.describe('Doc Header', () => {
|
|||||||
await expect(roles).toBeVisible();
|
await expect(roles).toBeVisible();
|
||||||
|
|
||||||
await roles.click();
|
await roles.click();
|
||||||
await expect(getMenuItem(page, 'Remove access')).toBeEnabled();
|
await expect(
|
||||||
|
page.getByRole('menuitemradio', { name: 'Remove access' }),
|
||||||
|
).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it checks the options available if editor', async ({ page }) => {
|
test('it checks the options available if editor', async ({ page }) => {
|
||||||
@@ -345,10 +350,12 @@ test.describe('Doc Header', () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
|
|
||||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
// Click somewhere else to close the options
|
// Click somewhere else to close the options
|
||||||
await page.click('body', { position: { x: 0, y: 0 } });
|
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
|
|
||||||
@@ -415,10 +422,12 @@ test.describe('Doc Header', () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
|
|
||||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
// Click somewhere else to close the options
|
// Click somewhere else to close the options
|
||||||
await page.click('body', { position: { x: 0, y: 0 } });
|
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
|
|
||||||
@@ -473,8 +482,10 @@ test.describe('Doc Header', () => {
|
|||||||
|
|
||||||
// Copy content to clipboard
|
// Copy content to clipboard
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await getMenuItem(page, 'Copy as Markdown').click();
|
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
|
||||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
await expect(
|
||||||
|
page.getByText('Copied as Markdown to clipboard'),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Test that clipboard is in Markdown format
|
// Test that clipboard is in Markdown format
|
||||||
const handle = await page.evaluateHandle(() =>
|
const handle = await page.evaluateHandle(() =>
|
||||||
@@ -535,7 +546,7 @@ test.describe('Doc Header', () => {
|
|||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Pin
|
// Pin
|
||||||
await getMenuItem(page, 'Pin').click();
|
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'Open the document options' })
|
.getByRole('button', { name: 'Open the document options' })
|
||||||
.click();
|
.click();
|
||||||
@@ -556,11 +567,11 @@ test.describe('Doc Header', () => {
|
|||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Unpin
|
// Unpin
|
||||||
await getMenuItem(page, 'Unpin').click();
|
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'Open the document options' })
|
.getByRole('button', { name: 'Open the document options' })
|
||||||
.click();
|
.click();
|
||||||
await expect(getMenuItem(page, 'Pin')).toBeVisible();
|
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
@@ -578,7 +589,7 @@ test.describe('Doc Header', () => {
|
|||||||
|
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Duplicate').click();
|
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Document duplicated successfully!'),
|
page.getByText('Document duplicated successfully!'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -593,7 +604,7 @@ test.describe('Doc Header', () => {
|
|||||||
await expect(row.getByText(duplicateTitle)).toBeVisible();
|
await expect(row.getByText(duplicateTitle)).toBeVisible();
|
||||||
|
|
||||||
await row.getByText(`more_horiz`).click();
|
await row.getByText(`more_horiz`).click();
|
||||||
await getMenuItem(page, 'Duplicate').click();
|
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||||
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
|
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
|
||||||
await page.getByText(duplicateDuplicateTitle).click();
|
await page.getByText(duplicateDuplicateTitle).click();
|
||||||
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
|
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
|
||||||
@@ -626,7 +637,7 @@ test.describe('Doc Header', () => {
|
|||||||
|
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
|
|
||||||
await getMenuItem(page, 'Duplicate').click();
|
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||||
|
|
||||||
await expect(page).not.toHaveURL(new RegExp(currentUrl));
|
await expect(page).not.toHaveURL(new RegExp(currentUrl));
|
||||||
|
|
||||||
@@ -665,8 +676,10 @@ test.describe('Documents Header mobile', () => {
|
|||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
|
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await expect(getMenuItem(page, 'Copy link')).toBeVisible();
|
await expect(
|
||||||
await getMenuItem(page, 'Share').click();
|
page.getByRole('menuitem', { name: 'Copy link' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -689,7 +702,7 @@ test.describe('Documents Header mobile', () => {
|
|||||||
await goToGridDoc(page);
|
await goToGridDoc(page);
|
||||||
|
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await getMenuItem(page, 'Share').click();
|
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||||
|
|
||||||
const shareModal = page.getByRole('dialog', {
|
const shareModal = page.getByRole('dialog', {
|
||||||
name: 'Share the document',
|
name: 'Share the document',
|
||||||
|
|||||||
@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
|
|||||||
return dt;
|
return dt;
|
||||||
}, filesData);
|
}, filesData);
|
||||||
|
|
||||||
await page.dispatchEvent(selector, 'drop', { dataTransfer });
|
await page.locator(selector).dispatchEvent('drop', { dataTransfer });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
|
import { createDoc, verifyDocName } from './utils-common';
|
||||||
import { updateShareLink } from './utils-share';
|
import { updateShareLink } from './utils-share';
|
||||||
import { createRootSubPage } from './utils-sub-pages';
|
import { createRootSubPage } from './utils-sub-pages';
|
||||||
|
|
||||||
@@ -53,17 +53,19 @@ test.describe('Inherited share accesses', () => {
|
|||||||
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
|
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
|
||||||
|
|
||||||
await docVisibilityCard.getByText('Reading').click();
|
await docVisibilityCard.getByText('Reading').click();
|
||||||
await getMenuItem(page, 'Editing').click();
|
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
|
||||||
|
|
||||||
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
|
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
|
||||||
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
|
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
|
||||||
|
|
||||||
// Verify inherited link
|
// Verify inherited link
|
||||||
await docVisibilityCard.getByText('Connected').click();
|
await docVisibilityCard.getByText('Connected').click();
|
||||||
await expect(getMenuItem(page, 'Private')).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole('menuitemradio', { name: 'Private' }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
// Update child link
|
// Update child link
|
||||||
await getMenuItem(page, 'Public').click();
|
await page.getByRole('menuitemradio', { name: 'Public' }).click();
|
||||||
|
|
||||||
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
|
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
import {
|
import {
|
||||||
BROWSERS,
|
BROWSERS,
|
||||||
createDoc,
|
createDoc,
|
||||||
getMenuItem,
|
|
||||||
keyCloakSignIn,
|
keyCloakSignIn,
|
||||||
randomName,
|
randomName,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
@@ -17,6 +16,41 @@ test.describe('Document create member', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it checks search hints', async ({ page, browserName }) => {
|
||||||
|
await createDoc(page, 'select-multi-users', browserName, 1);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
|
|
||||||
|
const shareModal = page.getByLabel('Share the document');
|
||||||
|
await expect(shareModal.getByText('Document owner')).toBeVisible();
|
||||||
|
|
||||||
|
const inputSearch = page.getByTestId('quick-search-input');
|
||||||
|
await inputSearch.fill('u');
|
||||||
|
await expect(shareModal.getByText('Document owner')).toBeHidden();
|
||||||
|
await expect(
|
||||||
|
shareModal.getByText('Type at least 3 characters to display user names'),
|
||||||
|
).toBeVisible();
|
||||||
|
await inputSearch.fill('user');
|
||||||
|
await expect(
|
||||||
|
shareModal.getByText('Type at least 3 characters to display user names'),
|
||||||
|
).toBeHidden();
|
||||||
|
await expect(shareModal.getByText('Choose a user')).toBeVisible();
|
||||||
|
await inputSearch.fill('anything');
|
||||||
|
await expect(shareModal.getByText('Choose a user')).toBeHidden();
|
||||||
|
await expect(
|
||||||
|
shareModal.getByText(
|
||||||
|
'No results. Type a full email address to invite someone.',
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
await inputSearch.fill('anything@test.com');
|
||||||
|
await expect(
|
||||||
|
shareModal.getByText(
|
||||||
|
'No results. Type a full email address to invite someone.',
|
||||||
|
),
|
||||||
|
).toBeHidden();
|
||||||
|
await expect(shareModal.getByText('Choose the email')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
|
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
|
||||||
const inputFill = 'user.test';
|
const inputFill = 'user.test';
|
||||||
const responsePromise = page.waitForResponse(
|
const responsePromise = page.waitForResponse(
|
||||||
@@ -76,13 +110,21 @@ test.describe('Document create member', () => {
|
|||||||
|
|
||||||
// Check roles are displayed
|
// Check roles are displayed
|
||||||
await list.getByTestId('doc-role-dropdown').click();
|
await list.getByTestId('doc-role-dropdown').click();
|
||||||
await expect(getMenuItem(page, 'Reader')).toBeVisible();
|
await expect(
|
||||||
await expect(getMenuItem(page, 'Editor')).toBeVisible();
|
page.getByRole('menuitemradio', { name: 'Reader' }),
|
||||||
await expect(getMenuItem(page, 'Owner')).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(getMenuItem(page, 'Administrator')).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole('menuitemradio', { name: 'Editor' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('menuitemradio', { name: 'Owner' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('menuitemradio', { name: 'Administrator' }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
await getMenuItem(page, 'Administrator').click();
|
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
|
||||||
await page.getByTestId('doc-share-invite-button').click();
|
await page.getByTestId('doc-share-invite-button').click();
|
||||||
|
|
||||||
// Check invitation added
|
// Check invitation added
|
||||||
@@ -128,7 +170,7 @@ test.describe('Document create member', () => {
|
|||||||
// Choose a role
|
// Choose a role
|
||||||
const container = page.getByTestId('doc-share-add-member-list');
|
const container = page.getByTestId('doc-share-add-member-list');
|
||||||
await container.getByTestId('doc-role-dropdown').click();
|
await container.getByTestId('doc-role-dropdown').click();
|
||||||
await getMenuItem(page, 'Owner').click();
|
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
|
||||||
|
|
||||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
@@ -146,7 +188,7 @@ test.describe('Document create member', () => {
|
|||||||
|
|
||||||
// Choose a role
|
// Choose a role
|
||||||
await container.getByTestId('doc-role-dropdown').click();
|
await container.getByTestId('doc-role-dropdown').click();
|
||||||
await getMenuItem(page, 'Owner').click();
|
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
|
||||||
|
|
||||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
@@ -183,7 +225,7 @@ test.describe('Document create member', () => {
|
|||||||
// Choose a role
|
// Choose a role
|
||||||
const container = page.getByTestId('doc-share-add-member-list');
|
const container = page.getByTestId('doc-share-add-member-list');
|
||||||
await container.getByTestId('doc-role-dropdown').click();
|
await container.getByTestId('doc-role-dropdown').click();
|
||||||
await getMenuItem(page, 'Administrator').click();
|
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
|
||||||
|
|
||||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
@@ -210,13 +252,13 @@ test.describe('Document create member', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await userInvitation.getByTestId('doc-role-dropdown').click();
|
await userInvitation.getByTestId('doc-role-dropdown').click();
|
||||||
await getMenuItem(page, 'Reader').click();
|
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
|
||||||
|
|
||||||
const responsePatchInvitation = await responsePromisePatchInvitation;
|
const responsePatchInvitation = await responsePromisePatchInvitation;
|
||||||
expect(responsePatchInvitation.ok()).toBeTruthy();
|
expect(responsePatchInvitation.ok()).toBeTruthy();
|
||||||
|
|
||||||
await userInvitation.getByTestId('doc-role-dropdown').click();
|
await userInvitation.getByTestId('doc-role-dropdown').click();
|
||||||
await getMenuItem(page, 'Remove access').click();
|
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
|
||||||
|
|
||||||
await expect(userInvitation).toBeHidden();
|
await expect(userInvitation).toBeHidden();
|
||||||
});
|
});
|
||||||
@@ -268,7 +310,7 @@ test.describe('Document create member', () => {
|
|||||||
`doc-share-access-request-row-${emailRequest}`,
|
`doc-share-access-request-row-${emailRequest}`,
|
||||||
);
|
);
|
||||||
await container.getByTestId('doc-role-dropdown').click();
|
await container.getByTestId('doc-role-dropdown').click();
|
||||||
await getMenuItem(page, 'Administrator').click();
|
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
|
||||||
await container.getByRole('button', { name: 'Approve' }).click();
|
await container.getByRole('button', { name: 'Approve' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Access Requests')).toBeHidden();
|
await expect(page.getByText('Access Requests')).toBeHidden();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
|
import { createDoc, verifyDocName } from './utils-common';
|
||||||
import { addNewMember } from './utils-share';
|
import { addNewMember } from './utils-share';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -160,7 +160,9 @@ test.describe('Document list members', () => {
|
|||||||
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
|
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
|
||||||
);
|
);
|
||||||
await expect(soloOwner).toBeVisible();
|
await expect(soloOwner).toBeVisible();
|
||||||
await expect(getMenuItem(page, 'Administrator')).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole('menuitemradio', { name: 'Administrator' }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
await list.click({
|
await list.click({
|
||||||
force: true, // Force click to close the dropdown
|
force: true, // Force click to close the dropdown
|
||||||
@@ -183,18 +185,20 @@ test.describe('Document list members', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await currentUserRole.click();
|
await currentUserRole.click();
|
||||||
await getMenuItem(page, 'Administrator').click();
|
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
|
||||||
await list.click();
|
await list.click();
|
||||||
await expect(currentUserRole).toBeVisible();
|
await expect(currentUserRole).toBeVisible();
|
||||||
|
|
||||||
await newUserRoles.click();
|
await newUserRoles.click();
|
||||||
await expect(getMenuItem(page, 'Owner')).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole('menuitemradio', { name: 'Owner' }),
|
||||||
|
).toBeDisabled();
|
||||||
await list.click({
|
await list.click({
|
||||||
force: true, // Force click to close the dropdown
|
force: true, // Force click to close the dropdown
|
||||||
});
|
});
|
||||||
|
|
||||||
await currentUserRole.click();
|
await currentUserRole.click();
|
||||||
await getMenuItem(page, 'Reader').click();
|
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
|
||||||
await list.click({
|
await list.click({
|
||||||
force: true, // Force click to close the dropdown
|
force: true, // Force click to close the dropdown
|
||||||
});
|
});
|
||||||
@@ -234,11 +238,11 @@ test.describe('Document list members', () => {
|
|||||||
await expect(userReader).toBeVisible();
|
await expect(userReader).toBeVisible();
|
||||||
|
|
||||||
await userReaderRole.click();
|
await userReaderRole.click();
|
||||||
await getMenuItem(page, 'Remove access').click();
|
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
|
||||||
await expect(userReader).toBeHidden();
|
await expect(userReader).toBeHidden();
|
||||||
|
|
||||||
await mySelfRole.click();
|
await mySelfRole.click();
|
||||||
await getMenuItem(page, 'Remove access').click();
|
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Insufficient access rights to view the document.'),
|
page.getByText('Insufficient access rights to view the document.'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
|
import { createDoc, verifyDocName } from './utils-common';
|
||||||
import { createRootSubPage } from './utils-sub-pages';
|
import { createRootSubPage } from './utils-sub-pages';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -29,7 +29,7 @@ test.describe('Document search', () => {
|
|||||||
await page.getByTestId('search-docs-button').click();
|
await page.getByTestId('search-docs-button').click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('img', { name: 'No active search' }),
|
page.getByLabel('Search modal').locator('img[alt=""]'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -107,7 +107,7 @@ test.describe('Document search', () => {
|
|||||||
|
|
||||||
await searchButton.click();
|
await searchButton.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('combobox', { name: 'Quick search input' }),
|
page.getByRole('combobox', { name: 'Search documents' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(filters).toBeHidden();
|
await expect(filters).toBeHidden();
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ test.describe('Document search', () => {
|
|||||||
|
|
||||||
await searchButton.click();
|
await searchButton.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('combobox', { name: 'Quick search input' }),
|
page.getByRole('combobox', { name: 'Search documents' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(filters).toBeHidden();
|
await expect(filters).toBeHidden();
|
||||||
|
|
||||||
@@ -136,9 +136,13 @@ test.describe('Document search', () => {
|
|||||||
|
|
||||||
await filters.click();
|
await filters.click();
|
||||||
await filters.getByRole('button', { name: 'Current doc' }).click();
|
await filters.getByRole('button', { name: 'Current doc' }).click();
|
||||||
await expect(getMenuItem(page, 'All docs')).toBeVisible();
|
await expect(
|
||||||
await expect(getMenuItem(page, 'Current doc')).toBeVisible();
|
page.getByRole('menuitemcheckbox', { name: 'All docs' }),
|
||||||
await getMenuItem(page, 'All docs').click();
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('menuitemcheckbox', { name: 'Current doc' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole('menuitemcheckbox', { name: 'All docs' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -164,9 +168,9 @@ test.describe('Document search', () => {
|
|||||||
const searchButton = page.getByTestId('search-docs-button');
|
const searchButton = page.getByTestId('search-docs-button');
|
||||||
|
|
||||||
await searchButton.click();
|
await searchButton.click();
|
||||||
await page.getByRole('combobox', { name: 'Quick search input' }).click();
|
await page.getByRole('combobox', { name: 'Search documents' }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole('combobox', { name: 'Quick search input' })
|
.getByRole('combobox', { name: 'Search documents' })
|
||||||
.fill('sub page search');
|
.fill('sub page search');
|
||||||
|
|
||||||
// Expect to find the first and second docs in the results list
|
// Expect to find the first and second docs in the results list
|
||||||
@@ -188,7 +192,7 @@ test.describe('Document search', () => {
|
|||||||
);
|
);
|
||||||
await searchButton.click();
|
await searchButton.click();
|
||||||
await page
|
await page
|
||||||
.getByRole('combobox', { name: 'Quick search input' })
|
.getByRole('combobox', { name: 'Search documents' })
|
||||||
.fill('second');
|
.fill('second');
|
||||||
|
|
||||||
// Now there is a sub page - expect to have the focus on the current doc
|
// Now there is a sub page - expect to have the focus on the current doc
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ test.describe('Doc Table Content', () => {
|
|||||||
|
|
||||||
await page.locator('.ProseMirror').click();
|
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');
|
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3');
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
import {
|
import {
|
||||||
createDoc,
|
createDoc,
|
||||||
expectLoginPage,
|
expectLoginPage,
|
||||||
getMenuItem,
|
|
||||||
keyCloakSignIn,
|
keyCloakSignIn,
|
||||||
updateDocTitle,
|
updateDocTitle,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
@@ -43,15 +42,12 @@ test.describe('Doc Tree', () => {
|
|||||||
await expect(secondSubPageItem).toBeVisible();
|
await expect(secondSubPageItem).toBeVisible();
|
||||||
|
|
||||||
// Check the position of the sub pages
|
// Check the position of the sub pages
|
||||||
const allSubPageItems = await docTree
|
const allSubPageItems = docTree.getByTestId(/^doc-sub-page-item/);
|
||||||
.getByTestId(/^doc-sub-page-item/)
|
await expect(allSubPageItems).toHaveCount(2);
|
||||||
.all();
|
|
||||||
|
|
||||||
expect(allSubPageItems.length).toBe(2);
|
|
||||||
|
|
||||||
// Check that elements are in the correct order
|
// Check that elements are in the correct order
|
||||||
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
|
await expect(allSubPageItems.nth(0).getByText('first move')).toBeVisible();
|
||||||
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
|
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
|
||||||
|
|
||||||
// Will move the first sub page to the second position
|
// Will move the first sub page to the second position
|
||||||
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
|
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
|
||||||
@@ -91,17 +87,15 @@ test.describe('Doc Tree', () => {
|
|||||||
await expect(secondSubPageItem).toBeVisible();
|
await expect(secondSubPageItem).toBeVisible();
|
||||||
|
|
||||||
// Check that elements are in the correct order
|
// Check that elements are in the correct order
|
||||||
const allSubPageItemsAfterReload = await docTree
|
const allSubPageItemsAfterReload =
|
||||||
.getByTestId(/^doc-sub-page-item/)
|
docTree.getByTestId(/^doc-sub-page-item/);
|
||||||
.all();
|
await expect(allSubPageItemsAfterReload).toHaveCount(2);
|
||||||
|
|
||||||
expect(allSubPageItemsAfterReload.length).toBe(2);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
allSubPageItemsAfterReload[0].getByText('second move'),
|
allSubPageItemsAfterReload.nth(0).getByText('second move'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
allSubPageItemsAfterReload[1].getByText('first move'),
|
allSubPageItemsAfterReload.nth(1).getByText('first move'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,7 +157,7 @@ test.describe('Doc Tree', () => {
|
|||||||
);
|
);
|
||||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||||
await currentUserRole.click();
|
await currentUserRole.click();
|
||||||
await getMenuItem(page, 'Administrator').click();
|
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
|
||||||
await list.click();
|
await list.click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Ok' }).click();
|
await page.getByRole('button', { name: 'Ok' }).click();
|
||||||
@@ -193,10 +187,9 @@ test.describe('Doc Tree', () => {
|
|||||||
const menu = child.getByText(`more_horiz`);
|
const menu = child.getByText(`more_horiz`);
|
||||||
await menu.click();
|
await menu.click();
|
||||||
|
|
||||||
await expect(getMenuItem(page, 'Move to my docs')).toHaveAttribute(
|
await expect(
|
||||||
'aria-disabled',
|
page.getByRole('menuitem', { name: 'Move to my docs' }),
|
||||||
'true',
|
).toHaveAttribute('aria-disabled', 'true');
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keyboard navigation with Enter key opens documents', async ({
|
test('keyboard navigation with Enter key opens documents', async ({
|
||||||
@@ -299,7 +292,7 @@ test.describe('Doc Tree', () => {
|
|||||||
|
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
await expect(page.getByLabel('Open onboarding menu')).toBeFocused();
|
await expect(page.getByLabel('Open help menu')).toBeFocused();
|
||||||
|
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
@@ -309,7 +302,7 @@ test.describe('Doc Tree', () => {
|
|||||||
|
|
||||||
await page.keyboard.press('Shift+Tab');
|
await page.keyboard.press('Shift+Tab');
|
||||||
|
|
||||||
await expect(page.getByLabel('Open onboarding menu')).toBeFocused();
|
await expect(page.getByLabel('Open help menu')).toBeFocused();
|
||||||
|
|
||||||
await page.keyboard.press('Shift+Tab');
|
await page.keyboard.press('Shift+Tab');
|
||||||
|
|
||||||
@@ -340,7 +333,9 @@ test.describe('Doc Tree', () => {
|
|||||||
await row.hover();
|
await row.hover();
|
||||||
const menu = row.getByText(`more_horiz`);
|
const menu = row.getByText(`more_horiz`);
|
||||||
await menu.click();
|
await menu.click();
|
||||||
await expect(getMenuItem(page, 'Remove emoji')).toBeHidden();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Remove emoji' }),
|
||||||
|
).toBeHidden();
|
||||||
|
|
||||||
// Close the menu
|
// Close the menu
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
@@ -360,7 +355,7 @@ test.describe('Doc Tree', () => {
|
|||||||
// Now remove the emoji using the new action
|
// Now remove the emoji using the new action
|
||||||
await row.hover();
|
await row.hover();
|
||||||
await menu.click();
|
await menu.click();
|
||||||
await getMenuItem(page, 'Remove emoji').click();
|
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
|
||||||
|
|
||||||
await expect(row.getByText('😀')).toBeHidden();
|
await expect(row.getByText('😀')).toBeHidden();
|
||||||
await expect(titleEmojiPicker).toBeHidden();
|
await expect(titleEmojiPicker).toBeHidden();
|
||||||
@@ -390,7 +385,7 @@ test.describe('Doc Tree: Inheritance', () => {
|
|||||||
const selectVisibility = page.getByTestId('doc-visibility');
|
const selectVisibility = page.getByTestId('doc-visibility');
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Public').click();
|
await page.getByRole('menuitemradio', { name: 'Public' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.'),
|
page.getByText('The document visibility has been updated.'),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createDoc,
|
createDoc,
|
||||||
getMenuItem,
|
|
||||||
goToGridDoc,
|
goToGridDoc,
|
||||||
mockedDocument,
|
mockedDocument,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
@@ -21,11 +20,11 @@ test.describe('Doc Version', () => {
|
|||||||
|
|
||||||
// Initially, there is no version
|
// Initially, there is no version
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await getMenuItem(page, 'Version history').click();
|
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
const modal = page.getByRole('dialog', { name: 'Version history' });
|
const modal = page.getByRole('dialog', { name: 'Version history' });
|
||||||
const panel = modal.getByLabel('version list');
|
const panel = modal.getByLabel('Version list');
|
||||||
await expect(panel).toBeVisible();
|
await expect(panel).toBeVisible();
|
||||||
await expect(modal.getByText('No versions')).toBeVisible();
|
await expect(modal.getByText('No versions')).toBeVisible();
|
||||||
|
|
||||||
@@ -75,14 +74,14 @@ test.describe('Doc Version', () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await getMenuItem(page, 'Version history').click();
|
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||||
|
|
||||||
await expect(panel).toBeVisible();
|
await expect(panel).toBeVisible();
|
||||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole('status')).toBeHidden();
|
await expect(page.getByRole('status')).toBeHidden();
|
||||||
const items = await panel.locator('.version-item').all();
|
const items = panel.locator('.version-item');
|
||||||
expect(items.length).toBe(2);
|
await expect(items).toHaveCount(2);
|
||||||
await items[1].click();
|
await items.nth(1).click();
|
||||||
|
|
||||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||||
@@ -90,7 +89,7 @@ test.describe('Doc Version', () => {
|
|||||||
modal.locator('div[data-content-type="callout"]').first(),
|
modal.locator('div[data-content-type="callout"]').first(),
|
||||||
).toBeHidden();
|
).toBeHidden();
|
||||||
|
|
||||||
await items[0].click();
|
await items.nth(0).click();
|
||||||
|
|
||||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||||
await expect(modal.getByText('It will create a version')).toBeVisible();
|
await expect(modal.getByText('It will create a version')).toBeVisible();
|
||||||
@@ -101,7 +100,7 @@ test.describe('Doc Version', () => {
|
|||||||
modal.getByText('It will create a second version'),
|
modal.getByText('It will create a second version'),
|
||||||
).toBeHidden();
|
).toBeHidden();
|
||||||
|
|
||||||
await items[1].click();
|
await items.nth(1).click();
|
||||||
|
|
||||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||||
@@ -125,7 +124,9 @@ test.describe('Doc Version', () => {
|
|||||||
await verifyDocName(page, 'Mocked document');
|
await verifyDocName(page, 'Mocked document');
|
||||||
|
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await expect(getMenuItem(page, 'Version history')).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole('menuitem', { name: 'Version history' }),
|
||||||
|
).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it restores the doc version', async ({ page, browserName }) => {
|
test('it restores the doc version', async ({ page, browserName }) => {
|
||||||
@@ -152,23 +153,28 @@ test.describe('Doc Version', () => {
|
|||||||
await expect(page.getByText('World')).toBeVisible();
|
await expect(page.getByText('World')).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await getMenuItem(page, 'Version history').click();
|
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||||
|
|
||||||
const modal = page.getByRole('dialog', { name: 'Version history' });
|
const modal = page.getByRole('dialog', { name: 'Version history' });
|
||||||
const panel = modal.getByLabel('version list');
|
const panel = modal.getByLabel('Version list');
|
||||||
await expect(panel).toBeVisible();
|
await expect(panel).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||||
await panel.getByRole('button', { name: 'version item' }).click();
|
await panel.locator('.version-item').first().click();
|
||||||
|
|
||||||
await expect(modal.getByText('World')).toBeHidden();
|
await expect(modal.getByText('World')).toBeHidden();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Restore' }).click();
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
await expect(page.getByText('Your current document will')).toBeVisible();
|
await expect(
|
||||||
await page.getByText('If a member is editing, his').click();
|
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.getByLabel('Restore', { exact: true }).click();
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
await expect(page.getByText('Hello')).toBeVisible();
|
await expect(page.getByText('Hello')).toBeVisible();
|
||||||
await expect(page.getByText('World')).toBeHidden();
|
await expect(page.getByText('World')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
BROWSERS,
|
BROWSERS,
|
||||||
createDoc,
|
createDoc,
|
||||||
expectLoginPage,
|
expectLoginPage,
|
||||||
getMenuItem,
|
|
||||||
keyCloakSignIn,
|
keyCloakSignIn,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
} from './utils-common';
|
} from './utils-common';
|
||||||
@@ -47,17 +46,21 @@ test.describe('Doc Visibility', () => {
|
|||||||
|
|
||||||
await expect(selectVisibility.getByText('Private')).toBeVisible();
|
await expect(selectVisibility.getByText('Private')).toBeVisible();
|
||||||
|
|
||||||
await expect(getMenuItem(page, 'Read only')).toBeHidden();
|
await expect(
|
||||||
await expect(getMenuItem(page, 'Can read and edit')).toBeHidden();
|
page.getByRole('menuitemradio', { name: 'Read only' }),
|
||||||
|
).toBeHidden();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('menuitemradio', { name: 'Can read and edit' }),
|
||||||
|
).toBeHidden();
|
||||||
|
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
await getMenuItem(page, 'Connected').click();
|
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
|
||||||
|
|
||||||
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
||||||
|
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Public').click();
|
await page.getByRole('menuitemradio', { name: 'Public' }).click();
|
||||||
|
|
||||||
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -202,7 +205,7 @@ test.describe('Doc Visibility: Public', () => {
|
|||||||
const selectVisibility = page.getByTestId('doc-visibility');
|
const selectVisibility = page.getByTestId('doc-visibility');
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Public').click();
|
await page.getByRole('menuitemradio', { name: 'Public' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.'),
|
page.getByText('The document visibility has been updated.'),
|
||||||
@@ -210,7 +213,7 @@ test.describe('Doc Visibility: Public', () => {
|
|||||||
|
|
||||||
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
||||||
await page.getByTestId('doc-access-mode').click();
|
await page.getByTestId('doc-access-mode').click();
|
||||||
await getMenuItem(page, 'Reading').click();
|
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.').first(),
|
page.getByText('The document visibility has been updated.').first(),
|
||||||
@@ -296,14 +299,14 @@ test.describe('Doc Visibility: Public', () => {
|
|||||||
const selectVisibility = page.getByTestId('doc-visibility');
|
const selectVisibility = page.getByTestId('doc-visibility');
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Public').click();
|
await page.getByRole('menuitemradio', { name: 'Public' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.'),
|
page.getByText('The document visibility has been updated.'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId('doc-access-mode').click();
|
await page.getByTestId('doc-access-mode').click();
|
||||||
await getMenuItem(page, 'Editing').click();
|
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.').first(),
|
page.getByText('The document visibility has been updated.').first(),
|
||||||
@@ -387,7 +390,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
|||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
const selectVisibility = page.getByTestId('doc-visibility');
|
const selectVisibility = page.getByTestId('doc-visibility');
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
await getMenuItem(page, 'Connected').click();
|
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.'),
|
page.getByText('The document visibility has been updated.'),
|
||||||
@@ -435,7 +438,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
|||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
const selectVisibility = page.getByTestId('doc-visibility');
|
const selectVisibility = page.getByTestId('doc-visibility');
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
await getMenuItem(page, 'Connected').click();
|
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.'),
|
page.getByText('The document visibility has been updated.'),
|
||||||
@@ -533,7 +536,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
|||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
const selectVisibility = page.getByTestId('doc-visibility');
|
const selectVisibility = page.getByTestId('doc-visibility');
|
||||||
await selectVisibility.click();
|
await selectVisibility.click();
|
||||||
await getMenuItem(page, 'Connected').click();
|
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.'),
|
page.getByText('The document visibility has been updated.'),
|
||||||
@@ -541,7 +544,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
|||||||
|
|
||||||
const urlDoc = page.url();
|
const urlDoc = page.url();
|
||||||
await page.getByTestId('doc-access-mode').click();
|
await page.getByTestId('doc-access-mode').click();
|
||||||
await getMenuItem(page, 'Editing').click();
|
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('The document visibility has been updated.').first(),
|
page.getByText('The document visibility has been updated.').first(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { getMenuItem, overrideConfig } from './utils-common';
|
import { overrideConfig } from './utils-common';
|
||||||
|
|
||||||
test.describe('Footer', () => {
|
test.describe('Footer', () => {
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
@@ -47,7 +47,7 @@ test.describe('Footer', () => {
|
|||||||
// Check the translation
|
// Check the translation
|
||||||
const header = page.locator('header').first();
|
const header = page.locator('header').first();
|
||||||
await header.getByRole('button').getByText('English').click();
|
await header.getByRole('button').getByText('English').click();
|
||||||
await getMenuItem(page, 'Français').click();
|
await page.getByRole('menuitemradio', { name: 'Français' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('footer').getByText('Mentions légales'),
|
page.locator('footer').getByText('Mentions légales'),
|
||||||
@@ -131,7 +131,7 @@ test.describe('Footer', () => {
|
|||||||
// Check the translation
|
// Check the translation
|
||||||
const header = page.locator('header').first();
|
const header = page.locator('header').first();
|
||||||
await header.getByRole('button').getByText('English').click();
|
await header.getByRole('button').getByText('English').click();
|
||||||
await getMenuItem(page, 'Français').click();
|
await page.getByRole('menuitemradio', { name: 'Français' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
|
|||||||
@@ -191,25 +191,27 @@ test.describe('Header: Override configuration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Header: Skip to Content', () => {
|
test.describe('Header: Skip to Content', () => {
|
||||||
test('it displays skip link on first TAB and focuses main content on click', async ({
|
test('it displays skip link on first TAB and focuses page heading on click', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// Wait for skip button to be mounted (client-side only component)
|
// Wait for skip link to be mounted (client-side only component)
|
||||||
const skipButton = page.getByRole('button', { name: 'Go to content' });
|
const skipLink = page.getByRole('link', { name: 'Go to content' });
|
||||||
await skipButton.waitFor({ state: 'attached' });
|
await skipLink.waitFor({ state: 'attached' });
|
||||||
|
|
||||||
// First TAB shows the skip button
|
// First TAB shows the skip link
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
// The skip button should be visible and focused
|
// The skip link should be visible and focused
|
||||||
await expect(skipButton).toBeFocused();
|
await expect(skipLink).toBeFocused();
|
||||||
await expect(skipButton).toBeVisible();
|
await expect(skipLink).toBeVisible();
|
||||||
|
// Clicking moves focus to the page heading
|
||||||
// Clicking moves focus to the main content
|
await skipLink.click();
|
||||||
await skipButton.click();
|
const pageHeading = page.getByRole('heading', {
|
||||||
const mainContent = page.locator('main#mainContent');
|
name: 'All docs',
|
||||||
await expect(mainContent).toBeFocused();
|
level: 2,
|
||||||
|
});
|
||||||
|
await expect(pageHeading).toBeFocused();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
TestLanguage,
|
TestLanguage,
|
||||||
getMenuItem,
|
|
||||||
overrideConfig,
|
overrideConfig,
|
||||||
waitForLanguageSwitch,
|
waitForLanguageSwitch,
|
||||||
} from './utils-common';
|
} from './utils-common';
|
||||||
@@ -27,7 +26,7 @@ test.describe('Help feature', () => {
|
|||||||
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('button', { name: 'Open onboarding menu' }),
|
page.getByRole('button', { name: 'Open help menu' }),
|
||||||
).toBeHidden();
|
).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,9 +42,9 @@ test.describe('Help feature', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
|
await page.getByRole('button', { name: 'Open help menu' }).click();
|
||||||
|
|
||||||
await getMenuItem(page, 'Onboarding').click();
|
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
|
||||||
|
|
||||||
const modal = page.getByTestId('onboarding-modal');
|
const modal = page.getByTestId('onboarding-modal');
|
||||||
await expect(modal).toBeVisible();
|
await expect(modal).toBeVisible();
|
||||||
@@ -87,8 +86,8 @@ test.describe('Help feature', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('closes modal with Skip button', async ({ page }) => {
|
test('closes modal with Skip button', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
|
await page.getByRole('button', { name: 'Open help menu' }).click();
|
||||||
await getMenuItem(page, 'Onboarding').click();
|
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
|
||||||
|
|
||||||
const modal = page.getByTestId('onboarding-modal');
|
const modal = page.getByTestId('onboarding-modal');
|
||||||
await expect(modal).toBeVisible();
|
await expect(modal).toBeVisible();
|
||||||
@@ -107,11 +106,9 @@ test.describe('Help feature', () => {
|
|||||||
// switch to french
|
// switch to french
|
||||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||||
|
|
||||||
await page
|
await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click();
|
||||||
.getByRole('button', { name: "Ouvrir le menu d'embarquement" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await getMenuItem(page, 'Premiers pas').click();
|
await page.getByRole('menuitem', { name: 'Premiers pas' }).click();
|
||||||
|
|
||||||
const modal = page.getByLabel('Apprenez les principes fondamentaux');
|
const modal = page.getByLabel('Apprenez les principes fondamentaux');
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ test.describe('Language', () => {
|
|||||||
|
|
||||||
await expect(page.locator('[role="menu"]')).toBeVisible();
|
await expect(page.locator('[role="menu"]')).toBeVisible();
|
||||||
|
|
||||||
const menuItems = page.locator('[role="menuitem"], [role="menuitemradio"]');
|
const menuItems = page.locator('[role="menuitemradio"]');
|
||||||
await expect(menuItems.first()).toBeVisible();
|
await expect(menuItems.first()).toBeVisible();
|
||||||
|
|
||||||
await menuItems.first().click();
|
await menuItems.first().click();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
|
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
|
||||||
|
import { createRootSubPage } from './utils-sub-pages';
|
||||||
|
|
||||||
test.describe('Left panel desktop', () => {
|
test.describe('Left panel desktop', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -18,7 +19,7 @@ test.describe('Left panel desktop', () => {
|
|||||||
await expect(page.getByTestId('home-button')).toBeVisible();
|
await expect(page.getByTestId('home-button')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('focuses main content after switching the docs filter', async ({
|
test('focuses page heading after switching the docs filter', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -28,8 +29,11 @@ test.describe('Left panel desktop', () => {
|
|||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await expect(page).toHaveURL(/target=my_docs/);
|
await expect(page).toHaveURL(/target=my_docs/);
|
||||||
|
|
||||||
const mainContent = page.locator('main#mainContent');
|
const pageHeading = page.getByRole('heading', {
|
||||||
await expect(mainContent).toBeFocused();
|
name: 'My docs',
|
||||||
|
level: 2,
|
||||||
|
});
|
||||||
|
await expect(pageHeading).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('checks resize handle is present and functional on document page', async ({
|
test('checks resize handle is present and functional on document page', async ({
|
||||||
@@ -118,6 +122,47 @@ test.describe('Left panel mobile', () => {
|
|||||||
await expect(logoutButton).toBeInViewport();
|
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 }) => {
|
test('checks resize handle is not present on mobile', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,6 @@ import path from 'path';
|
|||||||
|
|
||||||
import { Locator, Page, TestInfo, expect } from '@playwright/test';
|
import { Locator, Page, TestInfo, expect } from '@playwright/test';
|
||||||
|
|
||||||
/** Returns a locator for a menu item (handles both menuitem and menuitemradio roles) */
|
|
||||||
export const getMenuItem = (
|
|
||||||
context: Page | Locator,
|
|
||||||
name: string,
|
|
||||||
options?: { exact?: boolean },
|
|
||||||
): Locator =>
|
|
||||||
context
|
|
||||||
.getByRole('menuitem', { name, exact: options?.exact })
|
|
||||||
.or(context.getByRole('menuitemradio', { name, exact: options?.exact }));
|
|
||||||
|
|
||||||
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
|
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
|
||||||
|
|
||||||
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||||
@@ -392,12 +382,12 @@ export async function waitForLanguageSwitch(
|
|||||||
|
|
||||||
await languagePicker.click();
|
await languagePicker.click();
|
||||||
|
|
||||||
await getMenuItem(page, lang.label).click();
|
await page.getByRole('menuitemradio', { name: lang.label }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clickInEditorMenu = async (page: Page, textButton: string) => {
|
export const clickInEditorMenu = async (page: Page, textButton: string) => {
|
||||||
await page.getByRole('button', { name: 'Open the document options' }).click();
|
await page.getByRole('button', { name: 'Open the document options' }).click();
|
||||||
await getMenuItem(page, textButton).click();
|
await page.getByRole('menuitem', { name: textButton }).click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clickInGridMenu = async (
|
export const clickInGridMenu = async (
|
||||||
@@ -408,7 +398,7 @@ export const clickInGridMenu = async (
|
|||||||
await row
|
await row
|
||||||
.getByRole('button', { name: /Open the menu of actions for the document/ })
|
.getByRole('button', { name: /Open the menu of actions for the document/ })
|
||||||
.click();
|
.click();
|
||||||
await getMenuItem(page, textButton).click();
|
await page.getByRole('menuitem', { name: textButton }).click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const writeReport = async (
|
export const writeReport = async (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Page, chromium, expect } from '@playwright/test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BrowserName,
|
BrowserName,
|
||||||
getMenuItem,
|
|
||||||
getOtherBrowserName,
|
getOtherBrowserName,
|
||||||
keyCloakSignIn,
|
keyCloakSignIn,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
@@ -40,7 +39,7 @@ export const addNewMember = async (
|
|||||||
|
|
||||||
// Choose a role
|
// Choose a role
|
||||||
await page.getByTestId('doc-role-dropdown').click();
|
await page.getByTestId('doc-role-dropdown').click();
|
||||||
await getMenuItem(page, role).click();
|
await page.getByRole('menuitemradio', { name: role }).click();
|
||||||
await page.getByTestId('doc-share-invite-button').click();
|
await page.getByTestId('doc-share-invite-button').click();
|
||||||
|
|
||||||
return users[index].email;
|
return users[index].email;
|
||||||
@@ -52,7 +51,7 @@ export const updateShareLink = async (
|
|||||||
linkRole?: LinkRole | null,
|
linkRole?: LinkRole | null,
|
||||||
) => {
|
) => {
|
||||||
await page.getByTestId('doc-visibility').click();
|
await page.getByTestId('doc-visibility').click();
|
||||||
await getMenuItem(page, linkReach).click();
|
await page.getByRole('menuitemradio', { name: linkReach }).click();
|
||||||
|
|
||||||
const visibilityUpdatedText = page
|
const visibilityUpdatedText = page
|
||||||
.getByText('The document visibility has been updated')
|
.getByText('The document visibility has been updated')
|
||||||
@@ -62,7 +61,7 @@ export const updateShareLink = async (
|
|||||||
|
|
||||||
if (linkRole) {
|
if (linkRole) {
|
||||||
await page.getByTestId('doc-access-mode').click();
|
await page.getByTestId('doc-access-mode').click();
|
||||||
await getMenuItem(page, linkRole).click();
|
await page.getByRole('menuitemradio', { name: linkRole }).click();
|
||||||
await expect(visibilityUpdatedText).toBeVisible();
|
await expect(visibilityUpdatedText).toBeVisible();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,7 +76,7 @@ export const updateRoleUser = async (
|
|||||||
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
|
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
|
||||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||||
await currentUserRole.click();
|
await currentUserRole.click();
|
||||||
await getMenuItem(page, role).click();
|
await page.getByRole('menuitemradio', { name: role }).click();
|
||||||
await list.click();
|
await list.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-e2e",
|
"name": "app-e2e",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"repository": "https://github.com/suitenumerique/docs",
|
"repository": "https://github.com/suitenumerique/docs",
|
||||||
"author": "DINUM",
|
"author": "DINUM",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-impress",
|
"name": "app-impress",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"repository": "https://github.com/suitenumerique/docs",
|
"repository": "https://github.com/suitenumerique/docs",
|
||||||
"author": "DINUM",
|
"author": "DINUM",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ag-media/react-pdf-table": "2.0.3",
|
"@ag-media/react-pdf-table": "2.0.3",
|
||||||
"@ai-sdk/openai": "3.0.19",
|
"@ai-sdk/openai": "3.0.45",
|
||||||
"@blocknote/code-block": "0.47.1",
|
"@blocknote/code-block": "0.47.1",
|
||||||
"@blocknote/core": "0.47.1",
|
"@blocknote/core": "0.47.1",
|
||||||
"@blocknote/mantine": "0.47.1",
|
"@blocknote/mantine": "0.47.1",
|
||||||
@@ -38,20 +38,20 @@
|
|||||||
"@emoji-mart/data": "1.2.1",
|
"@emoji-mart/data": "1.2.1",
|
||||||
"@emoji-mart/react": "1.1.1",
|
"@emoji-mart/react": "1.1.1",
|
||||||
"@fontsource-variable/inter": "5.2.8",
|
"@fontsource-variable/inter": "5.2.8",
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.35",
|
"@fontsource-variable/material-symbols-outlined": "5.2.38",
|
||||||
"@fontsource/material-icons": "5.2.7",
|
"@fontsource/material-icons": "5.2.7",
|
||||||
"@gouvfr-lasuite/cunningham-react": "4.2.0",
|
"@gouvfr-lasuite/cunningham-react": "4.2.0",
|
||||||
"@gouvfr-lasuite/integration": "1.0.3",
|
"@gouvfr-lasuite/integration": "1.0.3",
|
||||||
"@gouvfr-lasuite/ui-kit": "0.19.6",
|
"@gouvfr-lasuite/ui-kit": "0.19.10",
|
||||||
"@hocuspocus/provider": "3.4.4",
|
"@hocuspocus/provider": "3.4.4",
|
||||||
"@mantine/core": "8.3.14",
|
"@mantine/core": "8.3.17",
|
||||||
"@mantine/hooks": "8.3.14",
|
"@mantine/hooks": "8.3.17",
|
||||||
"@react-aria/live-announcer": "3.4.4",
|
"@react-aria/live-announcer": "3.4.4",
|
||||||
"@react-pdf/renderer": "4.3.1",
|
"@react-pdf/renderer": "4.3.1",
|
||||||
"@sentry/nextjs": "10.38.0",
|
"@sentry/nextjs": "10.43.0",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tiptap/extensions": "*",
|
"@tiptap/extensions": "*",
|
||||||
"ai": "6.0.49",
|
"ai": "6.0.128",
|
||||||
"canvg": "4.0.3",
|
"canvg": "4.0.3",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
@@ -59,28 +59,28 @@
|
|||||||
"emoji-datasource-apple": "16.0.0",
|
"emoji-datasource-apple": "16.0.0",
|
||||||
"emoji-mart": "5.6.0",
|
"emoji-mart": "5.6.0",
|
||||||
"emoji-regex": "10.6.0",
|
"emoji-regex": "10.6.0",
|
||||||
"i18next": "25.8.12",
|
"i18next": "25.8.18",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"idb": "8.0.3",
|
"idb": "8.0.3",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.23",
|
||||||
"luxon": "3.7.2",
|
"luxon": "3.7.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.7",
|
||||||
"posthog-js": "1.347.2",
|
"posthog-js": "1.360.2",
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-aria-components": "1.15.1",
|
"react-aria-components": "1.16.0",
|
||||||
"react-dom": "*",
|
"react-dom": "*",
|
||||||
"react-dropzone": "15.0.0",
|
"react-dropzone": "15.0.0",
|
||||||
"react-i18next": "16.5.4",
|
"react-i18next": "16.5.8",
|
||||||
"react-intersection-observer": "10.0.2",
|
"react-intersection-observer": "10.0.3",
|
||||||
"react-resizable-panels": "3.0.6",
|
"react-resizable-panels": "3.0.6",
|
||||||
"react-select": "5.10.2",
|
"react-select": "5.10.2",
|
||||||
"styled-components": "6.3.9",
|
"styled-components": "6.3.11",
|
||||||
"use-debounce": "10.1.0",
|
"use-debounce": "10.1.0",
|
||||||
"uuid": "13.0.0",
|
"uuid": "13.0.0",
|
||||||
"y-protocols": "1.0.7",
|
"y-protocols": "1.0.7",
|
||||||
"yjs": "*",
|
"yjs": "*",
|
||||||
"zod": "3.25.28",
|
"zod": "4.3.6",
|
||||||
"zustand": "5.0.11"
|
"zustand": "5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@svgr/webpack": "8.1.0",
|
"@svgr/webpack": "8.1.0",
|
||||||
@@ -89,26 +89,25 @@
|
|||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@testing-library/react": "16.3.2",
|
"@testing-library/react": "16.3.2",
|
||||||
"@testing-library/user-event": "14.6.1",
|
"@testing-library/user-event": "14.6.1",
|
||||||
"@types/lodash": "4.17.23",
|
"@types/lodash": "4.17.24",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
"@types/react-dom": "*",
|
"@types/react-dom": "*",
|
||||||
"@vitejs/plugin-react": "5.1.4",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"dotenv": "17.3.1",
|
"dotenv": "17.3.1",
|
||||||
"eslint-plugin-docs": "*",
|
"eslint-plugin-docs": "*",
|
||||||
"fetch-mock": "9.11.0",
|
"fetch-mock": "9.11.0",
|
||||||
"jsdom": "28.1.0",
|
"jsdom": "29.0.0",
|
||||||
"node-fetch": "2.7.0",
|
"node-fetch": "2.7.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"stylelint": "16.26.1",
|
"stylelint": "16.26.1",
|
||||||
"stylelint-config-standard": "39.0.1",
|
"stylelint-config-standard": "39.0.1",
|
||||||
"stylelint-prettier": "5.0.3",
|
"stylelint-prettier": "5.0.3",
|
||||||
"typescript": "*",
|
"typescript": "*",
|
||||||
"vite-tsconfig-paths": "6.1.1",
|
"vitest": "4.1.0",
|
||||||
"vitest": "4.0.18",
|
"webpack": "5.105.4",
|
||||||
"webpack": "5.105.2",
|
|
||||||
"workbox-webpack-plugin": "7.1.0"
|
"workbox-webpack-plugin": "7.1.0"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@1.22.22"
|
"packageManager": "yarn@1.22.22"
|
||||||
|
|||||||
@@ -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 |
@@ -1,17 +1,17 @@
|
|||||||
import { forwardRef } from 'react';
|
import { Ref, forwardRef } from 'react';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
import { Box, BoxType } from './Box';
|
import { Box, BoxType } from './Box';
|
||||||
|
|
||||||
export type BoxButtonType = BoxType & {
|
export type BoxButtonType = Omit<BoxType, 'ref'> & {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
ref?: Ref<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Styleless button that extends the Box component.
|
* Styleless button that extends the Box component.
|
||||||
* Good to wrap around SVGs or other elements that need to be clickable.
|
* Good to wrap around SVGs or other elements that need to be clickable.
|
||||||
|
* Uses aria-disabled instead of native disabled to preserve keyboard focusability.
|
||||||
* @param props - @see BoxType props
|
* @param props - @see BoxType props
|
||||||
* @param ref
|
* @param ref
|
||||||
* @see Box
|
* @see Box
|
||||||
@@ -22,8 +22,8 @@ export type BoxButtonType = BoxType & {
|
|||||||
* </BoxButton>
|
* </BoxButton>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
|
||||||
({ $css, ...props }, ref) => {
|
({ $css, disabled, ...props }, ref) => {
|
||||||
const theme = props.$theme || 'gray';
|
const theme = props.$theme || 'gray';
|
||||||
const variation = props.$variation || 'primary';
|
const variation = props.$variation || 'primary';
|
||||||
|
|
||||||
@@ -31,16 +31,18 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
|||||||
<Box
|
<Box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
as="button"
|
as="button"
|
||||||
|
type="button"
|
||||||
$background="none"
|
$background="none"
|
||||||
$margin="none"
|
$margin="none"
|
||||||
$padding="none"
|
$padding="none"
|
||||||
$hasTransition
|
$hasTransition
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
$css={css`
|
$css={css`
|
||||||
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
|
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: ${props.disabled &&
|
color: ${disabled &&
|
||||||
`var(--c--contextuals--content--semantic--disabled--primary)`};
|
`var(--c--contextuals--content--semantic--disabled--primary)`};
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
transition: none;
|
transition: none;
|
||||||
@@ -53,11 +55,11 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
|||||||
`}
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
className={`--docs--box-button ${props.className || ''}`}
|
className={`--docs--box-button ${props.className || ''}`}
|
||||||
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
|
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (props.disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
props.onClick?.(event);
|
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,63 +1,33 @@
|
|||||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||||
import { useRouter } from 'next/router';
|
import { useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||||
|
import { focusMainContentStart } from '@/layouts/utils';
|
||||||
|
|
||||||
export const SkipToContent = () => {
|
export const SkipToContent = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
|
||||||
const { spacingsTokens } = useCunninghamTheme();
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
// Reset focus after route change so first TAB goes to skip link
|
|
||||||
useEffect(() => {
|
|
||||||
const handleRouteChange = () => {
|
|
||||||
(document.activeElement as HTMLElement)?.blur();
|
|
||||||
|
|
||||||
document.body.setAttribute('tabindex', '-1');
|
|
||||||
document.body.focus({ preventScroll: true });
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeAttribute('tabindex');
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
router.events.on('routeChangeComplete', handleRouteChange);
|
|
||||||
return () => {
|
|
||||||
router.events.off('routeChangeComplete', handleRouteChange);
|
|
||||||
};
|
|
||||||
}, [router.events]);
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
const focusTarget = focusMainContentStart();
|
||||||
if (mainContent) {
|
|
||||||
mainContent.focus();
|
if (focusTarget instanceof HTMLElement) {
|
||||||
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
focusTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box>
|
||||||
$css={css`
|
|
||||||
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px var(--c--globals--colors--white-000),
|
|
||||||
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
|
|
||||||
border-radius: var(--c--globals--spacings--st);
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClick}
|
href={`#${MAIN_LAYOUT_ID}`}
|
||||||
type="button"
|
|
||||||
color="brand"
|
color="brand"
|
||||||
className="--docs--skip-to-content"
|
className="--docs--skip-to-content"
|
||||||
|
onClick={handleClick}
|
||||||
onFocus={() => setIsVisible(true)}
|
onFocus={() => setIsVisible(true)}
|
||||||
onBlur={() => setIsVisible(false)}
|
onBlur={() => setIsVisible(false)}
|
||||||
style={{
|
style={{
|
||||||
@@ -65,7 +35,6 @@ export const SkipToContent = () => {
|
|||||||
pointerEvents: isVisible ? 'auto' : 'none',
|
pointerEvents: isVisible ? 'auto' : 'none',
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: spacingsTokens['2xs'],
|
top: spacingsTokens['2xs'],
|
||||||
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
|
|
||||||
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
|
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
|
|||||||
export type DropdownMenuOption = {
|
export type DropdownMenuOption = {
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
lang?: string;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
callback?: () => void | Promise<unknown>;
|
callback?: () => void | Promise<unknown>;
|
||||||
@@ -69,7 +70,10 @@ export const DropdownMenu = ({
|
|||||||
const [isOpen, setIsOpen] = useState(opened ?? false);
|
const [isOpen, setIsOpen] = useState(opened ?? false);
|
||||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
const blockButtonRef = useRef<HTMLDivElement>(null);
|
const blockButtonRef = useRef<HTMLDivElement>(null);
|
||||||
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
const isSingleSelectable = options.some(
|
||||||
|
(option) => option.isSelected !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const onOpenChange = useCallback(
|
const onOpenChange = useCallback(
|
||||||
(isOpen: boolean) => {
|
(isOpen: boolean) => {
|
||||||
@@ -110,10 +114,6 @@ export const DropdownMenu = ({
|
|||||||
[onOpenChange],
|
[onOpenChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSelectable =
|
|
||||||
selectedValues !== undefined ||
|
|
||||||
options.some((option) => option.isSelected !== undefined);
|
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
@@ -176,20 +176,25 @@ export const DropdownMenu = ({
|
|||||||
}
|
}
|
||||||
const isDisabled = option.disabled !== undefined && option.disabled;
|
const isDisabled = option.disabled !== undefined && option.disabled;
|
||||||
const isFocused = index === focusedIndex;
|
const isFocused = index === focusedIndex;
|
||||||
const ariaChecked = hasSelectable
|
const isSelected =
|
||||||
? option.isSelected ||
|
option.isSelected === true ||
|
||||||
selectedValues?.includes(option.value ?? '') ||
|
(selectedValues?.includes(option.value ?? '') ?? false);
|
||||||
false
|
const itemRole =
|
||||||
: undefined;
|
selectedValues !== undefined
|
||||||
|
? 'menuitemcheckbox'
|
||||||
|
: isSingleSelectable
|
||||||
|
? 'menuitemradio'
|
||||||
|
: 'menuitem';
|
||||||
|
const optionKey = option.value ?? option.testId ?? `option-${index}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={option.label}>
|
<Fragment key={optionKey}>
|
||||||
<BoxButton
|
<BoxButton
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
menuItemRefs.current[index] = el;
|
menuItemRefs.current[index] = el;
|
||||||
}}
|
}}
|
||||||
role={hasSelectable ? 'menuitemradio' : 'menuitem'}
|
role={itemRole}
|
||||||
aria-checked={ariaChecked}
|
aria-checked={itemRole === 'menuitem' ? undefined : isSelected}
|
||||||
data-testid={option.testId}
|
data-testid={option.testId}
|
||||||
$direction="row"
|
$direction="row"
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
@@ -200,7 +205,6 @@ export const DropdownMenu = ({
|
|||||||
triggerOption(option);
|
triggerOption(option);
|
||||||
}}
|
}}
|
||||||
onKeyDown={keyboardAction(() => triggerOption(option))}
|
onKeyDown={keyboardAction(() => triggerOption(option))}
|
||||||
key={option.label}
|
|
||||||
$align="center"
|
$align="center"
|
||||||
$justify="space-between"
|
$justify="space-between"
|
||||||
$background="var(--c--contextuals--background--surface--primary)"
|
$background="var(--c--contextuals--background--surface--primary)"
|
||||||
@@ -271,16 +275,16 @@ export const DropdownMenu = ({
|
|||||||
<Box
|
<Box
|
||||||
$theme="neutral"
|
$theme="neutral"
|
||||||
$variation={isDisabled ? 'tertiary' : 'primary'}
|
$variation={isDisabled ? 'tertiary' : 'primary'}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Text $variation={isDisabled ? 'tertiary' : 'primary'}>
|
<Text $variation={isDisabled ? 'tertiary' : 'primary'}>
|
||||||
{option.label}
|
<span lang={option.lang}>{option.label}</span>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{(option.isSelected ||
|
{isSelected && (
|
||||||
selectedValues?.includes(option.value ?? '')) && (
|
|
||||||
<Icon
|
<Icon
|
||||||
iconName="check"
|
iconName="check"
|
||||||
$size="20px"
|
$size="20px"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('<DropdownMenu />', () => {
|
|||||||
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
|
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders menuitemradio role with aria-checked when selectedValues is provided', async () => {
|
test('renders menuitemcheckbox role with aria-checked when selectedValues is provided', async () => {
|
||||||
const optionsWithValues: DropdownMenuOption[] = [
|
const optionsWithValues: DropdownMenuOption[] = [
|
||||||
{ label: 'English', value: 'en', callback: vi.fn() },
|
{ label: 'English', value: 'en', callback: vi.fn() },
|
||||||
{ label: 'Français', value: 'fr', callback: vi.fn() },
|
{ label: 'Français', value: 'fr', callback: vi.fn() },
|
||||||
@@ -77,12 +77,12 @@ describe('<DropdownMenu />', () => {
|
|||||||
{ wrapper: AppWrapper },
|
{ wrapper: AppWrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
const radios = screen.getAllByRole('menuitemradio');
|
const checkboxes = screen.getAllByRole('menuitemcheckbox');
|
||||||
expect(radios).toHaveLength(3);
|
expect(checkboxes).toHaveLength(3);
|
||||||
|
|
||||||
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
|
expect(checkboxes[0]).toHaveAttribute('aria-checked', 'false');
|
||||||
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
|
expect(checkboxes[1]).toHaveAttribute('aria-checked', 'true');
|
||||||
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
|
expect(checkboxes[2]).toHaveAttribute('aria-checked', 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('trigger button has aria-haspopup and aria-expanded', async () => {
|
test('trigger button has aria-haspopup and aria-expanded', async () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type UseDropdownKeyboardNavProps = {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
focusedIndex: number;
|
focusedIndex: number;
|
||||||
options: DropdownMenuOption[];
|
options: DropdownMenuOption[];
|
||||||
menuItemRefs: RefObject<(HTMLDivElement | null)[]>;
|
menuItemRefs: RefObject<(HTMLButtonElement | null)[]>;
|
||||||
setFocusedIndex: (index: number) => void;
|
setFocusedIndex: (index: number) => void;
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type QuickSearchProps = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
groupKey?: string;
|
groupKey?: string;
|
||||||
|
beforeList?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const QuickSearch = ({
|
export const QuickSearch = ({
|
||||||
@@ -41,6 +42,7 @@ export const QuickSearch = ({
|
|||||||
showInput = true,
|
showInput = true,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
beforeList,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<QuickSearchProps>) => {
|
}: PropsWithChildren<QuickSearchProps>) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -76,6 +78,7 @@ export const QuickSearch = ({
|
|||||||
{inputContent}
|
{inputContent}
|
||||||
</QuickSearchInput>
|
</QuickSearchInput>
|
||||||
)}
|
)}
|
||||||
|
{beforeList}
|
||||||
<Command.List id={listId} aria-label={label} role="listbox">
|
<Command.List id={listId} aria-label={label} role="listbox">
|
||||||
<Box>{children}</Box>
|
<Box>{children}</Box>
|
||||||
</Command.List>
|
</Command.List>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Command } from 'cmdk';
|
import { Command } from 'cmdk';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { HorizontalSeparator } from '@/components';
|
import { HorizontalSeparator } from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
|
import { useFocusStore } from '@/stores';
|
||||||
|
|
||||||
import { Box } from '../Box';
|
import { Box } from '../Box';
|
||||||
import { Icon } from '../Icon';
|
import { Icon } from '../Icon';
|
||||||
@@ -14,7 +15,6 @@ type QuickSearchInputProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
withSeparator?: boolean;
|
withSeparator?: boolean;
|
||||||
listId?: string;
|
listId?: string;
|
||||||
isExpanded?: boolean;
|
|
||||||
};
|
};
|
||||||
export const QuickSearchInput = ({
|
export const QuickSearchInput = ({
|
||||||
inputValue,
|
inputValue,
|
||||||
@@ -26,6 +26,12 @@ export const QuickSearchInput = ({
|
|||||||
}: PropsWithChildren<QuickSearchInputProps>) => {
|
}: PropsWithChildren<QuickSearchInputProps>) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spacingsTokens } = useCunninghamTheme();
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const addLastFocus = useFocusStore((state) => state.addLastFocus);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addLastFocus(inputRef.current);
|
||||||
|
}, [addLastFocus]);
|
||||||
|
|
||||||
if (children) {
|
if (children) {
|
||||||
return (
|
return (
|
||||||
@@ -42,11 +48,12 @@ export const QuickSearchInput = ({
|
|||||||
$direction="row"
|
$direction="row"
|
||||||
$align="center"
|
$align="center"
|
||||||
className="quick-search-input"
|
className="quick-search-input"
|
||||||
$gap={spacingsTokens['2xs']}
|
$gap={spacingsTokens['xxs']}
|
||||||
$padding={{ horizontal: 'base', vertical: 'xxs' }}
|
$padding={{ horizontal: 'base', vertical: 'xxs' }}
|
||||||
>
|
>
|
||||||
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
|
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
|
||||||
<Command.Input
|
<Command.Input
|
||||||
|
ref={inputRef}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
aria-label={t('Quick search input')}
|
aria-label={t('Quick search input')}
|
||||||
aria-controls={listId}
|
aria-controls={listId}
|
||||||
@@ -55,6 +62,7 @@ export const QuickSearchInput = ({
|
|||||||
placeholder={placeholder ?? t('Search')}
|
placeholder={placeholder ?? t('Search')}
|
||||||
onValueChange={onFilter}
|
onValueChange={onFilter}
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
|
minLength={6}
|
||||||
data-testid="quick-search-input"
|
data-testid="quick-search-input"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ export const QuickSearchStyle = createGlobalStyle`
|
|||||||
[cmdk-input] {
|
[cmdk-input] {
|
||||||
border: none;
|
border: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
background: white;
|
background: white;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||||
border-radius: var(--c--globals--spacings--0);
|
border-radius: var(--c--globals--spacings--0);
|
||||||
|
font-family: var(--c--globals--font--families--base);
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--c--globals--colors--gray-500);
|
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Box, Loading } from '@/components';
|
import { Box, Loading } from '@/components';
|
||||||
import { DocHeader, FloatingBar } from '@/docs/doc-header/';
|
import { DocHeader } from '@/docs/doc-header/';
|
||||||
import {
|
import {
|
||||||
Doc,
|
Doc,
|
||||||
LinkReach,
|
LinkReach,
|
||||||
@@ -35,7 +35,6 @@ export const DocEditorContainer = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDesktop && <FloatingBar />}
|
|
||||||
<Box
|
<Box
|
||||||
$maxWidth="868px"
|
$maxWidth="868px"
|
||||||
$width="100%"
|
$width="100%"
|
||||||
|
|||||||
@@ -8,12 +8,37 @@ export const cssComments = (
|
|||||||
& .--docs--main-editor .ProseMirror {
|
& .--docs--main-editor .ProseMirror {
|
||||||
// Comments marks in the editor
|
// Comments marks in the editor
|
||||||
.bn-editor {
|
.bn-editor {
|
||||||
.bn-thread-mark:not([data-orphan='true']),
|
// Resets blocknote comments styles
|
||||||
.bn-thread-mark-selected:not([data-orphan='true']) {
|
.bn-thread-mark,
|
||||||
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
|
.bn-thread-mark-selected {
|
||||||
color: var(--c--globals--colors--gray-700);
|
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] {
|
[data-show-selection] {
|
||||||
color: HighlightText;
|
color: HighlightText;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
|
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
|
||||||
import { toBase64 } from '@/utils/string';
|
import { toBase64 } from '@/utils/string';
|
||||||
import { isFirefox } from '@/utils/userAgent';
|
import { isFirefox } from '@/utils/userAgent';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
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 = (
|
export const useShortcuts = (
|
||||||
editor: DocsBlockNoteEditor,
|
editor: DocsBlockNoteEditor,
|
||||||
el: HTMLDivElement | null,
|
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(() => {
|
useEffect(() => {
|
||||||
// Check if editor and its view are mounted
|
// Check if editor and its view are mounted
|
||||||
if (!editor || !el) {
|
if (!editor || !el) {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@/docs/doc-export/components/ModalExport', () => ({
|
||||||
|
ModalExport: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
|
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
|
||||||
|
|
||||||
describe('useModuleExport', () => {
|
describe('useModuleExport', () => {
|
||||||
@@ -16,12 +21,12 @@ describe('useModuleExport', () => {
|
|||||||
const Export = await import('@/features/docs/doc-export/');
|
const Export = await import('@/features/docs/doc-export/');
|
||||||
|
|
||||||
expect(Export.default).toBeUndefined();
|
expect(Export.default).toBeUndefined();
|
||||||
}, 15000);
|
});
|
||||||
|
|
||||||
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
|
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
|
||||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
||||||
const Export = await import('@/features/docs/doc-export/');
|
const Export = await import('@/features/docs/doc-export/');
|
||||||
|
|
||||||
expect(Export.default).toHaveProperty('ModalExport');
|
expect(Export.default).toHaveProperty('ModalExport');
|
||||||
}, 15000);
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
const { untitledDocument } = useTrans();
|
const { untitledDocument } = useTrans();
|
||||||
const mediaUrl = useMediaUrl();
|
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() {
|
async function onSubmit() {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
toast(t('The export failed'), VariantType.ERROR);
|
toast(t('The export failed'), VariantType.ERROR);
|
||||||
@@ -211,9 +228,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-testid="doc-export-download-button"
|
data-testid="doc-export-download-button"
|
||||||
aria-label={
|
aria-label={downloadButtonAriaLabel}
|
||||||
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
|
|
||||||
}
|
|
||||||
variant="primary"
|
variant="primary"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => void onSubmit()}
|
onClick={() => void onSubmit()}
|
||||||
@@ -260,13 +275,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
|||||||
clearable={false}
|
clearable={false}
|
||||||
fullWidth
|
fullWidth
|
||||||
label={t('Format')}
|
label={t('Format')}
|
||||||
options={[
|
options={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 },
|
|
||||||
]}
|
|
||||||
value={format}
|
value={format}
|
||||||
onChange={(options) =>
|
onChange={(options) =>
|
||||||
setFormat(options.target.value as DocDownloadFormat)
|
setFormat(options.target.value as DocDownloadFormat)
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import React from 'react';
|
|
||||||
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
|
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
|
||||||
|
|
||||||
import { AppWrapper } from '@/tests/utils';
|
import { AppWrapper } from '@/tests/utils';
|
||||||
@@ -40,17 +38,11 @@ describe('DocToolBox - Licence', () => {
|
|||||||
render(<DocToolBox doc={doc as any} />, {
|
render(<DocToolBox doc={doc as any} />, {
|
||||||
wrapper: AppWrapper,
|
wrapper: AppWrapper,
|
||||||
});
|
});
|
||||||
const optionsButton = await screen.findByLabelText('Export the document');
|
|
||||||
await userEvent.click(optionsButton);
|
|
||||||
|
|
||||||
// Wait for the export modal to be visible, then assert on its content text.
|
|
||||||
await screen.findByTestId('modal-export-title');
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
await screen.findByLabelText('Export the document'),
|
||||||
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
}, 10000);
|
}, 15000);
|
||||||
|
|
||||||
test('The export button is not rendered when MIT version is activated', async () => {
|
test('The export button is not rendered when MIT version is activated', async () => {
|
||||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
|
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
|
||||||
@@ -68,5 +60,5 @@ describe('DocToolBox - Licence', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.queryByLabelText('Export the document'),
|
screen.queryByLabelText('Export the document'),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
}, 15000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
|
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
@@ -17,6 +16,7 @@ import GroupSVG from '@/assets/icons/ui-kit/group.svg';
|
|||||||
import HistorySVG from '@/assets/icons/ui-kit/history.svg';
|
import HistorySVG from '@/assets/icons/ui-kit/history.svg';
|
||||||
import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
|
import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
|
||||||
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
|
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
|
||||||
|
import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
useDocUtils,
|
useDocUtils,
|
||||||
useDuplicateDoc,
|
useDuplicateDoc,
|
||||||
} from '@/docs/doc-management';
|
} from '@/docs/doc-management';
|
||||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
|
|
||||||
import { useFocusStore, useResponsiveStore } from '@/stores';
|
import { useFocusStore, useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
|
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
|
||||||
@@ -87,7 +86,6 @@ interface DocToolBoxProps {
|
|||||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const treeContext = useTreeContext<Doc>();
|
const treeContext = useTreeContext<Doc>();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isChild, isTopRoot } = useDocUtils(doc);
|
const { isChild, isTopRoot } = useDocUtils(doc);
|
||||||
|
|
||||||
@@ -113,16 +111,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
|
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectHistoryModal.isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void queryClient.resetQueries({
|
|
||||||
queryKey: [KEY_LIST_DOC_VERSIONS],
|
|
||||||
});
|
|
||||||
}, [selectHistoryModal.isOpen, queryClient]);
|
|
||||||
|
|
||||||
// Emoji Management
|
// Emoji Management
|
||||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||||
const { updateDocEmoji } = useDocTitleUpdate();
|
const { updateDocEmoji } = useDocTitleUpdate();
|
||||||
@@ -193,7 +181,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('Copy as {{format}}', { format: 'Markdown' }),
|
label: t('Copy as {{format}}', { format: 'Markdown' }),
|
||||||
icon: <ContentCopySVG width={24} height={24} />,
|
icon: <MarkdownCopySVG width={24} height={24} />,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
void copyCurrentEditorToClipboard('markdown');
|
void copyCurrentEditorToClipboard('markdown');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from '@gouvfr-lasuite/cunningham-react';
|
} from '@gouvfr-lasuite/cunningham-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useEditorStore } from '../../doc-editor';
|
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
|
||||||
|
|
||||||
export const useCopyCurrentEditorToClipboard = () => {
|
export const useCopyCurrentEditorToClipboard = () => {
|
||||||
const { editor } = useEditorStore();
|
const { editor } = useEditorStore();
|
||||||
@@ -13,20 +13,31 @@ export const useCopyCurrentEditorToClipboard = () => {
|
|||||||
|
|
||||||
return async (asFormat: 'html' | 'markdown') => {
|
return async (asFormat: 'html' | 'markdown') => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
|
const message = t('Editor unavailable');
|
||||||
|
toast(message, VariantType.ERROR, { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const editorContentFormatted =
|
const editorContentFormatted =
|
||||||
asFormat === 'html'
|
asFormat === 'html'
|
||||||
? await editor.blocksToHTMLLossy()
|
? editor.blocksToHTMLLossy()
|
||||||
: await editor.blocksToMarkdownLossy();
|
: editor.blocksToMarkdownLossy();
|
||||||
await navigator.clipboard.writeText(editorContentFormatted);
|
await navigator.clipboard.writeText(editorContentFormatted);
|
||||||
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
|
const successMessage =
|
||||||
|
asFormat === 'markdown'
|
||||||
|
? t('Copied as Markdown to clipboard')
|
||||||
|
: t('Copied to clipboard');
|
||||||
|
|
||||||
|
toast(successMessage, VariantType.SUCCESS, { duration: 3000 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
|
const errorMessage =
|
||||||
|
asFormat === 'markdown'
|
||||||
|
? t('Failed to copy as Markdown to clipboard')
|
||||||
|
: t('Failed to copy to clipboard');
|
||||||
|
|
||||||
|
toast(errorMessage, VariantType.ERROR, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { announce } from '@react-aria/live-announcer';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ export function useCreateFavoriteDoc({
|
|||||||
listInvalidQueries,
|
listInvalidQueries,
|
||||||
}: CreateFavoriteDocProps) {
|
}: CreateFavoriteDocProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<void, APIError, CreateFavoriteDocParams>({
|
return useMutation<void, APIError, CreateFavoriteDocParams>({
|
||||||
mutationFn: createFavoriteDoc,
|
mutationFn: createFavoriteDoc,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -37,7 +41,15 @@ export function useCreateFavoriteDoc({
|
|||||||
queryKey: [queryKey],
|
queryKey: [queryKey],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const message = t('Document pinned successfully!');
|
||||||
|
announce(message, 'polite');
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
const message = t('Failed to pin the document.');
|
||||||
|
announce(message, 'assertive');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { announce } from '@react-aria/live-announcer';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ export function useDeleteFavoriteDoc({
|
|||||||
listInvalidQueries,
|
listInvalidQueries,
|
||||||
}: DeleteFavoriteDocProps) {
|
}: DeleteFavoriteDocProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<void, APIError, DeleteFavoriteDocParams>({
|
return useMutation<void, APIError, DeleteFavoriteDocParams>({
|
||||||
mutationFn: deleteFavoriteDoc,
|
mutationFn: deleteFavoriteDoc,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -37,7 +41,15 @@ export function useDeleteFavoriteDoc({
|
|||||||
queryKey: [queryKey],
|
queryKey: [queryKey],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const message = t('Document unpinned successfully!');
|
||||||
|
announce(message, 'polite');
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
const message = t('Failed to unpin the document.');
|
||||||
|
announce(message, 'assertive');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,14 +88,16 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
|
|||||||
queryKey: [KEY_LIST_DOC],
|
queryKey: [KEY_LIST_DOC],
|
||||||
});
|
});
|
||||||
|
|
||||||
toast(t('Document duplicated successfully!'), VariantType.SUCCESS, {
|
const message = t('Document duplicated successfully!');
|
||||||
|
toast(message, VariantType.SUCCESS, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
void options?.onSuccess?.(data, variables, onMutateResult, context);
|
void options?.onSuccess?.(data, variables, onMutateResult, context);
|
||||||
},
|
},
|
||||||
onError: (error, variables, onMutateResult, context) => {
|
onError: (error, variables, onMutateResult, context) => {
|
||||||
toast(t('Failed to duplicate the document...'), VariantType.ERROR, {
|
const message = t('Failed to duplicate the document...');
|
||||||
|
toast(message, VariantType.ERROR, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
|
|||||||
...queryConfig,
|
...queryConfig,
|
||||||
onSuccess: (data, variables, onMutateResult, context) => {
|
onSuccess: (data, variables, onMutateResult, context) => {
|
||||||
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
|
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.resetQueries({
|
||||||
queryKey: [queryKey],
|
queryKey: [queryKey],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const DocIcon = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addLastFocus, restoreFocus } = useFocusStore();
|
const { addLastFocus, restoreFocus } = useFocusStore();
|
||||||
|
|
||||||
const iconRef = useRef<HTMLDivElement>(null);
|
const iconRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
|
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
|
||||||
const [pickerPosition, setPickerPosition] = useState<{
|
const [pickerPosition, setPickerPosition] = useState<{
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ vi.mock('@/stores', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@gouvfr-lasuite/ui-kit', async () => ({
|
||||||
|
...(await vi.importActual('@gouvfr-lasuite/ui-kit')),
|
||||||
|
useTreeContext: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('useDocTitleUpdate', () => {
|
describe('useDocTitleUpdate', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const defaultValues = {
|
|||||||
|
|
||||||
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
|
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
|
||||||
|
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
createProvider: (wsUrl, storeId, initialDoc) => {
|
createProvider: (wsUrl, storeId, initialDoc) => {
|
||||||
@@ -48,7 +50,20 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
|||||||
onDisconnect(data) {
|
onDisconnect(data) {
|
||||||
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
|
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
|
||||||
if ((data.event as ExtendedCloseEvent).wasClean) {
|
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() {
|
onAuthenticationFailed() {
|
||||||
@@ -107,6 +122,7 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
|||||||
return provider;
|
return provider;
|
||||||
},
|
},
|
||||||
destroyProvider: () => {
|
destroyProvider: () => {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
const provider = get().provider;
|
const provider = get().provider;
|
||||||
if (provider) {
|
if (provider) {
|
||||||
provider.destroy();
|
provider.destroy();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { announce } from '@react-aria/live-announcer';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
@@ -73,10 +74,12 @@ export const DocSearchContent = ({
|
|||||||
docs = docs.filter(filterResults);
|
docs = docs.filter(filterResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elements = search || isSearchNotMandatory ? docs : [];
|
||||||
|
|
||||||
setDocsData({
|
setDocsData({
|
||||||
groupName: docs.length > 0 ? groupName : '',
|
groupName: docs.length > 0 ? groupName : '',
|
||||||
groupKey: 'docs',
|
groupKey: 'docs',
|
||||||
elements: search || isSearchNotMandatory ? docs : [],
|
elements,
|
||||||
emptyString: t('No document found'),
|
emptyString: t('No document found'),
|
||||||
endActions: hasNextPage
|
endActions: hasNextPage
|
||||||
? [
|
? [
|
||||||
@@ -90,6 +93,13 @@ export const DocSearchContent = ({
|
|||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
announce(
|
||||||
|
t('{{count}} result(s) available', { count: elements.length }),
|
||||||
|
'polite',
|
||||||
|
);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
search,
|
search,
|
||||||
data?.pages,
|
data?.pages,
|
||||||
|
|||||||
@@ -58,7 +58,13 @@ export const DocSearchFilters = ({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<Button color="brand" variant="tertiary" size="small" onClick={onReset}>
|
<Button
|
||||||
|
color="brand"
|
||||||
|
variant="tertiary"
|
||||||
|
size="small"
|
||||||
|
onClick={onReset}
|
||||||
|
aria-label={t('Reset search filters')}
|
||||||
|
>
|
||||||
{t('Reset')}
|
{t('Reset')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
DocSearchFiltersValues,
|
DocSearchFiltersValues,
|
||||||
DocSearchTarget,
|
DocSearchTarget,
|
||||||
} from '@/docs/doc-search';
|
} from '@/docs/doc-search';
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useFocusStore, useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ const DocSearchModalGlobal = ({
|
|||||||
}: DocSearchModalGlobalProps) => {
|
}: DocSearchModalGlobalProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const restoreFocus = useFocusStore((state) => state.restoreFocus);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
||||||
@@ -51,6 +52,7 @@ const DocSearchModalGlobal = ({
|
|||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
setFilters({});
|
setFilters({});
|
||||||
|
restoreFocus();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -62,6 +64,7 @@ const DocSearchModalGlobal = ({
|
|||||||
aria-describedby="doc-search-modal-title"
|
aria-describedby="doc-search-modal-title"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
aria-label={t('Search modal')}
|
||||||
$direction="column"
|
$direction="column"
|
||||||
$justify="space-between"
|
$justify="space-between"
|
||||||
className="--docs--doc-search-modal"
|
className="--docs--doc-search-modal"
|
||||||
@@ -85,21 +88,26 @@ const DocSearchModalGlobal = ({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<QuickSearch
|
<QuickSearch
|
||||||
|
label={t('Search documents')}
|
||||||
placeholder={t('Type the name of a document')}
|
placeholder={t('Type the name of a document')}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onFilter={handleInputSearch}
|
onFilter={handleInputSearch}
|
||||||
|
beforeList={
|
||||||
|
showFilters ? (
|
||||||
|
<Box $padding={{ horizontal: '10px' }}>
|
||||||
|
<DocSearchFilters
|
||||||
|
values={filters}
|
||||||
|
onValuesChange={setFilters}
|
||||||
|
onReset={handleResetFilters}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
$padding={{ horizontal: '10px', vertical: 'base' }}
|
$padding={{ horizontal: '10px', vertical: 'base' }}
|
||||||
$height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}
|
$height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}
|
||||||
>
|
>
|
||||||
{showFilters && (
|
|
||||||
<DocSearchFilters
|
|
||||||
values={filters}
|
|
||||||
onValuesChange={setFilters}
|
|
||||||
onReset={handleResetFilters}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{search.length === 0 && (
|
{search.length === 0 && (
|
||||||
<Box
|
<Box
|
||||||
$direction="column"
|
$direction="column"
|
||||||
@@ -107,11 +115,7 @@ const DocSearchModalGlobal = ({
|
|||||||
$align="center"
|
$align="center"
|
||||||
$justify="center"
|
$justify="center"
|
||||||
>
|
>
|
||||||
<Image
|
<Image width={320} src={EmptySearchIcon} alt="" />
|
||||||
width={320}
|
|
||||||
src={EmptySearchIcon}
|
|
||||||
alt={t('No active search')}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{search && (
|
{search && (
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export const DocShareAddMemberList = ({
|
|||||||
$scope="surface"
|
$scope="surface"
|
||||||
$theme="tertiary"
|
$theme="tertiary"
|
||||||
$variation=""
|
$variation=""
|
||||||
$border="1px solid var(--c--contextuals--border--semantic--contextual--primary)"
|
$border="1px solid var(--c--contextuals--border--surface--primary)"
|
||||||
|
$margin={{ bottom: 'sm' }}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
$direction="row"
|
$direction="row"
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showMemberSection && isRootDoc && (
|
{showMemberSection && isRootDoc && (
|
||||||
<Box $padding={{ horizontal: 'base' }}>
|
<Box $padding={{ horizontal: 'base', top: 'base' }}>
|
||||||
<QuickSearchGroupAccessRequest doc={doc} />
|
<QuickSearchGroupAccessRequest doc={doc} />
|
||||||
<QuickSearchGroupInvitation doc={doc} />
|
<QuickSearchGroupInvitation doc={doc} />
|
||||||
<QuickSearchGroupMember doc={doc} />
|
<QuickSearchGroupMember doc={doc} />
|
||||||
@@ -301,6 +301,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
|||||||
searchUsersRawData={searchUsersQuery.data}
|
searchUsersRawData={searchUsersQuery.data}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
userQuery={userQuery}
|
userQuery={userQuery}
|
||||||
|
minLength={API_USERS_SEARCH_QUERY_MIN_LENGTH}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</QuickSearch>
|
</QuickSearch>
|
||||||
@@ -321,14 +322,35 @@ interface QuickSearchInviteInputSectionProps {
|
|||||||
onSelect: (usr: User) => void;
|
onSelect: (usr: User) => void;
|
||||||
searchUsersRawData: User[] | undefined;
|
searchUsersRawData: User[] | undefined;
|
||||||
userQuery: string;
|
userQuery: string;
|
||||||
|
minLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QuickSearchInviteInputSection = ({
|
const QuickSearchInviteInputSection = ({
|
||||||
onSelect,
|
onSelect,
|
||||||
searchUsersRawData,
|
searchUsersRawData,
|
||||||
userQuery,
|
userQuery,
|
||||||
|
minLength,
|
||||||
}: QuickSearchInviteInputSectionProps) => {
|
}: QuickSearchInviteInputSectionProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const hint = useMemo(() => {
|
||||||
|
if (userQuery.length < minLength) {
|
||||||
|
return t('Type at least {{minLength}} characters to display user names', {
|
||||||
|
minLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isValidEmail(userQuery)) {
|
||||||
|
return t('Choose the email');
|
||||||
|
}
|
||||||
|
if (!searchUsersRawData?.length) {
|
||||||
|
return t('No results. Type a full email address to invite someone.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('Choose a user');
|
||||||
|
}, [minLength, searchUsersRawData?.length, t, userQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
announce(hint, 'polite');
|
||||||
|
}, [hint]);
|
||||||
|
|
||||||
const searchUserData: QuickSearchData<User> = useMemo(() => {
|
const searchUserData: QuickSearchData<User> = useMemo(() => {
|
||||||
const users = searchUsersRawData || [];
|
const users = searchUsersRawData || [];
|
||||||
@@ -347,7 +369,7 @@ const QuickSearchInviteInputSection = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groupName: t('Search user result'),
|
groupName: hint,
|
||||||
elements: users,
|
elements: users,
|
||||||
endActions:
|
endActions:
|
||||||
isEmail && !hasEmailInUsers
|
isEmail && !hasEmailInUsers
|
||||||
@@ -359,12 +381,12 @@ const QuickSearchInviteInputSection = ({
|
|||||||
]
|
]
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
}, [onSelect, searchUsersRawData, t, userQuery]);
|
}, [searchUsersRawData, userQuery, hint, onSelect]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
aria-label={t('List search user result card')}
|
aria-label={t('List search user result card')}
|
||||||
$padding={{ horizontal: 'base', bottom: '3xs' }}
|
$padding={{ horizontal: 'base', bottom: '3xs', top: 'base' }}
|
||||||
>
|
>
|
||||||
<QuickSearchGroup
|
<QuickSearchGroup
|
||||||
group={searchUserData}
|
group={searchUserData}
|
||||||
|
|||||||
@@ -51,11 +51,13 @@ export const Heading = ({
|
|||||||
|
|
||||||
editor.setTextCursorPosition(headingId, 'end');
|
editor.setTextCursorPosition(headingId, 'end');
|
||||||
|
|
||||||
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
|
document
|
||||||
behavior: 'smooth',
|
.querySelector<HTMLElement>(`[data-id="${headingId}"]`)
|
||||||
inline: 'start',
|
?.scrollIntoView({
|
||||||
block: 'start',
|
behavior: 'smooth',
|
||||||
});
|
inline: 'start',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
$radius="var(--c--globals--spacings--st)"
|
$radius="var(--c--globals--spacings--st)"
|
||||||
$background={
|
$background={
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const TableContent = () => {
|
|||||||
$height="100%"
|
$height="100%"
|
||||||
$justify="center"
|
$justify="center"
|
||||||
$align="center"
|
$align="center"
|
||||||
aria-label={t('Summary')}
|
aria-label={t('Show the table of contents')}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-controls="toc-list"
|
aria-controls="toc-list"
|
||||||
$css={css`
|
$css={css`
|
||||||
@@ -218,8 +218,8 @@ const TableContentOpened = ({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
$justify="center"
|
$justify="center"
|
||||||
$align="center"
|
$align="center"
|
||||||
aria-label={t('Summary')}
|
aria-label={t('Hide the table of contents')}
|
||||||
aria-expanded="true"
|
aria-expanded={true}
|
||||||
aria-controls="toc-list"
|
aria-controls="toc-list"
|
||||||
$css={css`
|
$css={css`
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
|||||||
const ariaLabel = docTitle;
|
const ariaLabel = docTitle;
|
||||||
const isDisabled = !!doc.deleted_at;
|
const isDisabled = !!doc.deleted_at;
|
||||||
const actionsRef = useRef<HTMLDivElement>(null);
|
const actionsRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonOptionRef = useRef<HTMLDivElement | null>(null);
|
const buttonOptionRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useMoveDoc,
|
useMoveDoc,
|
||||||
useTrans,
|
useTrans,
|
||||||
} from '@/docs/doc-management';
|
} from '@/docs/doc-management';
|
||||||
|
import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton';
|
||||||
|
|
||||||
import { CLASS_DOC_TITLE } from '../../doc-header';
|
import { CLASS_DOC_TITLE } from '../../doc-header';
|
||||||
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
|
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
|
||||||
@@ -43,7 +44,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
|
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
|
||||||
const rootItemRef = useRef<HTMLDivElement>(null);
|
const rootItemRef = useRef<HTMLDivElement>(null);
|
||||||
const rootActionsRef = useRef<HTMLDivElement>(null);
|
const rootActionsRef = useRef<HTMLDivElement>(null);
|
||||||
const rootButtonOptionRef = useRef<HTMLDivElement | null>(null);
|
const rootButtonOptionRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -257,7 +258,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
}, [currentDoc, treeContext]);
|
}, [currentDoc, treeContext]);
|
||||||
|
|
||||||
if (!treeContext || !treeContext.root) {
|
if (!treeContext || !treeContext.root) {
|
||||||
return null;
|
return <TreeSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type DocTreeItemActionsProps = {
|
|||||||
onOpenChange?: (isOpen: boolean) => void;
|
onOpenChange?: (isOpen: boolean) => void;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
actionsRef?: React.RefObject<HTMLDivElement | null>;
|
actionsRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
|
buttonOptionRef?: React.RefObject<HTMLButtonElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocTreeItemActions = ({
|
export const DocTreeItemActions = ({
|
||||||
@@ -48,7 +48,7 @@ export const DocTreeItemActions = ({
|
|||||||
}: DocTreeItemActionsProps) => {
|
}: DocTreeItemActionsProps) => {
|
||||||
const internalActionsRef = useRef<HTMLDivElement | null>(null);
|
const internalActionsRef = useRef<HTMLDivElement | null>(null);
|
||||||
const targetActionsRef = actionsRef ?? internalActionsRef;
|
const targetActionsRef = actionsRef ?? internalActionsRef;
|
||||||
const internalButtonRef = useRef<HTMLDivElement | null>(null);
|
const internalButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
|
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { useEffect, useState } from 'react';
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
import { Box, Text, TextErrors } from '@/components';
|
import { Box, Text, TextErrors } from '@/components';
|
||||||
import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
|
import { BlockNoteReader } from '@/docs/doc-editor/components/BlockNoteEditor';
|
||||||
|
import { DocEditorContainer } from '@/docs/doc-editor/components/DocEditor';
|
||||||
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
|
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
|
||||||
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
|
|
||||||
|
import { useDocVersion } from '../api/useDocVersion';
|
||||||
|
import { Versions } from '../types';
|
||||||
|
|
||||||
import { DocVersionHeader } from './DocVersionHeader';
|
import { DocVersionHeader } from './DocVersionHeader';
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
VariantType,
|
VariantType,
|
||||||
useToastProvider,
|
useToastProvider,
|
||||||
} from '@gouvfr-lasuite/cunningham-react';
|
} from '@gouvfr-lasuite/cunningham-react';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createGlobalStyle } from 'styled-components';
|
||||||
|
|
||||||
import { Box, Text } from '@/components';
|
import { Box, Text } from '@/components';
|
||||||
import {
|
import {
|
||||||
@@ -21,15 +21,22 @@ import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
|
|||||||
import { Versions } from '../types';
|
import { Versions } from '../types';
|
||||||
import { revertUpdate } from '../utils';
|
import { revertUpdate } from '../utils';
|
||||||
|
|
||||||
interface ModalConfirmationVersionProps {
|
const ModalStyle = createGlobalStyle`
|
||||||
onClose: () => void;
|
.c__modal__title {
|
||||||
docId: Doc['id'];
|
margin-bottom: var(--c--globals--spacings--sm);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface ModalConfirmationVersionProps {
|
||||||
|
docId: Doc['id'];
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
versionId: Versions['version_id'];
|
versionId: Versions['version_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModalConfirmationVersion = ({
|
export const ModalConfirmationVersion = ({
|
||||||
onClose,
|
onClose,
|
||||||
|
onSuccess,
|
||||||
docId,
|
docId,
|
||||||
versionId,
|
versionId,
|
||||||
}: ModalConfirmationVersionProps) => {
|
}: ModalConfirmationVersionProps) => {
|
||||||
@@ -39,14 +46,13 @@ export const ModalConfirmationVersion = ({
|
|||||||
});
|
});
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
const { push } = useRouter();
|
|
||||||
const { provider } = useProviderStore();
|
const { provider } = useProviderStore();
|
||||||
const { mutate: updateDoc } = useUpdateDoc({
|
const { mutate: updateDoc } = useUpdateDoc({
|
||||||
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
|
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const onDisplaySuccess = () => {
|
const onDisplaySuccess = () => {
|
||||||
toast(t('Version restored successfully'), VariantType.SUCCESS);
|
toast(t('Version restored successfully'), VariantType.SUCCESS);
|
||||||
void push(`/docs/${docId}`);
|
onSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!provider || !version?.content) {
|
if (!provider || !version?.content) {
|
||||||
@@ -64,6 +70,10 @@ export const ModalConfirmationVersion = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
@@ -102,7 +112,7 @@ export const ModalConfirmationVersion = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
size={ModalSize.SMALL}
|
size={ModalSize.MEDIUM}
|
||||||
title={
|
title={
|
||||||
<Text
|
<Text
|
||||||
as="h1"
|
as="h1"
|
||||||
@@ -111,17 +121,17 @@ export const ModalConfirmationVersion = ({
|
|||||||
$size="h6"
|
$size="h6"
|
||||||
$align="flex-start"
|
$align="flex-start"
|
||||||
>
|
>
|
||||||
{t('Warning')}
|
{t('Restoring an older version')}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<ModalStyle />
|
||||||
<Box className="--docs--modal-confirmation-version">
|
<Box className="--docs--modal-confirmation-version">
|
||||||
<Box>
|
<Box>
|
||||||
<Text $variation="secondary" as="p">
|
<Text $variation="secondary" as="p" $margin="none">
|
||||||
{t('Your current document will revert to this version.')}
|
{t(
|
||||||
</Text>
|
"The current document will be replaced, but you'll still find it in the version history.",
|
||||||
<Text $variation="secondary" as="p">
|
)}
|
||||||
{t('If a member is editing, his works can be lost.')}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -114,11 +114,12 @@ export const ModalSelectVersion = ({
|
|||||||
$height="calc(100vh - 2em - 12px)"
|
$height="calc(100vh - 2em - 12px)"
|
||||||
$css={css`
|
$css={css`
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
border-left: 1px solid var(--c--globals--colors--gray-200);
|
border-left: 1px solid
|
||||||
|
var(--c--contextuals--border--surface--primary);
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
aria-label="version list"
|
aria-label={t('Version list')}
|
||||||
$css={css`
|
$css={css`
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -130,7 +131,8 @@ export const ModalSelectVersion = ({
|
|||||||
$direction="row"
|
$direction="row"
|
||||||
$align="center"
|
$align="center"
|
||||||
$css={css`
|
$css={css`
|
||||||
border-bottom: 1px solid var(--c--globals--colors--gray-200);
|
border-bottom: 1px solid
|
||||||
|
var(--c--contextuals--border--surface--primary);
|
||||||
`}
|
`}
|
||||||
$padding="sm"
|
$padding="sm"
|
||||||
>
|
>
|
||||||
@@ -155,7 +157,8 @@ export const ModalSelectVersion = ({
|
|||||||
<Box
|
<Box
|
||||||
$padding="xs"
|
$padding="xs"
|
||||||
$css={css`
|
$css={css`
|
||||||
border-top: 1px solid var(--c--globals--colors--gray-200);
|
border-top: 1px solid
|
||||||
|
var(--c--contextuals--border--surface--primary);
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -174,6 +177,9 @@ export const ModalSelectVersion = ({
|
|||||||
<ModalConfirmationVersion
|
<ModalConfirmationVersion
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
restoreModal.close();
|
restoreModal.close();
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
restoreModal.close();
|
||||||
onClose();
|
onClose();
|
||||||
setSelectedVersionId(undefined);
|
setSelectedVersionId(undefined);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,78 +1,38 @@
|
|||||||
import dynamic from 'next/dynamic';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Box, Text } from '@/components';
|
import { BoxButton, Text } from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
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 {
|
interface VersionItemProps {
|
||||||
docId: Doc['id'];
|
|
||||||
text: string;
|
text: string;
|
||||||
|
|
||||||
versionId?: Versions['version_id'];
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VersionItem = ({
|
export const VersionItem = ({ text, isActive, onSelect }: VersionItemProps) => {
|
||||||
docId,
|
const { t } = useTranslation();
|
||||||
versionId,
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
text,
|
|
||||||
|
|
||||||
isActive,
|
|
||||||
}: VersionItemProps) => {
|
|
||||||
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
|
|
||||||
|
|
||||||
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<BoxButton
|
||||||
<Box
|
aria-label={t('Restore version of {{date}}', { date: text })}
|
||||||
$width="100%"
|
aria-pressed={isActive}
|
||||||
as="li"
|
$width="100%"
|
||||||
$background={isActive ? colorsTokens['gray-100'] : 'transparent'}
|
$css={`
|
||||||
$radius={spacingsTokens['3xs']}
|
background: ${isActive ? 'var(--c--contextuals--background--semantic--overlay--primary)' : 'transparent'};
|
||||||
$css={`
|
&:focus-visible, &:hover {
|
||||||
cursor: pointer;
|
background: var(--c--contextuals--background--semantic--overlay--primary);
|
||||||
|
}
|
||||||
&:hover {
|
`}
|
||||||
background: ${colorsTokens['gray-100']};
|
className="version-item --docs--version-item"
|
||||||
}
|
onClick={onSelect}
|
||||||
`}
|
$radius={spacingsTokens['3xs']}
|
||||||
$hasTransition
|
$padding={{ vertical: 'm', horizontal: 'xs' }}
|
||||||
$minWidth="13rem"
|
$hasTransition
|
||||||
className="--docs--version-item"
|
>
|
||||||
>
|
<Text $weight="bold" $size="sm" $textAlign="left">
|
||||||
<Box
|
{text}
|
||||||
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
|
</Text>
|
||||||
$align="center"
|
</BoxButton>
|
||||||
$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>
|
|
||||||
{isModalVersionOpen && versionId && (
|
|
||||||
<ModalConfirmationVersion
|
|
||||||
onClose={() => setIsModalVersionOpen(false)}
|
|
||||||
docId={docId}
|
|
||||||
versionId={versionId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,14 +3,7 @@ import { DateTime } from 'luxon';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { APIError } from '@/api';
|
import { APIError } from '@/api';
|
||||||
import {
|
import { Box, Icon, InfiniteScroll, Text, TextErrors } from '@/components';
|
||||||
Box,
|
|
||||||
BoxButton,
|
|
||||||
Icon,
|
|
||||||
InfiniteScroll,
|
|
||||||
Text,
|
|
||||||
TextErrors,
|
|
||||||
} from '@/components';
|
|
||||||
import { Doc } from '@/docs/doc-management';
|
import { Doc } from '@/docs/doc-management';
|
||||||
import { useDate } from '@/hooks';
|
import { useDate } from '@/hooks';
|
||||||
|
|
||||||
@@ -23,7 +16,6 @@ interface VersionListStateProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: APIError<unknown> | null;
|
error: APIError<unknown> | null;
|
||||||
versions?: Versions[];
|
versions?: Versions[];
|
||||||
doc: Doc;
|
|
||||||
selectedVersionId?: Versions['version_id'];
|
selectedVersionId?: Versions['version_id'];
|
||||||
onSelectVersion?: (versionId: Versions['version_id']) => void;
|
onSelectVersion?: (versionId: Versions['version_id']) => void;
|
||||||
}
|
}
|
||||||
@@ -31,13 +23,11 @@ interface VersionListStateProps {
|
|||||||
const VersionListState = ({
|
const VersionListState = ({
|
||||||
onSelectVersion,
|
onSelectVersion,
|
||||||
selectedVersionId,
|
selectedVersionId,
|
||||||
|
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
versions,
|
versions,
|
||||||
doc,
|
|
||||||
}: VersionListStateProps) => {
|
}: VersionListStateProps) => {
|
||||||
const { formatDate } = useDate();
|
const { formatDateSpecial } = useDate();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -48,24 +38,23 @@ const VersionListState = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box $gap="10px" $padding="xs">
|
<Box $gap="xxs" $padding="xs">
|
||||||
{versions?.map((version) => (
|
{versions?.map((version) => {
|
||||||
<BoxButton
|
const formattedDate = formatDateSpecial(
|
||||||
aria-label="version item"
|
version.last_modified,
|
||||||
className="version-item"
|
'dd MMMM · HH:mm',
|
||||||
key={version.version_id}
|
);
|
||||||
onClick={() => {
|
const isSelected = version.version_id === selectedVersionId;
|
||||||
onSelectVersion?.(version.version_id);
|
return (
|
||||||
}}
|
<Box as="li" key={version.version_id} $css="list-style: none;">
|
||||||
>
|
<VersionItem
|
||||||
<VersionItem
|
text={formattedDate}
|
||||||
versionId={version.version_id}
|
isActive={isSelected}
|
||||||
text={formatDate(version.last_modified, DateTime.DATETIME_MED)}
|
onSelect={() => onSelectVersion?.(version.version_id)}
|
||||||
docId={doc.id}
|
/>
|
||||||
isActive={version.version_id === selectedVersionId}
|
</Box>
|
||||||
/>
|
);
|
||||||
</BoxButton>
|
})}
|
||||||
))}
|
|
||||||
{error && (
|
{error && (
|
||||||
<Box
|
<Box
|
||||||
$justify="center"
|
$justify="center"
|
||||||
@@ -97,6 +86,7 @@ export const VersionList = ({
|
|||||||
selectedVersionId,
|
selectedVersionId,
|
||||||
}: VersionListProps) => {
|
}: VersionListProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { formatDate } = useDate();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -112,6 +102,12 @@ export const VersionList = ({
|
|||||||
const versions = data?.pages.reduce((acc, page) => {
|
const versions = data?.pages.reduce((acc, page) => {
|
||||||
return acc.concat(page.versions);
|
return acc.concat(page.versions);
|
||||||
}, [] as Versions[]);
|
}, [] as Versions[]);
|
||||||
|
const selectedVersion = versions?.find(
|
||||||
|
(version) => version.version_id === selectedVersionId,
|
||||||
|
);
|
||||||
|
const selectedVersionDate = selectedVersion
|
||||||
|
? formatDate(selectedVersion.last_modified, DateTime.DATETIME_MED)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -127,7 +123,7 @@ export const VersionList = ({
|
|||||||
as="ul"
|
as="ul"
|
||||||
$padding="none"
|
$padding="none"
|
||||||
$margin={{ top: 'none' }}
|
$margin={{ top: 'none' }}
|
||||||
role="listbox"
|
role="list"
|
||||||
>
|
>
|
||||||
{versions?.length === 0 && (
|
{versions?.length === 0 && (
|
||||||
<Box $align="center" $margin="large">
|
<Box $align="center" $margin="large">
|
||||||
@@ -141,10 +137,14 @@ export const VersionList = ({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
versions={versions}
|
versions={versions}
|
||||||
doc={doc}
|
|
||||||
selectedVersionId={selectedVersionId}
|
selectedVersionId={selectedVersionId}
|
||||||
/>
|
/>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
|
<Text className="sr-only" aria-live="polite">
|
||||||
|
{selectedVersionDate
|
||||||
|
? t('Selected version {{date}}', { date: selectedVersionDate })
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Doc } from '../doc-management';
|
import { Doc } from '../doc-management/types';
|
||||||
|
|
||||||
export interface APIListVersions {
|
export interface APIListVersions {
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ export const DocsGrid = ({
|
|||||||
$padding={{
|
$padding={{
|
||||||
bottom: 'md',
|
bottom: 'md',
|
||||||
}}
|
}}
|
||||||
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
|
{...(withUpload
|
||||||
|
? getRootProps({ className: 'dropzone', tabIndex: -1 })
|
||||||
|
: {})}
|
||||||
>
|
>
|
||||||
{withUpload && <input {...getInputProps()} />}
|
{withUpload && <input {...getInputProps()} />}
|
||||||
<DocGridTitleBar
|
<DocGridTitleBar
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
useDuplicateDoc,
|
useDuplicateDoc,
|
||||||
useTrans,
|
useTrans,
|
||||||
} from '@/docs/doc-management';
|
} from '@/docs/doc-management';
|
||||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
import { focusMainContentStart } from '@/layouts/utils';
|
||||||
import { useFocusStore } from '@/stores';
|
import { useFocusStore } from '@/stores';
|
||||||
|
|
||||||
import { DocMoveModal } from './DocMoveModal';
|
import { DocMoveModal } from './DocMoveModal';
|
||||||
@@ -55,10 +55,9 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
|
|||||||
|
|
||||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
requestAnimationFrame(() => {
|
||||||
if (mainContent) {
|
focusMainContentStart({ preventScroll: true });
|
||||||
requestAnimationFrame(() => mainContent.focus());
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@@ -73,7 +73,9 @@ describe('DocsGridItemDate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it(`should render rendered the updated_at field in the correct language`, async () => {
|
it(`should render rendered the updated_at field in the correct language`, async () => {
|
||||||
await i18next.changeLanguage('fr');
|
await act(async () => {
|
||||||
|
await i18next.changeLanguage('fr');
|
||||||
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<DocsGridItemDate
|
<DocsGridItemDate
|
||||||
@@ -90,7 +92,9 @@ describe('DocsGridItemDate', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
|
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
|
||||||
|
|
||||||
await i18next.changeLanguage('en');
|
await act(async () => {
|
||||||
|
await i18next.changeLanguage('en');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const HelpMenu = ({
|
|||||||
>
|
>
|
||||||
<Box $direction="row" $align="center">
|
<Box $direction="row" $align="center">
|
||||||
<Button
|
<Button
|
||||||
aria-label={t('Open onboarding menu')}
|
aria-label={t('Open help menu')}
|
||||||
color={colorButton || 'neutral'}
|
color={colorButton || 'neutral'}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
iconPosition="left"
|
iconPosition="left"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { announce } from '@react-aria/live-announcer';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
@@ -17,23 +18,35 @@ export const LanguagePicker = () => {
|
|||||||
const { changeLanguageSynchronized } = useSynchronizedLanguage();
|
const { changeLanguageSynchronized } = useSynchronizedLanguage();
|
||||||
const language = i18n.language;
|
const language = i18n.language;
|
||||||
|
|
||||||
|
const toLangTag = (locale: string) => locale.replace('_', '-');
|
||||||
|
|
||||||
// Compute options for dropdown
|
// Compute options for dropdown
|
||||||
const optionsPicker = useMemo(() => {
|
const optionsPicker = useMemo(() => {
|
||||||
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
|
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
|
||||||
return backendOptions.map(([backendLocale, backendLabel]) => {
|
return backendOptions.map(([backendLocale, backendLabel]) => {
|
||||||
return {
|
return {
|
||||||
label: backendLabel,
|
label: backendLabel,
|
||||||
|
lang: toLangTag(backendLocale),
|
||||||
|
value: backendLocale,
|
||||||
isSelected: getMatchingLocales([backendLocale], [language]).length > 0,
|
isSelected: getMatchingLocales([backendLocale], [language]).length > 0,
|
||||||
callback: () => changeLanguageSynchronized(backendLocale, user),
|
callback: async () => {
|
||||||
|
await changeLanguageSynchronized(backendLocale, user);
|
||||||
|
announce(
|
||||||
|
t('Language changed to {{language}}', {
|
||||||
|
language: backendLabel,
|
||||||
|
defaultValue: `Language changed to ${backendLabel}`,
|
||||||
|
}),
|
||||||
|
'polite',
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
|
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, t, user]);
|
||||||
|
|
||||||
// Extract current language label for display
|
// Extract current language label for display
|
||||||
const currentLanguageLabel =
|
const [currentLanguageCode, currentLanguageLabel] = conf?.LANGUAGES.find(
|
||||||
conf?.LANGUAGES.find(
|
([code]) => getMatchingLocales([code], [language]).length > 0,
|
||||||
([code]) => getMatchingLocales([code], [language]).length > 0,
|
) ?? [language, language];
|
||||||
)?.[1] || language;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
@@ -65,7 +78,9 @@ export const LanguagePicker = () => {
|
|||||||
$align="center"
|
$align="center"
|
||||||
>
|
>
|
||||||
<Icon iconName="translate" $color="inherit" $size="xl" />
|
<Icon iconName="translate" $color="inherit" $size="xl" />
|
||||||
{currentLanguageLabel}
|
<span lang={toLangTag(currentLanguageCode)}>
|
||||||
|
{currentLanguageLabel}
|
||||||
|
</span>
|
||||||
</Box>
|
</Box>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { createGlobalStyle, css } from 'styled-components';
|
import { createGlobalStyle, css } from 'styled-components';
|
||||||
|
|
||||||
@@ -23,11 +25,15 @@ const MobileLeftPanelStyle = createGlobalStyle`
|
|||||||
|
|
||||||
export const LeftPanel = () => {
|
export const LeftPanel = () => {
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
const { t } = useTranslation();
|
if (isDesktop) {
|
||||||
|
return <LeftPanelDesktop />;
|
||||||
|
}
|
||||||
|
|
||||||
const { spacingsTokens } = useCunninghamTheme();
|
return <LeftPanelMobile />;
|
||||||
const { isPanelOpen, isPanelOpenMobile } = useLeftPanelStore();
|
};
|
||||||
const isPanelOpenState = isDesktop ? isPanelOpen : isPanelOpenMobile;
|
|
||||||
|
export const LeftPanelDesktop = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
/**
|
/**
|
||||||
* The onboarding can be disable, so we need to check if it's enabled before displaying the help menu.
|
* 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;
|
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
|
<Box
|
||||||
data-testid="left-panel-desktop"
|
|
||||||
$css={css`
|
$css={css`
|
||||||
height: calc(100vh - ${HEADER_HEIGHT}px);
|
flex: 0 0 auto;
|
||||||
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
|
<LeftPanelHeader />
|
||||||
$css={css`
|
|
||||||
flex: 0 0 auto;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<LeftPanelHeader />
|
|
||||||
</Box>
|
|
||||||
<LeftPanelContent />
|
|
||||||
{showHelpMenu && (
|
|
||||||
<SeparatedSection showSeparator={false}>
|
|
||||||
<Box $padding={{ horizontal: 'sm' }} $justify="flex-start">
|
|
||||||
<HelpMenu />
|
|
||||||
</Box>
|
|
||||||
</SeparatedSection>
|
|
||||||
)}
|
|
||||||
</Box>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{isPanelOpenState && <MobileLeftPanelStyle />}
|
{isPanelOpenMobile && <MobileLeftPanelStyle />}
|
||||||
<Box
|
<Box
|
||||||
$hasTransition
|
$hasTransition
|
||||||
$height="100vh"
|
$height="100vh"
|
||||||
@@ -81,7 +96,7 @@ export const LeftPanel = () => {
|
|||||||
height: calc(100dvh - 52px);
|
height: calc(100dvh - 52px);
|
||||||
border-right: 1px solid var(--c--globals--colors--gray-200);
|
border-right: 1px solid var(--c--globals--colors--gray-200);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
transform: translateX(${isPanelOpenState ? '0' : '-100dvw'});
|
transform: translateX(${isPanelOpenMobile ? '0' : '-100dvw'});
|
||||||
background-color: var(--c--contextuals--background--surface--primary);
|
background-color: var(--c--contextuals--background--surface--primary);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@@ -18,8 +18,46 @@ export const LeftPanelCollapseButton = () => {
|
|||||||
const { isPanelOpen, togglePanel } = useLeftPanelStore();
|
const { isPanelOpen, togglePanel } = useLeftPanelStore();
|
||||||
const { currentDoc } = useDocStore();
|
const { currentDoc } = useDocStore();
|
||||||
const [isDocTitleVisible, setIsDocTitleVisible] = useState(true);
|
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(() => {
|
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 mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
||||||
const docTitleEl = document.querySelector(`.${CLASS_DOC_TITLE}`);
|
const docTitleEl = document.querySelector(`.${CLASS_DOC_TITLE}`);
|
||||||
|
|
||||||
@@ -43,7 +81,7 @@ export const LeftPanelCollapseButton = () => {
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
setIsDocTitleVisible(true);
|
setIsDocTitleVisible(true);
|
||||||
};
|
};
|
||||||
}, [currentDoc?.id]);
|
}, [isDocTitleInDom]);
|
||||||
|
|
||||||
const { untitledDocument } = useTrans();
|
const { untitledDocument } = useTrans();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useRouter } from 'next/router';
|
|||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
import { Box, SeparatedSection } from '@/components';
|
import { Box, SeparatedSection } from '@/components';
|
||||||
import { useDocStore } from '@/docs/doc-management';
|
|
||||||
|
|
||||||
import { LeftPanelTargetFilters } from './LefPanelTargetFilters';
|
import { LeftPanelTargetFilters } from './LefPanelTargetFilters';
|
||||||
import { LeftPanelDocContent } from './LeftPanelDocContent';
|
import { LeftPanelDocContent } from './LeftPanelDocContent';
|
||||||
@@ -12,33 +11,35 @@ export const LeftPanelContent = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isHome = router.pathname === '/';
|
const isHome = router.pathname === '/';
|
||||||
const isDoc = router.pathname === '/docs/[id]';
|
const isDoc = router.pathname === '/docs/[id]';
|
||||||
const { currentDoc } = useDocStore();
|
|
||||||
|
|
||||||
return (
|
if (isHome) {
|
||||||
<>
|
return (
|
||||||
{isHome && (
|
<>
|
||||||
<>
|
<Box
|
||||||
<Box
|
$width="100%"
|
||||||
$width="100%"
|
$css={css`
|
||||||
$css={css`
|
flex: 0 0 auto;
|
||||||
flex: 0 0 auto;
|
`}
|
||||||
`}
|
className="--docs--home-left-panel-content"
|
||||||
className="--docs--home-left-panel-content"
|
>
|
||||||
>
|
<SeparatedSection showSeparator={false}>
|
||||||
<SeparatedSection showSeparator={false}>
|
<LeftPanelTargetFilters />
|
||||||
<LeftPanelTargetFilters />
|
</SeparatedSection>
|
||||||
</SeparatedSection>
|
</Box>
|
||||||
</Box>
|
<Box
|
||||||
<Box
|
$flex={1}
|
||||||
$flex={1}
|
$width="100%"
|
||||||
$width="100%"
|
$css="overflow-y: auto; overflow-x: hidden;"
|
||||||
$css="overflow-y: auto; overflow-x: hidden;"
|
>
|
||||||
>
|
<LeftPanelFavorites />
|
||||||
<LeftPanelFavorites />
|
</Box>
|
||||||
</Box>
|
</>
|
||||||
</>
|
);
|
||||||
)}
|
}
|
||||||
{isDoc && currentDoc && <LeftPanelDocContent doc={currentDoc} />}
|
|
||||||
</>
|
if (isDoc) {
|
||||||
);
|
return <LeftPanelDocContent />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
import { Doc } from '@/docs/doc-management';
|
import { Doc, useDocStore } from '@/docs/doc-management';
|
||||||
import { DocTree } from '@/docs/doc-tree/';
|
import { DocTree } from '@/docs/doc-tree/';
|
||||||
|
import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton';
|
||||||
|
|
||||||
export const LeftPanelDocContent = ({ doc }: { doc: Doc }) => {
|
export const LeftPanelDocContent = () => {
|
||||||
const tree = useTreeContext<Doc>();
|
const tree = useTreeContext<Doc>();
|
||||||
|
const { currentDoc } = useDocStore();
|
||||||
if (!tree) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -18,7 +16,11 @@ export const LeftPanelDocContent = ({ doc }: { doc: Doc }) => {
|
|||||||
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
|
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
|
||||||
className="--docs--left-panel-doc-content"
|
className="--docs--left-panel-doc-content"
|
||||||
>
|
>
|
||||||
<DocTree currentDoc={doc} />
|
{tree && currentDoc ? (
|
||||||
|
<DocTree currentDoc={currentDoc} />
|
||||||
|
) : (
|
||||||
|
<TreeSkeleton />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('@/../package.json', () => ({
|
|
||||||
default: { version: '0.0.0' },
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('DocsDB', () => {
|
describe('DocsDB', () => {
|
||||||
afterEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,17 +15,16 @@ describe('DocsDB', () => {
|
|||||||
{ version: '3.0.0', expected: 3000000 },
|
{ version: '3.0.0', expected: 3000000 },
|
||||||
{ version: '10.20.30', expected: 10020030 },
|
{ version: '10.20.30', expected: 10020030 },
|
||||||
].forEach(({ version, expected }) => {
|
].forEach(({ version, expected }) => {
|
||||||
it(`correctly computes version for ${version}`, () => {
|
it(`correctly computes version for ${version}`, async () => {
|
||||||
vi.doMock('@/../package.json', () => ({
|
vi.doMock('@/../package.json', () => ({
|
||||||
default: { version },
|
default: { version },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return vi.importActual('../DocsDB').then((module: any) => {
|
const module = await import('../DocsDB');
|
||||||
const result = module.getCurrentVersion();
|
const result = (module as any).getCurrentVersion();
|
||||||
expect(result).toBe(expected);
|
expect(result).toBe(expected);
|
||||||
expect(result).toBeGreaterThan(previousExpected);
|
expect(result).toBeGreaterThan(previousExpected);
|
||||||
previousExpected = result;
|
previousExpected = result;
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,69 +1,12 @@
|
|||||||
import { css, keyframes } from 'styled-components';
|
import { Box } from '@/components';
|
||||||
|
|
||||||
import { Box, BoxType } from '@/components';
|
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
const shimmer = keyframes`
|
import { SkeletonCircle, SkeletonLine } from './SkeletionUI';
|
||||||
0% {
|
|
||||||
background-position: -1000px 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 1000px 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
type SkeletonLineProps = Partial<BoxType>;
|
|
||||||
|
|
||||||
type SkeletonCircleProps = Partial<BoxType>;
|
|
||||||
|
|
||||||
export const DocEditorSkeleton = () => {
|
export const DocEditorSkeleton = () => {
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
|
|
||||||
const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
$width="100%"
|
|
||||||
$height="16px"
|
|
||||||
$css={css`
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
${colorsTokens['black-050']} 0%,
|
|
||||||
${colorsTokens['black-100']} 50%,
|
|
||||||
${colorsTokens['black-050']} 100%
|
|
||||||
);
|
|
||||||
background-size: 1000px 100%;
|
|
||||||
animation: ${shimmer} 2s infinite linear;
|
|
||||||
border-radius: 4px;
|
|
||||||
${$css}
|
|
||||||
`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
$width="32px"
|
|
||||||
$height="32px"
|
|
||||||
$css={css`
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
${colorsTokens['black-050']} 0%,
|
|
||||||
${colorsTokens['black-100']} 50%,
|
|
||||||
${colorsTokens['black-050']} 100%
|
|
||||||
);
|
|
||||||
background-size: 1000px 100%;
|
|
||||||
animation: ${shimmer} 2s infinite linear;
|
|
||||||
border-radius: 50%;
|
|
||||||
${$css}
|
|
||||||
`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { css, keyframes } from 'styled-components';
|
||||||
|
|
||||||
|
import { Box, BoxType } from '@/components/Box';
|
||||||
|
import { useCunninghamTheme } from '@/cunningham/useCunninghamTheme';
|
||||||
|
|
||||||
|
const shimmer = keyframes`
|
||||||
|
0% {
|
||||||
|
background-position: -1000px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 1000px 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type SkeletonLineProps = Partial<BoxType>;
|
||||||
|
|
||||||
|
type SkeletonCircleProps = Partial<BoxType>;
|
||||||
|
|
||||||
|
export const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
|
||||||
|
const { colorsTokens } = useCunninghamTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
$width="100%"
|
||||||
|
$height="16px"
|
||||||
|
$css={css`
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
${colorsTokens['black-050']} 0%,
|
||||||
|
${colorsTokens['black-100']} 50%,
|
||||||
|
${colorsTokens['black-050']} 100%
|
||||||
|
);
|
||||||
|
background-size: 1000px 100%;
|
||||||
|
animation: ${shimmer} 2s infinite linear;
|
||||||
|
border-radius: 4px;
|
||||||
|
${$css}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
|
||||||
|
const { colorsTokens } = useCunninghamTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
$width="32px"
|
||||||
|
$height="32px"
|
||||||
|
$css={css`
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
${colorsTokens['black-050']} 0%,
|
||||||
|
${colorsTokens['black-100']} 50%,
|
||||||
|
${colorsTokens['black-050']} 100%
|
||||||
|
);
|
||||||
|
background-size: 1000px 100%;
|
||||||
|
animation: ${shimmer} 2s infinite linear;
|
||||||
|
border-radius: 50%;
|
||||||
|
${$css}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Box } from '@/components';
|
||||||
|
|
||||||
|
import { SkeletonLine } from './SkeletionUI';
|
||||||
|
|
||||||
|
export const TreeSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Box className="--docs--tree-skeleton">
|
||||||
|
<SkeletonLine
|
||||||
|
$width="92%"
|
||||||
|
$height="40px"
|
||||||
|
$margin={{ left: 'sm', top: 'sm' }}
|
||||||
|
/>
|
||||||
|
<SkeletonLine
|
||||||
|
$width="92%"
|
||||||
|
$height="30px"
|
||||||
|
$margin={{ left: 'sm', top: 'sm' }}
|
||||||
|
/>
|
||||||
|
<SkeletonLine
|
||||||
|
$width="92%"
|
||||||
|
$height="30px"
|
||||||
|
$margin={{ left: 'sm', top: 'sm' }}
|
||||||
|
/>
|
||||||
|
<SkeletonLine
|
||||||
|
$width="92%"
|
||||||
|
$height="30px"
|
||||||
|
$margin={{ left: 'sm', top: 'sm' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
VariantType,
|
VariantType,
|
||||||
useToastProvider,
|
useToastProvider,
|
||||||
} from '@gouvfr-lasuite/cunningham-react';
|
} from '@gouvfr-lasuite/cunningham-react';
|
||||||
import { announce } from '@react-aria/live-announcer';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -19,14 +18,12 @@ export const useClipboard = () => {
|
|||||||
toast(message, VariantType.SUCCESS, {
|
toast(message, VariantType.SUCCESS, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
announce(message, 'polite');
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
const message = errorMessage ?? t('Failed to copy to clipboard');
|
const message = errorMessage ?? t('Failed to copy to clipboard');
|
||||||
toast(message, VariantType.ERROR, {
|
toast(message, VariantType.ERROR, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
announce(message, 'assertive');
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t, toast],
|
[t, toast],
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const useDate = () => {
|
|||||||
.toLocaleString(format);
|
.toLocaleString(format);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDateSpecial = (date: string, format: string): string => {
|
||||||
|
return DateTime.fromISO(date).setLocale(i18n.language).toFormat(format);
|
||||||
|
};
|
||||||
|
|
||||||
const relativeDate = (date: string): string => {
|
const relativeDate = (date: string): string => {
|
||||||
const dateToCompare = DateTime.fromISO(date);
|
const dateToCompare = DateTime.fromISO(date);
|
||||||
|
|
||||||
@@ -45,5 +49,5 @@ export const useDate = () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { formatDate, relativeDate, calculateDaysLeft };
|
return { formatDate, formatDateSpecial, relativeDate, calculateDaysLeft };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
import {
|
||||||
|
focusMainContentStart,
|
||||||
|
getMainContentFocusTarget,
|
||||||
|
} from '@/layouts/utils';
|
||||||
|
|
||||||
export const useRouteChangeCompleteFocus = () => {
|
export const useRouteChangeCompleteFocus = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -25,27 +28,24 @@ export const useRouteChangeCompleteFocus = () => {
|
|||||||
lastCompletedPathRef.current = normalizedUrl;
|
lastCompletedPathRef.current = normalizedUrl;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const mainContent =
|
const focusTarget = getMainContentFocusTarget();
|
||||||
document.getElementById(MAIN_LAYOUT_ID) ??
|
|
||||||
document.getElementsByTagName('main')[0];
|
|
||||||
|
|
||||||
if (!mainContent) {
|
if (!focusTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstHeading = mainContent.querySelector('h1, h2, h3');
|
|
||||||
const prefersReducedMotion = window.matchMedia(
|
const prefersReducedMotion = window.matchMedia(
|
||||||
'(prefers-reduced-motion: reduce)',
|
'(prefers-reduced-motion: reduce)',
|
||||||
).matches;
|
).matches;
|
||||||
|
|
||||||
if (isKeyboardNavigationRef.current) {
|
if (isKeyboardNavigationRef.current) {
|
||||||
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
|
focusMainContentStart({ preventScroll: true });
|
||||||
isKeyboardNavigationRef.current = false;
|
isKeyboardNavigationRef.current = false;
|
||||||
}
|
}
|
||||||
if (router.pathname === '/docs/[id]') {
|
if (router.pathname === '/docs/[id]') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(firstHeading ?? mainContent)?.scrollIntoView({
|
focusTarget.scrollIntoView({
|
||||||
behavior: prefersReducedMotion ? 'auto' : 'smooth',
|
behavior: prefersReducedMotion ? 'auto' : 'smooth',
|
||||||
block: 'start',
|
block: 'start',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -905,7 +905,7 @@
|
|||||||
"Open Source": "Open Source",
|
"Open Source": "Open Source",
|
||||||
"Open document {{title}}": "Ouvrir le document {{title}}",
|
"Open document {{title}}": "Ouvrir le document {{title}}",
|
||||||
"Open document: {{title}}": "Ouvrir le document : {{title}}",
|
"Open document: {{title}}": "Ouvrir le document : {{title}}",
|
||||||
"Open onboarding menu": "Ouvrir le menu d'embarquement",
|
"Open help menu": "Ouvrir le menu d'aide",
|
||||||
"Open root document": "Ouvrir le document racine",
|
"Open root document": "Ouvrir le document racine",
|
||||||
"Open the document options": "Ouvrir les options du document",
|
"Open the document options": "Ouvrir les options du document",
|
||||||
"Open the menu of actions for the document: {{title}}": "Ouvrir le menu des actions du document : {{title}}",
|
"Open the menu of actions for the document: {{title}}": "Ouvrir le menu des actions du document : {{title}}",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
|
||||||
import { Header } from '@/features/header';
|
import { Header } from '@/features/header';
|
||||||
import { HEADER_HEIGHT } from '@/features/header/conf';
|
import { HEADER_HEIGHT } from '@/features/header/conf';
|
||||||
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
||||||
@@ -94,7 +93,6 @@ const MainContent = ({
|
|||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { colorsTokens } = useCunninghamTheme();
|
|
||||||
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
|
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -103,7 +101,6 @@ const MainContent = ({
|
|||||||
role="main"
|
role="main"
|
||||||
aria-label={t('Main content')}
|
aria-label={t('Main content')}
|
||||||
id={MAIN_LAYOUT_ID}
|
id={MAIN_LAYOUT_ID}
|
||||||
tabIndex={-1}
|
|
||||||
$align="center"
|
$align="center"
|
||||||
$flex={1}
|
$flex={1}
|
||||||
$width="100%"
|
$width="100%"
|
||||||
@@ -120,14 +117,6 @@ const MainContent = ({
|
|||||||
$css={css`
|
$css={css`
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
&:focus-visible::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border: 3px solid ${colorsTokens['brand-400']};
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2001;
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Skeleton>
|
<Skeleton>
|
||||||
|
|||||||
37
src/frontend/apps/impress/src/layouts/utils.ts
Normal file
37
src/frontend/apps/impress/src/layouts/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { MAIN_LAYOUT_ID } from './conf';
|
||||||
|
|
||||||
|
export const getMainContentElement = (): HTMLElement | null =>
|
||||||
|
document.getElementById(MAIN_LAYOUT_ID) ??
|
||||||
|
document.getElementsByTagName('main')[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
export const getMainContentFocusTarget = (): HTMLElement | null => {
|
||||||
|
const mainContent = getMainContentElement();
|
||||||
|
|
||||||
|
if (!mainContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstHeading =
|
||||||
|
mainContent.querySelector('h1') ?? mainContent.querySelector('h2');
|
||||||
|
|
||||||
|
return firstHeading instanceof HTMLElement ? firstHeading : mainContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const focusMainContentStart = (
|
||||||
|
options?: FocusOptions,
|
||||||
|
): HTMLElement | null => {
|
||||||
|
const focusTarget = getMainContentFocusTarget();
|
||||||
|
|
||||||
|
if (!focusTarget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!focusTarget.hasAttribute('tabindex')) {
|
||||||
|
focusTarget.setAttribute('tabindex', '-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
focusTarget.focus(options);
|
||||||
|
|
||||||
|
return focusTarget;
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
useTrans,
|
useTrans,
|
||||||
} from '@/docs/doc-management/';
|
} from '@/docs/doc-management/';
|
||||||
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
|
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 { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
|
||||||
import { DocEditorSkeleton, useSkeletonStore } from '@/features/skeletons';
|
import { DocEditorSkeleton, useSkeletonStore } from '@/features/skeletons';
|
||||||
import { MainLayout } from '@/layouts';
|
import { MainLayout } from '@/layouts';
|
||||||
@@ -60,6 +61,7 @@ export function DocLayout() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MainLayout enableResizablePanel={true}>
|
<MainLayout enableResizablePanel={true}>
|
||||||
|
<FloatingBar />
|
||||||
<DocPage id={id} />
|
<DocPage id={id} />
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
</TreeProvider>
|
</TreeProvider>
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [react()],
|
||||||
react(),
|
|
||||||
tsconfigPaths({
|
|
||||||
root: '.',
|
|
||||||
projects: ['./tsconfig.json'],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
@@ -22,4 +15,7 @@ export default defineConfig({
|
|||||||
define: {
|
define: {
|
||||||
'process.env.NODE_ENV': 'test',
|
'process.env.NODE_ENV': 'test',
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
tsconfigPaths: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "impress",
|
"name": "impress",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": "https://github.com/suitenumerique/docs",
|
"repository": "https://github.com/suitenumerique/docs",
|
||||||
"author": "DINUM",
|
"author": "DINUM",
|
||||||
@@ -32,17 +32,17 @@
|
|||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@tiptap/extensions": "3.19.0",
|
"@tiptap/extensions": "3.19.0",
|
||||||
"@types/node": "24.10.13",
|
"@types/node": "24.12.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"eslint": "10.0.1",
|
"eslint": "10.0.3",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"prosemirror-view": "1.41.6",
|
"prosemirror-view": "1.41.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"wrap-ansi": "9.0.2",
|
"wrap-ansi": "10.0.0",
|
||||||
"yjs": "13.6.29"
|
"yjs": "13.6.30"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@1.22.22"
|
"packageManager": "yarn@1.22.22"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const js = require('@eslint/js');
|
|||||||
const nextPlugin = require('@next/eslint-plugin-next');
|
const nextPlugin = require('@next/eslint-plugin-next');
|
||||||
const tanstackQuery = require('@tanstack/eslint-plugin-query');
|
const tanstackQuery = require('@tanstack/eslint-plugin-query');
|
||||||
const { defineConfig } = require('eslint/config');
|
const { defineConfig } = require('eslint/config');
|
||||||
const importPlugin = require('eslint-plugin-import');
|
const importPlugin = require('eslint-plugin-import-x');
|
||||||
const jsxA11y = require('eslint-plugin-jsx-a11y');
|
const jsxA11y = require('eslint-plugin-jsx-a11y');
|
||||||
const prettier = require('eslint-plugin-prettier');
|
const prettier = require('eslint-plugin-prettier');
|
||||||
const react = require('eslint-plugin-react');
|
const react = require('eslint-plugin-react');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "eslint-plugin-docs",
|
"name": "eslint-plugin-docs",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"repository": "https://github.com/suitenumerique/docs",
|
"repository": "https://github.com/suitenumerique/docs",
|
||||||
"author": "DINUM",
|
"author": "DINUM",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -18,22 +18,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@next/eslint-plugin-next": "16.1.6",
|
"@next/eslint-plugin-next": "16.1.7",
|
||||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
"@tanstack/eslint-plugin-query": "5.91.4",
|
||||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||||
"@typescript-eslint/parser": "8.56.0",
|
"@typescript-eslint/parser": "8.57.1",
|
||||||
"@typescript-eslint/utils": "8.56.0",
|
"@typescript-eslint/utils": "8.57.1",
|
||||||
"@vitest/eslint-plugin": "1.6.9",
|
"@vitest/eslint-plugin": "1.6.12",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.7",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-import": "2.32.0",
|
"eslint-plugin-import-x": "4.16.2",
|
||||||
"eslint-plugin-jest": "29.15.0",
|
"eslint-plugin-jest": "29.15.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-playwright": "2.5.1",
|
"eslint-plugin-playwright": "2.10.0",
|
||||||
"eslint-plugin-prettier": "5.5.5",
|
"eslint-plugin-prettier": "5.5.5",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "7.0.1",
|
||||||
"eslint-plugin-testing-library": "7.15.4",
|
"eslint-plugin-testing-library": "7.16.0",
|
||||||
"prettier": "3.8.1"
|
"prettier": "3.8.1"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@1.22.22"
|
"packageManager": "yarn@1.22.22"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "packages-i18n",
|
"name": "packages-i18n",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"repository": "https://github.com/suitenumerique/docs",
|
"repository": "https://github.com/suitenumerique/docs",
|
||||||
"author": "DINUM",
|
"author": "DINUM",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"eslint-plugin-docs": "*",
|
"eslint-plugin-docs": "*",
|
||||||
"eslint-plugin-import": "2.32.0",
|
"eslint-plugin-import": "2.32.0",
|
||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.4.0",
|
||||||
"jest": "30.2.0",
|
"jest": "30.3.0",
|
||||||
"ts-jest": "29.4.6",
|
"ts-jest": "29.4.6",
|
||||||
"typescript": "*",
|
"typescript": "*",
|
||||||
"yargs": "18.0.0"
|
"yargs": "18.0.0"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user