Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef1679968d | |||
| a42c35dda0 | |||
| b2192b9594 | |||
| 75c649be39 | |||
| 03fd1fe50e | |||
| fc803226ac | |||
| fb725edda3 | |||
| 6838b387a2 | |||
| 87f570582f | |||
| 37f56fcc22 | |||
| 19aa3a36bc |
+1
-4
@@ -6,13 +6,10 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add a public_search API view to the Document viewset #2068
|
||||
|
||||
### Changed
|
||||
|
||||
- 💄(frontend) improve comments highlights #1961
|
||||
- ♿️(frontend) fix empty heading before section titles in HTML export #2125
|
||||
|
||||
## [v4.8.3] - 2026-03-23
|
||||
|
||||
|
||||
@@ -60,10 +60,13 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"@react-pdf/renderer",
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"react-resizable-panels",
|
||||
"stylelint",
|
||||
"stylelint-config-standard",
|
||||
"workbox-webpack-plugin"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -512,9 +512,6 @@ class DocumentViewSet(
|
||||
15. **AI Proxy**: Proxy an AI request to an external AI service.
|
||||
Example: POST /api/v1.0/documents/<resource_id>/ai-proxy
|
||||
|
||||
13. **Public Search**: Search within a public document and the related tree.
|
||||
Example: GET /documents/{id}/public_search/?q=search_text
|
||||
|
||||
### Ordering: created_at, updated_at, is_favorite, title
|
||||
|
||||
Example:
|
||||
@@ -1532,43 +1529,6 @@ class DocumentViewSet(
|
||||
queryset = filterset.qs
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="public_search")
|
||||
def public_search(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list
|
||||
for public search on the tree of a given public document.
|
||||
|
||||
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
|
||||
|
||||
The filtering is done on the model field 'title', there is no full text search.
|
||||
|
||||
The ordering is always by the most recent first.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||
params.is_valid(raise_exception=True)
|
||||
text = params.validated_data["q"]
|
||||
|
||||
public_root = document.get_highest_public_ancestor()
|
||||
|
||||
# We limit the queryset to the current public tree, filtering out deleted documents.
|
||||
queryset = public_root.get_descendants(include_self=True).filter(
|
||||
ancestors_deleted_at__isnull=True
|
||||
)
|
||||
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset)
|
||||
queryset = queryset.filter(ancestors_deleted_at__isnull=True)
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset.order_by("-updated_at"),
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -1019,22 +1019,6 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
self._content = content
|
||||
|
||||
def get_highest_public_ancestor(self):
|
||||
"""
|
||||
Get the highest ancestor of the document that has a public link reach.
|
||||
If the document itself has a public link reach, it will be returned.
|
||||
If there is no public ancestor, None will be returned.
|
||||
"""
|
||||
if self.link_reach == LinkReachChoices.PUBLIC:
|
||||
return self
|
||||
|
||||
return (
|
||||
self.get_ancestors()
|
||||
.filter(link_reach=LinkReachChoices.PUBLIC)
|
||||
.order_by("-path")
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_content_response(self, version_id=""):
|
||||
"""Get the content in a specific version of the document"""
|
||||
params = {
|
||||
@@ -1300,8 +1284,6 @@ class Document(MP_Node, BaseModel):
|
||||
else (is_owner_or_admin or (user.is_authenticated and self.creator == user))
|
||||
) and not is_deleted
|
||||
|
||||
is_public = link_reach == LinkReachChoices.PUBLIC
|
||||
|
||||
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
|
||||
ai_access = any(
|
||||
[
|
||||
@@ -1338,7 +1320,6 @@ class Document(MP_Node, BaseModel):
|
||||
"mask": can_get and user.is_authenticated,
|
||||
"move": is_owner_or_admin and not is_deleted,
|
||||
"partial_update": can_update,
|
||||
"public_search": is_public and not is_deleted,
|
||||
"restore": is_owner,
|
||||
"retrieve": retrieve,
|
||||
"media_auth": can_get,
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint: public_search action.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_public_search_missing_q():
|
||||
"""Missing `q` param should return 400."""
|
||||
client = APIClient()
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/public_search/",
|
||||
data={},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
|
||||
def test_api_documents_public_search_blank_q():
|
||||
"""Blank `q` param should return all documents in the public tree."""
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/public_search/",
|
||||
data={"q": " "},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result_ids = {r["id"] for r in response.json()["results"]}
|
||||
assert len(result_ids) == 2
|
||||
assert str(document.id) in result_ids
|
||||
assert str(child.id) in result_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_api_documents_public_search_anonymous_on_public_document_tree():
|
||||
"""Anonymous users can search within a public document's tree."""
|
||||
client = APIClient()
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
match = factories.DocumentFactory(parent=document, title="match me")
|
||||
no_match = factories.DocumentFactory(parent=document, title="don't find me")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/public_search/",
|
||||
data={"q": "match"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
result_ids = {r["id"] for r in response.json()["results"]}
|
||||
assert len(result_ids) == 1
|
||||
assert str(match.id) in result_ids
|
||||
assert str(no_match.id) not in result_ids
|
||||
|
||||
|
||||
def test_api_documents_public_search_anonymous_on_restricted_document():
|
||||
"""Anonymous users cannot search on a restricted document."""
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/public_search/",
|
||||
data={"q": "anything"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_public_search_anonymous_on_authenticated_document():
|
||||
"""Anonymous users cannot search on an authenticated-only document."""
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/public_search/",
|
||||
data={"q": "anything"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_public_search_authenticated_on_restricted_document():
|
||||
"""Authenticated users cannot search on a restricted document they don't own."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/public_search/",
|
||||
data={"q": "anything"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_public_search_authenticated_on_authenticated_document():
|
||||
"""Authenticated users cannot search on a authenticated document they don't own."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/public_search/",
|
||||
data={"q": "anything"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public via ancestor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_api_documents_public_search_document_public_via_ancestor():
|
||||
"""
|
||||
A restricted child document whose ancestor is public is effectively public.
|
||||
The search scope should be rooted at the highest public ancestor.
|
||||
"""
|
||||
client = APIClient()
|
||||
|
||||
root = factories.DocumentFactory(link_reach="public", title="root")
|
||||
child = factories.DocumentFactory(
|
||||
parent=root, link_reach="restricted", title="child alpha"
|
||||
)
|
||||
sibling = factories.DocumentFactory(parent=root, title="sibling alpha")
|
||||
grand_child = factories.DocumentFactory(parent=child, title="grand alpha")
|
||||
|
||||
# child is public via root
|
||||
assert child.computed_link_reach == models.LinkReachChoices.PUBLIC
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{child.id}/public_search/",
|
||||
data={"q": "alpha"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.json()
|
||||
result_ids = {r["id"] for r in content["results"]}
|
||||
|
||||
# All descendants of root that match "alpha" should be returned
|
||||
assert len(result_ids) == 3
|
||||
assert str(child.id) in result_ids
|
||||
assert str(sibling.id) in result_ids
|
||||
assert str(grand_child.id) in result_ids
|
||||
|
||||
|
||||
def test_api_documents_public_search_scope_limited_to_public_tree():
|
||||
"""
|
||||
Documents outside the public tree should not appear in results, even if they
|
||||
match the query.
|
||||
"""
|
||||
client = APIClient()
|
||||
|
||||
private_root = factories.DocumentFactory(
|
||||
link_reach="restricted", title="private root"
|
||||
)
|
||||
public_doc = factories.DocumentFactory(
|
||||
parent=private_root, link_reach="public", title="public doc"
|
||||
)
|
||||
inside = factories.DocumentFactory(parent=public_doc, title="alpha inside")
|
||||
|
||||
# Separate tree — should never appear
|
||||
other_root = factories.DocumentFactory(link_reach="public", title="other root")
|
||||
outside = factories.DocumentFactory(parent=other_root, title="alpha outside")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{public_doc.id}/public_search/",
|
||||
data={"q": "alpha"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
result_ids = {r["id"] for r in response.json()["results"]}
|
||||
assert len(result_ids) == 1
|
||||
assert str(inside.id) in result_ids
|
||||
assert str(outside.id) not in result_ids
|
||||
|
||||
|
||||
def test_api_documents_public_search_excludes_deleted_documents():
|
||||
"""Soft-deleted documents should not appear in results."""
|
||||
client = APIClient()
|
||||
root = factories.DocumentFactory(link_reach="public")
|
||||
alive = factories.DocumentFactory(parent=root, title="alive alpha")
|
||||
deleted = factories.DocumentFactory(
|
||||
parent=root,
|
||||
title="deleted alpha",
|
||||
deleted_at="2024-01-01T00:00:00Z",
|
||||
ancestors_deleted_at="2024-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{root.id}/public_search/",
|
||||
data={"q": "alpha"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
result_ids = {r["id"] for r in response.json()["results"]}
|
||||
assert len(result_ids) == 1
|
||||
assert str(alive.id) in result_ids
|
||||
assert str(deleted.id) not in result_ids
|
||||
|
||||
|
||||
def test_api_documents_public_search_excludes_documents_with_deleted_ancestor():
|
||||
"""Documents whose ancestor is deleted should not appear in results."""
|
||||
client = APIClient()
|
||||
root = factories.DocumentFactory(link_reach="public")
|
||||
deleted_parent = factories.DocumentFactory(
|
||||
parent=root,
|
||||
title="deleted parent",
|
||||
deleted_at="2024-01-01T00:00:00Z",
|
||||
ancestors_deleted_at="2024-01-01T00:00:00Z",
|
||||
)
|
||||
orphan = factories.DocumentFactory(
|
||||
parent=deleted_parent,
|
||||
title="orphan alpha",
|
||||
ancestors_deleted_at="2024-01-01T00:00:00Z",
|
||||
)
|
||||
alive = factories.DocumentFactory(parent=root, title="alive alpha")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{root.id}/public_search/",
|
||||
data={"q": "alpha"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
result_ids = {r["id"] for r in response.json()["results"]}
|
||||
assert len(result_ids) == 1
|
||||
assert str(alive.id) in result_ids
|
||||
assert str(orphan.id) not in result_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_api_documents_public_search_ordered_by_most_recent_first():
|
||||
"""Results should be ordered by -updated_at."""
|
||||
client = APIClient()
|
||||
|
||||
root_doc = factories.DocumentFactory(link_reach="public")
|
||||
old = factories.DocumentFactory(parent=root_doc, title="old alpha")
|
||||
new = factories.DocumentFactory(parent=root_doc, title="new alpha")
|
||||
|
||||
# Force updated_at ordering
|
||||
models.Document.objects.filter(pk=old.pk).update(
|
||||
updated_at=timezone.now() - datetime.timedelta(days=10)
|
||||
)
|
||||
models.Document.objects.filter(pk=new.pk).update(updated_at=timezone.now())
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{root_doc.id}/public_search/",
|
||||
data={"q": "alpha"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
result_ids = [r["id"] for r in response.json()["results"]]
|
||||
assert len(result_ids) == 2
|
||||
assert result_ids.index(str(new.id)) < result_ids.index(str(old.id))
|
||||
@@ -57,7 +57,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"public_search": document.link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
@@ -136,7 +135,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"public_search": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
@@ -248,7 +246,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"public_search": document.link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
@@ -334,7 +331,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"public_search": grand_parent.link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
@@ -535,7 +531,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"media_check": True,
|
||||
"move": access.role in ["administrator", "owner"],
|
||||
"partial_update": access.role not in ["reader", "commenter"],
|
||||
"public_search": document.computed_link_reach == "public",
|
||||
"restore": access.role == "owner",
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
|
||||
@@ -99,7 +99,6 @@ def test_api_documents_trashbin_format():
|
||||
"media_check": False,
|
||||
"move": False, # Can't move a deleted document
|
||||
"partial_update": False,
|
||||
"public_search": False,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"search": False,
|
||||
|
||||
@@ -182,7 +182,6 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"restricted": None,
|
||||
},
|
||||
"partial_update": False,
|
||||
"public_search": document.computed_link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": False,
|
||||
"tree": False,
|
||||
@@ -250,7 +249,6 @@ def test_models_documents_get_abilities_reader(
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"public_search": reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -323,7 +321,6 @@ def test_models_documents_get_abilities_commenter(
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"public_search": reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -393,7 +390,6 @@ def test_models_documents_get_abilities_editor(
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": True,
|
||||
"public_search": reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -452,7 +448,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"media_check": True,
|
||||
"move": True,
|
||||
"partial_update": True,
|
||||
"public_search": document.computed_link_reach == "public",
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -497,7 +492,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"media_check": False,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"public_search": document.computed_link_reach == "public",
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -546,7 +540,6 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"media_check": True,
|
||||
"move": True,
|
||||
"partial_update": True,
|
||||
"public_search": document.computed_link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -605,7 +598,6 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": True,
|
||||
"public_search": document.computed_link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -672,7 +664,6 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": access_from_link,
|
||||
"public_search": document.computed_link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -740,7 +731,6 @@ def test_models_documents_get_abilities_commenter_user(
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": access_from_link,
|
||||
"public_search": document.computed_link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -804,7 +794,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"public_search": access.document.computed_link_reach == "public",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
@@ -1702,74 +1691,3 @@ def test_models_documents_compute_ancestors_links_paths_mapping_structure(
|
||||
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# get_highest_public_ancestor method
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_root_public():
|
||||
"""A root document with public link reach should return itself."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
assert document.get_highest_public_ancestor() == document
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_root_restricted():
|
||||
"""A root document with restricted link reach should return None."""
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
assert document.get_highest_public_ancestor() is None
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_root_authenticated():
|
||||
"""A root document with authenticated link reach should return None."""
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
assert document.get_highest_public_ancestor() is None
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_child_is_public():
|
||||
"""A child document that is itself public should return itself, not the parent."""
|
||||
parent = factories.DocumentFactory(link_reach="restricted")
|
||||
child = factories.DocumentFactory(parent=parent, link_reach="public")
|
||||
assert child.get_highest_public_ancestor() == child
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_parent_is_public():
|
||||
"""A child with restricted reach whose parent is public should return the parent."""
|
||||
parent = factories.DocumentFactory(link_reach="public")
|
||||
child = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
assert child.get_highest_public_ancestor() == parent
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_neither_public():
|
||||
"""A child with restricted reach and a restricted parent should return None."""
|
||||
parent = factories.DocumentFactory(link_reach="restricted")
|
||||
child = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
assert child.get_highest_public_ancestor() is None
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_grandparent_is_public():
|
||||
"""
|
||||
Returns the highest public ancestor (grandparent) when only the grandparent is public.
|
||||
"""
|
||||
grandparent = factories.DocumentFactory(link_reach="public")
|
||||
parent = factories.DocumentFactory(parent=grandparent, link_reach="restricted")
|
||||
child = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
assert child.get_highest_public_ancestor() == grandparent
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_deep_tree_only_middle_public():
|
||||
"""
|
||||
When only a middle ancestor is public, it is returned as the highest public ancestor.
|
||||
"""
|
||||
root_doc = factories.DocumentFactory(link_reach="restricted")
|
||||
middle = factories.DocumentFactory(parent=root_doc, link_reach="public")
|
||||
child = factories.DocumentFactory(parent=middle, link_reach="restricted")
|
||||
grandchild = factories.DocumentFactory(parent=child, link_reach="restricted")
|
||||
assert grandchild.get_highest_public_ancestor() == middle
|
||||
|
||||
|
||||
def test_models_documents_get_highest_public_ancestor_all_restricted_deep():
|
||||
"""A deeply nested document with no public ancestor should return None."""
|
||||
root_doc = factories.DocumentFactory(link_reach="restricted")
|
||||
child = factories.DocumentFactory(parent=root_doc, link_reach="restricted")
|
||||
grandchild = factories.DocumentFactory(parent=child, link_reach="restricted")
|
||||
assert grandchild.get_highest_public_ancestor() is None
|
||||
|
||||
@@ -47,9 +47,9 @@ test.describe('Doc AI feature', () => {
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Anything');
|
||||
await page.getByText('Anything').selectText();
|
||||
expect(
|
||||
await page.locator('button[data-test="convertMarkdown"]').count(),
|
||||
).toBe(1);
|
||||
await expect(
|
||||
page.locator('button[data-test="convertMarkdown"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByRole('button', { name: config.selector, exact: true }),
|
||||
).toBeHidden();
|
||||
|
||||
@@ -155,13 +155,11 @@ test.describe('Doc Editor', () => {
|
||||
expect(wsClose.isClosed()).toBeTruthy();
|
||||
|
||||
// Check the ws is connected again
|
||||
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
webSocket = await page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:4444/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
webSocket = await webSocketPromise;
|
||||
framesentPromise = webSocket.waitForEvent('framesent');
|
||||
framesent = await framesentPromise;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
@@ -578,12 +576,10 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await page.reload();
|
||||
|
||||
responseCanEditPromise = page.waitForResponse(
|
||||
responseCanEdit = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/can-edit/`) && response.status() === 200,
|
||||
);
|
||||
|
||||
responseCanEdit = await responseCanEditPromise;
|
||||
expect(responseCanEdit.ok()).toBeTruthy();
|
||||
|
||||
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
|
||||
|
||||
@@ -236,7 +236,7 @@ test.describe('Doc Header', () => {
|
||||
hasText: randomDoc,
|
||||
});
|
||||
|
||||
expect(await row.count()).toBe(0);
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('it checks the options available if administrator', async ({ page }) => {
|
||||
@@ -273,7 +273,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -348,7 +348,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -418,7 +418,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
||||
|
||||
// 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();
|
||||
|
||||
|
||||
@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
|
||||
return dt;
|
||||
}, filesData);
|
||||
|
||||
await page.dispatchEvent(selector, 'drop', { dataTransfer });
|
||||
await page.locator(selector).dispatchEvent('drop', { dataTransfer });
|
||||
};
|
||||
|
||||
@@ -43,15 +43,12 @@ test.describe('Doc Tree', () => {
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// Check the position of the sub pages
|
||||
const allSubPageItems = await docTree
|
||||
.getByTestId(/^doc-sub-page-item/)
|
||||
.all();
|
||||
|
||||
expect(allSubPageItems.length).toBe(2);
|
||||
const allSubPageItems = docTree.getByTestId(/^doc-sub-page-item/);
|
||||
await expect(allSubPageItems).toHaveCount(2);
|
||||
|
||||
// Check that elements are in the correct order
|
||||
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
|
||||
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
|
||||
await expect(allSubPageItems.nth(0).getByText('first move')).toBeVisible();
|
||||
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
|
||||
|
||||
// Will move the first sub page to the second position
|
||||
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
|
||||
@@ -91,17 +88,15 @@ test.describe('Doc Tree', () => {
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// Check that elements are in the correct order
|
||||
const allSubPageItemsAfterReload = await docTree
|
||||
.getByTestId(/^doc-sub-page-item/)
|
||||
.all();
|
||||
|
||||
expect(allSubPageItemsAfterReload.length).toBe(2);
|
||||
const allSubPageItemsAfterReload =
|
||||
docTree.getByTestId(/^doc-sub-page-item/);
|
||||
await expect(allSubPageItemsAfterReload).toHaveCount(2);
|
||||
|
||||
await expect(
|
||||
allSubPageItemsAfterReload[0].getByText('second move'),
|
||||
allSubPageItemsAfterReload.nth(0).getByText('second move'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
allSubPageItemsAfterReload[1].getByText('first move'),
|
||||
allSubPageItemsAfterReload.nth(1).getByText('first move'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -80,9 +80,9 @@ test.describe('Doc Version', () => {
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('status')).toBeHidden();
|
||||
const items = await panel.locator('.version-item').all();
|
||||
expect(items.length).toBe(2);
|
||||
await items[1].click();
|
||||
const items = panel.locator('.version-item');
|
||||
await expect(items).toHaveCount(2);
|
||||
await items.nth(1).click();
|
||||
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||
@@ -90,7 +90,7 @@ test.describe('Doc Version', () => {
|
||||
modal.locator('div[data-content-type="callout"]').first(),
|
||||
).toBeHidden();
|
||||
|
||||
await items[0].click();
|
||||
await items.nth(0).click();
|
||||
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeVisible();
|
||||
@@ -101,7 +101,7 @@ test.describe('Doc Version', () => {
|
||||
modal.getByText('It will create a second version'),
|
||||
).toBeHidden();
|
||||
|
||||
await items[1].click();
|
||||
await items.nth(1).click();
|
||||
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||
|
||||
@@ -3,6 +3,11 @@ import path from 'path';
|
||||
|
||||
import { Locator, Page, TestInfo, expect } from '@playwright/test';
|
||||
|
||||
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
|
||||
|
||||
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
|
||||
|
||||
/** Returns a locator for a menu item (handles both menuitem and menuitemradio roles) */
|
||||
export const getMenuItem = (
|
||||
context: Page | Locator,
|
||||
@@ -13,11 +18,6 @@ export const getMenuItem = (
|
||||
.getByRole('menuitem', { name, exact: options?.exact })
|
||||
.or(context.getByRole('menuitemradio', { name, exact: options?.exact }));
|
||||
|
||||
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
|
||||
|
||||
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
|
||||
|
||||
export const CONFIG = {
|
||||
AI_BOT: {
|
||||
name: 'Docs AI',
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/core": "0.47.1",
|
||||
"@blocknote/mantine": "0.47.1",
|
||||
@@ -38,20 +38,20 @@
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@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",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.2.0",
|
||||
"@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",
|
||||
"@mantine/core": "8.3.14",
|
||||
"@mantine/hooks": "8.3.14",
|
||||
"@mantine/core": "8.3.17",
|
||||
"@mantine/hooks": "8.3.17",
|
||||
"@react-aria/live-announcer": "3.4.4",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.38.0",
|
||||
"@sentry/nextjs": "10.43.0",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tiptap/extensions": "*",
|
||||
"ai": "6.0.49",
|
||||
"ai": "6.0.128",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -59,28 +59,28 @@
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.6.0",
|
||||
"i18next": "25.8.12",
|
||||
"i18next": "25.8.18",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.23",
|
||||
"luxon": "3.7.2",
|
||||
"next": "16.1.7",
|
||||
"posthog-js": "1.347.2",
|
||||
"posthog-js": "1.360.2",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.15.1",
|
||||
"react-aria-components": "1.16.0",
|
||||
"react-dom": "*",
|
||||
"react-dropzone": "15.0.0",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-intersection-observer": "10.0.2",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-intersection-observer": "10.0.3",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
"styled-components": "6.3.9",
|
||||
"styled-components": "6.3.11",
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"y-protocols": "1.0.7",
|
||||
"yjs": "*",
|
||||
"zod": "3.25.28",
|
||||
"zustand": "5.0.11"
|
||||
"zod": "4.3.6",
|
||||
"zustand": "5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
@@ -89,26 +89,25 @@
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.3.1",
|
||||
"eslint-plugin-docs": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jsdom": "28.1.0",
|
||||
"jsdom": "29.0.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.8.1",
|
||||
"stylelint": "16.26.1",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.0.18",
|
||||
"webpack": "5.105.2",
|
||||
"vitest": "4.1.0",
|
||||
"webpack": "5.105.4",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
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 { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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;
|
||||
|
||||
describe('useModuleExport', () => {
|
||||
@@ -16,12 +21,12 @@ describe('useModuleExport', () => {
|
||||
const Export = await import('@/features/docs/doc-export/');
|
||||
|
||||
expect(Export.default).toBeUndefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
||||
const Export = await import('@/features/docs/doc-export/');
|
||||
|
||||
expect(Export.default).toHaveProperty('ModalExport');
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import { improveHtmlAccessibility } from '../utils_html';
|
||||
|
||||
const parse = (html: string): Document => {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(html, 'text/html');
|
||||
};
|
||||
|
||||
const bodyHtml = (doc: Document) =>
|
||||
doc.body.innerHTML.replace(/\s+/g, ' ').trim();
|
||||
|
||||
describe('improveHtmlAccessibility', () => {
|
||||
// Headings
|
||||
|
||||
describe('headings', () => {
|
||||
it('converts heading blocks without inner h tag to semantic h1–h6', () => {
|
||||
const doc = parse(`
|
||||
<div data-content-type="heading" data-level="3"><span>Title</span></div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const h3 = doc.querySelector('h3');
|
||||
expect(h3).not.toBeNull();
|
||||
expect(h3!.textContent.trim()).toBe('Title');
|
||||
});
|
||||
|
||||
it('does not nest a second heading when BlockNote already outputs h1–h6', () => {
|
||||
const doc = parse(`
|
||||
<div data-content-type="heading" data-level="2">
|
||||
<h2 class="bn-inline-content">Section</h2>
|
||||
</div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const h2 = doc.querySelectorAll('h2');
|
||||
expect(h2).toHaveLength(1);
|
||||
expect(h2[0].textContent).toBe('Section');
|
||||
expect(h2[0].className).toBe('bn-inline-content');
|
||||
});
|
||||
|
||||
it('uses data-level when inner heading tag has a different level', () => {
|
||||
const doc = parse(`
|
||||
<div data-content-type="heading" data-level="3">
|
||||
<h2 class="bn-inline-content">Mismatch</h2>
|
||||
</div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
expect(doc.querySelector('h3')).not.toBeNull();
|
||||
expect(doc.querySelector('h2')).toBeNull();
|
||||
});
|
||||
|
||||
it('inserts an h1 with the document title when none exists', () => {
|
||||
const doc = parse(`<p>Content</p>`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'My Doc');
|
||||
|
||||
const h1 = doc.querySelector('h1');
|
||||
expect(h1).not.toBeNull();
|
||||
expect(h1!.id).toBe('doc-title');
|
||||
expect(h1!.textContent).toBe('My Doc');
|
||||
});
|
||||
|
||||
it('does not insert h1 when the document already has one', () => {
|
||||
const doc = parse(`
|
||||
<div data-content-type="heading" data-level="1"><span>Existing</span></div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Ignored');
|
||||
|
||||
expect(doc.querySelectorAll('h1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Lists
|
||||
|
||||
describe('lists', () => {
|
||||
it('converts bullet list items to ul > li', () => {
|
||||
const doc = parse(`
|
||||
<div class="bn-block-group">
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="bulletListItem"><span>A</span></div>
|
||||
</div>
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="bulletListItem"><span>B</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const items = doc.querySelectorAll('ul > li');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].textContent).toBe('A');
|
||||
expect(items[1].textContent).toBe('B');
|
||||
});
|
||||
|
||||
it('converts numbered list items to ol > li', () => {
|
||||
const doc = parse(`
|
||||
<div class="bn-block-group">
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="numberedListItem"><span>1st</span></div>
|
||||
</div>
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="numberedListItem"><span>2nd</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
expect(doc.querySelectorAll('ol > li')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('splits lists when a heading sits between them', () => {
|
||||
const doc = parse(`
|
||||
<div class="bn-block-group">
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="bulletListItem"><span>A</span></div>
|
||||
</div>
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="bulletListItem"><span>B</span></div>
|
||||
</div>
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="heading" data-level="2"><span>Next</span></div>
|
||||
</div>
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="bulletListItem"><span>C</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const uls = doc.querySelectorAll('ul');
|
||||
expect(uls).toHaveLength(2);
|
||||
expect(uls[0].querySelectorAll('li')).toHaveLength(2);
|
||||
expect(uls[1].querySelectorAll('li')).toHaveLength(1);
|
||||
|
||||
const html = bodyHtml(doc);
|
||||
expect(html.indexOf('<ul')).toBeLessThan(html.indexOf('<h2'));
|
||||
expect(html.indexOf('<h2')).toBeLessThan(
|
||||
html.indexOf('<ul', html.indexOf('<ul') + 1),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps consecutive same-type items in a single list', () => {
|
||||
const doc = parse(`
|
||||
<div class="bn-block-group">
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="bulletListItem"><span>A</span></div>
|
||||
</div>
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="bulletListItem"><span>B</span></div>
|
||||
</div>
|
||||
<div class="bn-block-outer">
|
||||
<div data-content-type="bulletListItem"><span>C</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
expect(doc.querySelectorAll('ul')).toHaveLength(1);
|
||||
expect(doc.querySelectorAll('ul > li')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// Quotes
|
||||
|
||||
it('converts quote blocks to blockquote', () => {
|
||||
const doc = parse(`
|
||||
<div data-content-type="quote"><span>Wise words</span></div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const bq = doc.querySelector('blockquote');
|
||||
expect(bq).not.toBeNull();
|
||||
expect(bq!.textContent).toBe('Wise words');
|
||||
});
|
||||
|
||||
// Callouts
|
||||
|
||||
it('converts callout blocks to aside with role="note"', () => {
|
||||
const doc = parse(`
|
||||
<div data-content-type="callout"><span>Note</span></div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const aside = doc.querySelector('aside');
|
||||
expect(aside).not.toBeNull();
|
||||
expect(aside!.getAttribute('role')).toBe('note');
|
||||
});
|
||||
|
||||
// Checklists
|
||||
|
||||
it('wraps check list items in a ul with role="list" and adds aria-checked', () => {
|
||||
const doc = parse(`
|
||||
<div>
|
||||
<div data-content-type="checkListItem">
|
||||
<input type="checkbox" />
|
||||
<span>Todo</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const ul = doc.querySelector('ul.checklist');
|
||||
expect(ul).not.toBeNull();
|
||||
expect(ul!.getAttribute('role')).toBe('list');
|
||||
expect(doc.querySelector('input')!.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
// Code blocks
|
||||
|
||||
it('converts code blocks to pre > code and preserves data attributes', () => {
|
||||
const doc = parse(`
|
||||
<div data-content-type="codeBlock" class="hl-theme" data-language="js"><span>const x = 1;</span></div>
|
||||
`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const pre = doc.querySelector('pre');
|
||||
expect(pre).not.toBeNull();
|
||||
expect(pre!.className).toBe('hl-theme');
|
||||
expect(pre!.getAttribute('data-language')).toBe('js');
|
||||
expect(pre!.querySelector('code')!.textContent.trim()).toBe('const x = 1;');
|
||||
});
|
||||
|
||||
// Images
|
||||
|
||||
it('adds empty alt to images without one', () => {
|
||||
const doc = parse(`<img src="photo.png" />`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
expect(doc.querySelector('img')!.getAttribute('alt')).toBe('');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing alt', () => {
|
||||
const doc = parse(`<img src="photo.png" alt="A photo" />`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
expect(doc.querySelector('img')!.getAttribute('alt')).toBe('A photo');
|
||||
});
|
||||
|
||||
// Article wrapper
|
||||
|
||||
it('wraps body in article with role="document"', () => {
|
||||
const doc = parse(`<p>Hello</p>`);
|
||||
|
||||
improveHtmlAccessibility(doc, 'Doc');
|
||||
|
||||
const article = doc.querySelector('article');
|
||||
expect(article).not.toBeNull();
|
||||
expect(article!.getAttribute('role')).toBe('document');
|
||||
expect(article!.getAttribute('aria-labelledby')).toBe('doc-title');
|
||||
});
|
||||
});
|
||||
@@ -145,8 +145,20 @@ export const improveHtmlAccessibility = (
|
||||
headingBlocks.forEach((block) => {
|
||||
const rawLevel = Number(block.getAttribute('data-level')) || 1;
|
||||
const level = Math.min(Math.max(rawLevel, 1), 6);
|
||||
const heading = parsedDocument.createElement(`h${level}`);
|
||||
moveChildNodes(block, heading);
|
||||
const tag = `h${level}`;
|
||||
|
||||
const existingHeading = block.querySelector('h1, h2, h3, h4, h5, h6');
|
||||
const heading = parsedDocument.createElement(tag);
|
||||
|
||||
if (existingHeading) {
|
||||
if (existingHeading.className) {
|
||||
heading.className = existingHeading.className;
|
||||
}
|
||||
moveChildNodes(existingHeading, heading);
|
||||
} else {
|
||||
moveChildNodes(block, heading);
|
||||
}
|
||||
|
||||
block.replaceWith(heading);
|
||||
});
|
||||
|
||||
@@ -178,10 +190,11 @@ export const improveHtmlAccessibility = (
|
||||
listItem: HTMLElement;
|
||||
contentType: string;
|
||||
level: number;
|
||||
blockOuterIndex: number;
|
||||
}
|
||||
|
||||
const listItemsInfo: ListItemInfo[] = [];
|
||||
allBlockOuters.forEach((blockOuter) => {
|
||||
allBlockOuters.forEach((blockOuter, index) => {
|
||||
const listItem = blockOuter.querySelector<HTMLElement>(listItemSelector);
|
||||
if (listItem) {
|
||||
const contentType = listItem.getAttribute('data-content-type');
|
||||
@@ -192,6 +205,7 @@ export const improveHtmlAccessibility = (
|
||||
listItem,
|
||||
contentType,
|
||||
level,
|
||||
blockOuterIndex: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -206,13 +220,19 @@ export const improveHtmlAccessibility = (
|
||||
const isBullet = contentType === 'bulletListItem';
|
||||
const listTag = isBullet ? 'ul' : 'ol';
|
||||
|
||||
// Check if previous item continues the same list (same type and level)
|
||||
// Check if previous item continues the same list (same type, level, and
|
||||
// no non-list block between them in the DOM : e.g. a heading separates lists).
|
||||
const previousInfo = idx > 0 ? listItemsInfo[idx - 1] : null;
|
||||
const isAdjacentBlock = previousInfo && info.blockOuterIndex === previousInfo.blockOuterIndex + 1;
|
||||
const continuesPreviousList =
|
||||
previousInfo &&
|
||||
isAdjacentBlock &&
|
||||
previousInfo.contentType === contentType &&
|
||||
previousInfo.level === level;
|
||||
|
||||
if (previousInfo && !isAdjacentBlock) {
|
||||
listStack.length = 0;
|
||||
}
|
||||
|
||||
// Find or create the appropriate list
|
||||
let targetList: HTMLElement | null = null;
|
||||
|
||||
|
||||
+3
-11
@@ -1,6 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
@@ -40,17 +38,11 @@ describe('DocToolBox - Licence', () => {
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
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(
|
||||
screen.getByText(
|
||||
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
|
||||
),
|
||||
await screen.findByLabelText('Export the document'),
|
||||
).toBeInTheDocument();
|
||||
}, 10000);
|
||||
}, 15000);
|
||||
|
||||
test('The export button is not rendered when MIT version is activated', async () => {
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
|
||||
@@ -68,5 +60,5 @@ describe('DocToolBox - Licence', () => {
|
||||
expect(
|
||||
screen.queryByLabelText('Export the document'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
useDocUtils,
|
||||
useDuplicateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
|
||||
import { useFocusStore, useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
|
||||
@@ -88,7 +86,6 @@ interface DocToolBoxProps {
|
||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { t } = useTranslation();
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { isChild, isTopRoot } = useDocUtils(doc);
|
||||
|
||||
@@ -114,16 +111,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
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
|
||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
const { updateDocEmoji } = useDocTitleUpdate();
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ import {
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useEditorStore } from '../../doc-editor';
|
||||
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
|
||||
|
||||
export const useCopyCurrentEditorToClipboard = () => {
|
||||
const { editor } = useEditorStore();
|
||||
@@ -21,8 +21,8 @@ export const useCopyCurrentEditorToClipboard = () => {
|
||||
try {
|
||||
const editorContentFormatted =
|
||||
asFormat === 'html'
|
||||
? await editor.blocksToHTMLLossy()
|
||||
: await editor.blocksToMarkdownLossy();
|
||||
? editor.blocksToHTMLLossy()
|
||||
: editor.blocksToMarkdownLossy();
|
||||
await navigator.clipboard.writeText(editorContentFormatted);
|
||||
const successMessage =
|
||||
asFormat === 'markdown'
|
||||
|
||||
@@ -44,7 +44,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
|
||||
...queryConfig,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
+5
@@ -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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
+5
-2
@@ -4,9 +4,12 @@ import { useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
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 { Versions, useDocVersion } from '@/docs/doc-versioning/';
|
||||
|
||||
import { useDocVersion } from '../api/useDocVersion';
|
||||
import { Versions } from '../types';
|
||||
|
||||
import { DocVersionHeader } from './DocVersionHeader';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Doc } from '../doc-management';
|
||||
import { Doc } from '../doc-management/types';
|
||||
|
||||
export interface APIListVersions {
|
||||
count: number;
|
||||
|
||||
+7
-3
@@ -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 i18next from 'i18next';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -73,7 +73,9 @@ describe('DocsGridItemDate', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<DocsGridItemDate
|
||||
@@ -90,7 +92,9 @@ describe('DocsGridItemDate', () => {
|
||||
|
||||
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
|
||||
|
||||
await i18next.changeLanguage('en');
|
||||
await act(async () => {
|
||||
await i18next.changeLanguage('en');
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/../package.json', () => ({
|
||||
default: { version: '0.0.0' },
|
||||
}));
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('DocsDB', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -20,17 +15,16 @@ describe('DocsDB', () => {
|
||||
{ version: '3.0.0', expected: 3000000 },
|
||||
{ version: '10.20.30', expected: 10020030 },
|
||||
].forEach(({ version, expected }) => {
|
||||
it(`correctly computes version for ${version}`, () => {
|
||||
it(`correctly computes version for ${version}`, async () => {
|
||||
vi.doMock('@/../package.json', () => ({
|
||||
default: { version },
|
||||
}));
|
||||
|
||||
return vi.importActual('../DocsDB').then((module: any) => {
|
||||
const result = module.getCurrentVersion();
|
||||
expect(result).toBe(expected);
|
||||
expect(result).toBeGreaterThan(previousExpected);
|
||||
previousExpected = result;
|
||||
});
|
||||
const module = await import('../DocsDB');
|
||||
const result = (module as any).getCurrentVersion();
|
||||
expect(result).toBe(expected);
|
||||
expect(result).toBeGreaterThan(previousExpected);
|
||||
previousExpected = result;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
/// <reference types="vitest" />
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths({
|
||||
root: '.',
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
],
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
@@ -22,4 +15,7 @@ export default defineConfig({
|
||||
define: {
|
||||
'process.env.NODE_ENV': 'test',
|
||||
},
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,17 +32,17 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@tiptap/extensions": "3.19.0",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/node": "24.12.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"eslint": "10.0.1",
|
||||
"eslint": "10.0.3",
|
||||
"glob": "13.0.6",
|
||||
"prosemirror-view": "1.41.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"typescript": "5.9.3",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"yjs": "13.6.29"
|
||||
"wrap-ansi": "10.0.0",
|
||||
"yjs": "13.6.30"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const js = require('@eslint/js');
|
||||
const nextPlugin = require('@next/eslint-plugin-next');
|
||||
const tanstackQuery = require('@tanstack/eslint-plugin-query');
|
||||
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 prettier = require('eslint-plugin-prettier');
|
||||
const react = require('eslint-plugin-react');
|
||||
|
||||
@@ -18,22 +18,22 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
||||
"@typescript-eslint/parser": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"@vitest/eslint-plugin": "1.6.9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
"@typescript-eslint/parser": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1",
|
||||
"@vitest/eslint-plugin": "1.6.12",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"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-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-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-testing-library": "7.15.4",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-testing-library": "7.16.0",
|
||||
"prettier": "3.8.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"@types/node": "*",
|
||||
"eslint-plugin-docs": "*",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"i18next-parser": "9.3.0",
|
||||
"jest": "30.2.0",
|
||||
"i18next-parser": "9.4.0",
|
||||
"jest": "30.3.0",
|
||||
"ts-jest": "29.4.6",
|
||||
"typescript": "*",
|
||||
"yargs": "18.0.0"
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
"dependencies": {
|
||||
"@blocknote/server-util": "0.47.1",
|
||||
"@hocuspocus/server": "3.4.4",
|
||||
"@sentry/node": "10.38.0",
|
||||
"@sentry/profiling-node": "10.38.0",
|
||||
"@sentry/node": "10.43.0",
|
||||
"@sentry/profiling-node": "10.43.0",
|
||||
"@tiptap/extensions": "*",
|
||||
"axios": "1.13.5",
|
||||
"axios": "1.13.6",
|
||||
"cors": "2.8.6",
|
||||
"express": "5.2.1",
|
||||
"express-ws": "5.0.2",
|
||||
@@ -36,16 +36,16 @@
|
||||
"@types/express": "5.0.6",
|
||||
"@types/express-ws": "3.0.6",
|
||||
"@types/node": "*",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/supertest": "7.2.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint-plugin-docs": "*",
|
||||
"nodemon": "3.1.11",
|
||||
"nodemon": "3.1.14",
|
||||
"supertest": "7.2.2",
|
||||
"ts-node": "10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"typescript": "*",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.1.0",
|
||||
"vitest-mock-extended": "3.1.0",
|
||||
"ws": "8.19.0"
|
||||
},
|
||||
|
||||
+2350
-2037
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,9 @@
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
"mjml": "4.18.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"minimatch": "^9.0.7"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-mjml-to-html": "bash ./bin/mjml-to-html",
|
||||
|
||||
+6
-13
@@ -110,7 +110,7 @@ boolbase@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
|
||||
|
||||
brace-expansion@^2.0.1:
|
||||
brace-expansion@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
|
||||
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
|
||||
@@ -562,19 +562,12 @@ mime@^2.4.6:
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
|
||||
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
|
||||
|
||||
minimatch@9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
|
||||
integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
|
||||
minimatch@9.0.1, minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.7:
|
||||
version "9.0.9"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
|
||||
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimatch@^9.0.3, minimatch@^9.0.4:
|
||||
version "9.0.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
|
||||
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
brace-expansion "^2.0.2"
|
||||
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
|
||||
version "7.1.2"
|
||||
|
||||
Reference in New Issue
Block a user