Compare commits

..

6 Commits

Author SHA1 Message Date
charles a4f668929c (backend): add command admin
we want to run the indexing from the admin.
in `dmin/core/runindexing/`is a form to do so.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-26 17:41:18 +01:00
charles 209622c523 📝(backend): add docs
i am documenting the index command

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-26 17:41:18 +01:00
charles 0076df5782 ♻️(backend): renaming
to me tasks/search.py should be tasks/indexing.py

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-26 17:41:18 +01:00
charles e2344dcc17 (backend): add time-bound filtering options on index command
we need to be able to specify time bounds on the bulk index
to allow recovering from checkpoint after crash

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-26 17:41:17 +01:00
charles e183ee9ed7 (backend): add crash-safe mode to index command
crash-save mode consist in indexing documents in ascending
updated_at order and save the last document.update_at.
This allows resuming indexing from the last successful batch
in case of a crash.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-25 17:05:40 +01:00
charles 5ce3ab71d3 📝(backend): improve docstring in tasks.search
i think some docstrings, about a counter, were outdated.
i add more details to help understand the logic.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-24 15:42:33 +01:00
73 changed files with 3065 additions and 2959 deletions
+1 -10
View File
@@ -6,24 +6,15 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- 🚸(frontend) hint min char search users #2064
### Changed ### Changed
- 💄(frontend) improve comments highlights #1961 - 💄(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 ## [v4.8.3] - 2026-03-23
### Changed ### Changed
- ✨(backend) improve indexing command with checkpoint recovery and asynchronicity
- ♿️(frontend) improve version history list accessibility #2033 - ♿️(frontend) improve version history list accessibility #2033
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983 - ♿(frontend) focus skip link on headings and skip grid dropzone #1983
- ♿️(frontend) add sr-only format to export download button #2088 - ♿️(frontend) add sr-only format to export download button #2088
+52
View File
@@ -0,0 +1,52 @@
# Index Command
The `index` management command is used to index documents to the remote search indexer.
It sends an asynchronous task to the Celery worker.
## Usage
### Command Line
```bash
python manage.py index \
--lower-time-bound "2024-01-01T00:00:00" \
--upper-time-bound "2024-01-31T23:59:59" \
--batch-size 200 \
--crash-safe-mode false
```
### Django Admin
The command is available in the Django admin interface:
1. Go to `/admin/`
2. Click on **"Run Indexing"** in the CORE section
3. Fill in the form with the desired parameters
4. Click **"Run Indexing Command"**
## Parameters
### `--batch-size`
- **type:** Integer
- **default:** `50`
- **description:** Number of documents to process per batch. Higher values may improve performance but use more memory.
### `--lower-time-bound`
- **optional**: true
- **type:** ISO 8601 datetime string
- **default:** `None`
- **description:** Only documents updated after this date will be indexed.
### `--upper-time-bound` (optional)
- **optional**: true
- **type:** ISO 8601 datetime string
- **default:** `None`
- **description:** Only documents updated before this date will be indexed.
### `--crash-safe-mode`
- **type:** Boolean flag
- **default:** `True`
- **description:** When enabled, the command orders documents by `updated_at` and stores the last indexed document's timestamp in cache. This allows resuming indexing from the last successful batch in case of a crash. However, it is more computationally expensive due to sorting.b
-3
View File
@@ -60,13 +60,10 @@
"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"
] ]
} }
+36 -1
View File
@@ -1,8 +1,10 @@
"""Admin classes and registrations for core app.""" """Admin classes and registrations for core app."""
from django.conf import settings
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin from django.contrib.auth import admin as auth_admin
from django.shortcuts import redirect from django.core.management import call_command
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin from treebeard.admin import TreeAdmin
@@ -227,3 +229,36 @@ class InvitationAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
obj.issuer = request.user obj.issuer = request.user
obj.save() obj.save()
@admin.register(models.RunIndexing)
class RunIndexingAdmin(admin.ModelAdmin):
"""Admin for running indexing commands."""
def changelist_view(self, request, extra_context=None):
"""Override to avoid querying the database and handle form submission."""
if request.method == "POST":
try:
call_command(
"index",
batch_size=int(request.POST.get("batch_size")),
lower_time_bound=request.POST.get("lower_time_bound"),
upper_time_bound=request.POST.get("upper_time_bound"),
crash_safe_mode=request.POST.get("crash_safe_mode"),
)
messages.success(request, _("Indexing triggered!"))
except Exception as e:
messages.error(
request,
_("Error running indexing command: %(error)s") % {"error": str(e)},
)
return render(
request=request,
template_name="runindexing.html",
context={
**self.admin_site.each_context(request),
"title": "Run Indexing Command",
"default_batch_size": settings.SEARCH_INDEXER_BATCH_SIZE,
},
)
+37 -11
View File
@@ -3,11 +3,12 @@ Handle search setup that needs to be done at bootstrap time.
""" """
import logging import logging
import time from datetime import datetime
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer from core.services.search_indexers import get_document_indexer
from core.tasks.indexing import batch_document_indexer_task
logger = logging.getLogger("docs.search.bootstrap_search") logger = logging.getLogger("docs.search.bootstrap_search")
@@ -27,6 +28,31 @@ class Command(BaseCommand):
default=50, default=50,
help="Indexation query batch size", help="Indexation query batch size",
) )
parser.add_argument(
"--lower-time-bound",
action="store",
dest="lower_time_bound",
type=datetime.fromisoformat,
default=None,
help="DateTime in ISO format. Only documents updated after this date will be indexed",
)
parser.add_argument(
"--upper-time-bound",
action="store",
dest="upper_time_bound",
type=datetime.fromisoformat,
default=None,
help="DateTime in ISO format. Only documents updated before this date will be indexed",
)
parser.add_argument(
"--crash-safe-mode",
action="store_true",
dest="crash_safe_mode",
default=True,
help="If True, order documents by updated_at and store the last indexed document.updated_at in cache. "
"This allows resuming indexing from the last successful batch in case of a crash "
"but is more computationally expensive due to sorting.",
)
def handle(self, *args, **options): def handle(self, *args, **options):
"""Launch and log search index generation.""" """Launch and log search index generation."""
@@ -35,18 +61,18 @@ class Command(BaseCommand):
if not indexer: if not indexer:
raise CommandError("The indexer is not enabled or properly configured.") raise CommandError("The indexer is not enabled or properly configured.")
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
batch_size = options["batch_size"]
try: try:
count = indexer.index(batch_size=batch_size) batch_document_indexer_task.apply_async(
kwargs={
"lower_time_bound": options["lower_time_bound"],
"upper_time_bound": options["upper_time_bound"],
"batch_size": options["batch_size"],
"crash_safe_mode": options["crash_safe_mode"],
},
)
except Exception as err: except Exception as err:
raise CommandError("Unable to regenerate index") from err raise CommandError("Unable send indexing task to worker") from err
duration = time.perf_counter() - start
logger.info( logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.", "Document indexing task sent to worker",
count,
duration,
) )
@@ -0,0 +1,23 @@
# Generated by Django 5.2.12 on 2026-03-26 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0031_clean_onboarding_accesses'),
]
operations = [
migrations.CreateModel(
name='RunIndexing',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
options={
'verbose_name': 'Run Indexing',
'managed': False,
},
),
]
+10
View File
@@ -2029,3 +2029,13 @@ class Invitation(BaseModel):
"partial_update": is_admin_or_owner, "partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner, "retrieve": is_admin_or_owner,
} }
class RunIndexing(models.Model):
"""Proxy model for indexing management in admin."""
class Meta:
"""Meta options."""
managed = False
verbose_name = _("Run Indexing")
+23 -16
View File
@@ -1,5 +1,6 @@
"""Document search index management utilities and indexers""" """Document search index management utilities and indexers"""
import itertools
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
@@ -7,6 +8,7 @@ from functools import cache
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
@@ -18,6 +20,9 @@ from core.enums import SearchType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BULK_INDEXER_CHECKPOINT = "bulk-indexer-checkpoint"
@cache @cache
def get_document_indexer(): def get_document_indexer():
"""Returns an instance of indexer service if enabled and properly configured.""" """Returns an instance of indexer service if enabled and properly configured."""
@@ -125,7 +130,7 @@ class BaseDocumentIndexer(ABC):
if not self.search_url: if not self.search_url:
raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.") raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
def index(self, queryset=None, batch_size=None): def index(self, queryset=None, batch_size=None, crash_safe_mode=False):
""" """
Fetch documents in batches, serialize them, and push to the search backend. Fetch documents in batches, serialize them, and push to the search backend.
@@ -134,35 +139,37 @@ class BaseDocumentIndexer(ABC):
Defaults to all documents without filter. Defaults to all documents without filter.
batch_size (int, optional): Number of documents per batch. batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE. Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
crash_safe_mode (bool, optional): If True, order documents by updated_at
and store the last indexed document.updated_at in cache.
This allows resuming indexing from the last successful batch in case of a crash
but is more computationally expensive due to sorting.
""" """
last_id = 0
count = 0 count = 0
queryset = queryset or models.Document.objects.all() queryset = queryset or models.Document.objects.all()
batch_size = batch_size or self.batch_size batch_size = batch_size or self.batch_size
while True: if crash_safe_mode:
documents_batch = list( queryset = queryset.order_by("updated_at")
queryset.filter(
id__gt=last_id,
).order_by("id")[:batch_size]
)
if not documents_batch:
break
for documents_batch in itertools.batched(queryset.iterator(), batch_size):
doc_paths = [doc.path for doc in documents_batch] doc_paths = [doc.path for doc in documents_batch]
last_id = documents_batch[-1].id
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths) accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
serialized_batch = [ serialized_batch = [
self.serialize_document(document, accesses_by_document_path) self.serialize_document(document, accesses_by_document_path)
for document in documents_batch for document in documents_batch
if document.content or document.title if document.content or document.title
] ]
if serialized_batch: if not serialized_batch:
self.push(serialized_batch) continue
count += len(serialized_batch)
self.push(serialized_batch)
count += len(serialized_batch)
if crash_safe_mode:
django_cache.set(
BULK_INDEXER_CHECKPOINT, serialized_batch[-1]["updated_at"]
)
return count return count
+1 -1
View File
@@ -10,7 +10,7 @@ from django.db.models import signals
from django.dispatch import receiver from django.dispatch import receiver
from core import models from core import models
from core.tasks.search import trigger_batch_document_indexer from core.tasks.indexing import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key from core.utils import get_users_sharing_documents_with_cache_key
+130
View File
@@ -0,0 +1,130 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from core.utils import build_indexable_documents_queryset
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""
Celery Task: Indexes a single document by its ID.
Args:
document_id: Primary key of the document to index.
"""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Acquire a throttle lock to prevent multiple batch indexation tasks during countdown.
implements a debouncing pattern: only the first call during the timeout period
will succeed, subsequent calls are skipped until the timeout expires.
Args:
timeout (int): Lock duration in seconds (countdown period).
atomic (bool): Use Redis locks for atomic operations if available.
Returns:
bool: True if lock acquired (first call), False if already held (subsequent calls).
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# cache.add() is atomic test-and-set operation:
# - If key doesn't exist: creates it with timeout and returns True
# - If key already exists: does nothing and returns False
# The key expires after timeout seconds, releasing the lock.
# The value 1 is irrelevant, only the key presence/absence matters.
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(lower_time_bound=None, upper_time_bound=None, **kwargs):
"""
Celery Task: Batch indexes all documents modified since timestamp.
Args:
lower_time_bound (datetime|isoformat, optional):
indexes documents updated or deleted after this timestamp.
upper_time_bound (datetime|isoformat, optional):
indexes documents updated or deleted before this timestamp.
"""
indexer = get_document_indexer()
if not indexer:
logger.warning("Indexing task triggered but no indexer configured: skipping")
return
count = indexer.index(
queryset=build_indexable_documents_queryset(
lower_time_bound=lower_time_bound, upper_time_bound=upper_time_bound
),
**kwargs,
)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(document):
"""
Trigger document indexation with optional debounce mechanism.
behavior depends on SEARCH_INDEXER_COUNTDOWN setting:
- if countdown > 0 sec (async batch mode):
* schedules a batch indexation task after countdown in seconds
* uses throttle mechanism to ensure only ONE batch task runs per countdown period
* all documents modified since first trigger are indexed together
- if countdown == 0 sec (sync mode):
* executes indexation synchronously in the current thread
* no batching, no throttling, no Celery task queuing
Args:
document (Document): the document instance that triggered the indexation.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation is disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# use throttle to ensure only one task is scheduled per countdown period.
# if throttle acquired, schedule batch task; otherwise skip.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
document.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
kwargs={"lower_time_bound": document.updated_at}, countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", document.pk)
else:
document_indexer_task.apply(args=[document.pk])
-95
View File
@@ -1,95 +0,0 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Enable the task throttle flag for a delay.
Uses redis locks if available to ensure atomic changes
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# Use add() here :
# - set the flag and returns true if not exist
# - do nothing and return false if exist
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(timestamp):
"""Celery Task : Sends indexation query for a batch of documents."""
indexer = get_document_indexer()
if indexer:
queryset = models.Document.objects.filter(
Q(updated_at__gte=timestamp)
| Q(deleted_at__gte=timestamp)
| Q(ancestors_deleted_at__gte=timestamp)
)
count = indexer.index(queryset)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(document):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# Each time this method is called during a countdown, we increment the
# counter and each task decrease it, so the index be run only once.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
document.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
args=[document.updated_at], countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", document.pk)
else:
document_indexer_task.apply(args=[document.pk])
@@ -0,0 +1,43 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<form method="POST" >
{% csrf_token %}
<hr style="margin-bottom: 10px;">
<p>
This command triggers the indexing of all documents within the specified time bound.
</p>
<p>
See <b>docs/commands/index.md</b> for more details.
</p>
<hr style="margin-bottom: 20px;">
<div>
<label for="batch_size" style="margin-right: 10px">Batch size</label>
<input id="batch_size" type="number" name="batch_size" value={{ default_batch_size }} style="width: 80px;" >
</div>
<div>
<label for="lower_time_bound" style="margin-right: 10px">Lower time bound (optional)</label>
<input id="lower_time_bound" type="datetime-local" name="lower_time_bound" >
</div>
<div>
<label for="upper_time_bound" style="margin-right: 10px">Upper time bound (optional)</label>
<input id="upper_time_bound" type="datetime-local" name="upper_time_bound" >
</div>
<div>
<label for="crash_safe_mode" style="margin-right: 10px">Crash save mode</label>
<input id="crash_safe_mode" type="checkbox" name="crash_safe_mode" value="true" checked>
</div>
<input type="submit" value="{% translate 'Run Indexing' %}" style="margin-top: 20px;">
</form>
{% endblock %}
+147 -12
View File
@@ -2,21 +2,24 @@
Unit test for `index` command. Unit test for `index` command.
""" """
from datetime import datetime, timedelta, timezone
from operator import itemgetter from operator import itemgetter
from unittest import mock from unittest import mock
from django.core.cache import cache
from django.core.management import CommandError, call_command from django.core.management import CommandError, call_command
from django.db import transaction from django.db import transaction
import pytest import pytest
from core import factories from core import factories
from core.services.search_indexers import FindDocumentIndexer from core.services.search_indexers import BULK_INDEXER_CHECKPOINT, FindDocumentIndexer
from core.tests.conftest import create_document_with_updated_at
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
def test_index(): def test_index_without_bound_success():
"""Test the command `index` that run the Find app indexer for all the available documents.""" """Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory() user = factories.UserFactory()
indexer = FindDocumentIndexer() indexer = FindDocumentIndexer()
@@ -37,20 +40,152 @@ def test_index():
} }
with mock.patch.object(FindDocumentIndexer, "push") as mock_push: with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command("index") call_command("index", crash_safe_mode=False)
push_call_args = [call.args[0] for call in mock_push.call_args_list] push_call_args = [call.args[0] for call in mock_push.call_args_list]
# called once but with a batch of docs # called once but with a batch of docs
mock_push.assert_called_once() mock_push.assert_called_once()
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted( assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[ [
indexer.serialize_document(doc, accesses), indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses), indexer.serialize_document(no_title_doc, accesses),
], ],
key=itemgetter("id"), key=itemgetter("id"),
)
# crash_safe_mode deactivated -> no checkpoint stored
assert cache.get(BULK_INDEXER_CHECKPOINT) is None
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_with_both_bounds_success():
"""Test the command `index` for all documents within time bound."""
cache.clear()
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=30)
document_too_early = create_document_with_updated_at(
lower_time_bound - timedelta(days=10)
)
document_in_window_1 = create_document_with_updated_at(
lower_time_bound + timedelta(days=5)
)
document_in_window_2 = create_document_with_updated_at(
lower_time_bound + timedelta(days=15)
)
document_too_late = create_document_with_updated_at(
upper_time_bound + timedelta(days=10)
)
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index",
lower_time_bound=lower_time_bound.isoformat(),
upper_time_bound=upper_time_bound.isoformat(),
) )
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# Only documents in window should be indexed
assert str(document_too_early.id) not in all_push_call_args
assert str(document_in_window_1.id) in all_push_call_args
assert str(document_in_window_2.id) in all_push_call_args
assert str(document_too_late.id) not in all_push_call_args
# Checkpoint should be set to last indexed document's updated_at
assert (
cache.get(BULK_INDEXER_CHECKPOINT)
== document_in_window_2.updated_at.isoformat()
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_with_crash_recovery():
"""Test resuming indexing from checkpoint after a crash."""
cache.clear()
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=60)
batch_size = 2
documents = [
# batch 0
create_document_with_updated_at(lower_time_bound + timedelta(days=5)),
create_document_with_updated_at(lower_time_bound + timedelta(days=10)),
# batch 1
create_document_with_updated_at(lower_time_bound + timedelta(days=20)),
create_document_with_updated_at(lower_time_bound + timedelta(days=25)),
# batch 2 - will crash here
create_document_with_updated_at(lower_time_bound + timedelta(days=30)),
create_document_with_updated_at(lower_time_bound + timedelta(days=35)),
# batch 3
create_document_with_updated_at(lower_time_bound + timedelta(days=40)),
create_document_with_updated_at(lower_time_bound + timedelta(days=45)),
]
# First run: simulate crash on batch 3
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
def push_with_failure(data):
# Crash when we encounter document at index 4 (batch 2 with batch_size=2)
if str(documents[4].id) in [document["id"] for document in data]:
raise ConnectionError("Simulated indexing error")
mock_push.side_effect = push_with_failure
call_command(
"index",
batch_size=batch_size,
lower_time_bound=lower_time_bound.isoformat(),
upper_time_bound=upper_time_bound.isoformat(),
)
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# First 2 batches should have been indexed and checkpoint saved
checkpoint = cache.get(BULK_INDEXER_CHECKPOINT)
assert checkpoint == documents[3].updated_at.isoformat()
# first 2 batches should be indexed successfully
for i in range(0, 4):
assert str(documents[i].id) in all_push_call_args
# next batch should have been attempted but failed
for i in range(4, 6):
assert str(documents[i].id) in all_push_call_args
# last batches indexing should not have been attempted
for i in range(6, 8):
assert str(documents[i].id) not in all_push_call_args
# Second run: resume from checkpoint
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index",
batch_size=batch_size,
lower_time_bound=checkpoint,
upper_time_bound=upper_time_bound.isoformat(),
)
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# first 2 batches should NOT be re-indexed
# except the last document of the last batch which is on the checkpoint boundary
for i in range(0, 3):
assert str(documents[i].id) not in all_push_call_args
# next batches should be indexed including the document at the checkpoint boundary
# which has already been indexed and is re-indexed
for i in range(4, 8):
assert str(documents[i].id) in all_push_call_args
@pytest.mark.django_db @pytest.mark.django_db
+8 -1
View File
@@ -8,7 +8,7 @@ from django.core.cache import cache
import pytest import pytest
import responses import responses
from core import factories from core import factories, models
from core.tests.utils.urls import reload_urls from core.tests.utils.urls import reload_urls
USER = "user" USER = "user"
@@ -143,3 +143,10 @@ def user_token():
A fixture to create a user token for testing. A fixture to create a user token for testing.
""" """
return build_authorization_bearer("some_token") return build_authorization_bearer("some_token")
def create_document_with_updated_at(updated_at, **kwargs):
"""Helper to create a document with a specific updated_at, bypassing auto_now=True."""
document = factories.DocumentFactory(**kwargs)
models.Document.objects.filter(pk=document.pk).update(updated_at=updated_at)
return models.Document.objects.get(pk=document.pk)
+126
View File
@@ -2,6 +2,7 @@
import base64 import base64
import uuid import uuid
from datetime import datetime, timedelta, timezone
from django.core.cache import cache from django.core.cache import cache
@@ -9,6 +10,7 @@ import pycrdt
import pytest import pytest
from core import factories, utils from core import factories, utils
from core.tests.conftest import create_document_with_updated_at
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -240,3 +242,127 @@ def test_utils_get_value_by_pattern_no_match():
result = utils.get_value_by_pattern(data, r"^title\.") result = utils.get_value_by_pattern(data, r"^title\.")
assert result == [] assert result == []
def test_utils_build_indexable_documents_queryset_no_filters():
"""Test build_indexable_documents_queryset with no filters returns all documents."""
factories.DocumentFactory.create_batch(3)
queryset = utils.build_indexable_documents_queryset()
assert queryset.count() == 3
def test_utils_build_indexable_documents_queryset_oldest_updated_at():
"""Test filtering by oldest_updated_at includes documents updated after timestamp."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_too_old = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
document_at_boundary = create_document_with_updated_at(lower_time_bound)
document_recent = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=15)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 2
assert document_too_old not in queryset
assert document_at_boundary in queryset
assert document_recent in queryset
def test_utils_build_indexable_documents_queryset_newest_updated_at():
"""Test filtering by newest_updated_at includes documents updated before timestamp."""
upper_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_old = create_document_with_updated_at(
updated_at=upper_time_bound - timedelta(days=30)
)
document_at_boundary = create_document_with_updated_at(upper_time_bound)
document_too_recent = create_document_with_updated_at(
updated_at=upper_time_bound + timedelta(days=15)
)
queryset = utils.build_indexable_documents_queryset(
upper_time_bound=upper_time_bound
)
assert queryset.count() == 2
assert document_old in queryset
assert document_at_boundary in queryset
assert document_too_recent not in queryset
def test_utils_build_indexable_documents_queryset_both_bounds():
"""Test filtering with both oldest and newest bounds."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=30)
document_too_early = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=10)
)
document_in_window = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=5)
)
other_document_in_window = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=15)
)
document_too_late = create_document_with_updated_at(
updated_at=upper_time_bound + timedelta(days=10)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound, upper_time_bound=upper_time_bound
)
assert queryset.count() == 2
assert document_too_early not in queryset
assert document_in_window in queryset
assert other_document_in_window in queryset
assert document_too_late not in queryset
def test_utils_build_indexable_documents_queryset_deleted_at():
"""Test filtering includes documents with deleted_at in range."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_deleted_in_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30),
deleted_at=lower_time_bound + timedelta(days=15),
)
document_updated_before_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 1
assert document_deleted_in_range in queryset
assert document_updated_before_range not in queryset
def test_utils_build_indexable_documents_queryset_ancestors_deleted_at():
"""Test filtering includes documents with ancestors_deleted_at in range."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_ancestor_deleted_in_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30),
ancestors_deleted_at=lower_time_bound + timedelta(days=15),
)
document_updated_before_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 1
assert document_ancestor_deleted_in_range in queryset
assert document_updated_before_range not in queryset
+45 -1
View File
@@ -8,7 +8,7 @@ from collections import defaultdict
from django.core.cache import cache from django.core.cache import cache
from django.db import models as db from django.db import models as db
from django.db.models import Subquery from django.db.models import Q, Subquery
import pycrdt import pycrdt
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -168,3 +168,47 @@ def users_sharing_documents_with(user):
elapsed, elapsed,
) )
return result return result
def build_indexable_documents_queryset(lower_time_bound=None, upper_time_bound=None):
"""
Build a queryset of documents to index filtered by timestamp range.
filters out documents that have been updated, or deleted outside the time range.
Args:
lower_time_bound (datetime, optional):
keeps documents updated or deleted after this timestamp.
upper_time_bound (datetime, optional):
keeps documents updated or deleted before this timestamp.
Returns:
QuerySet: Filtered Document queryset ready for indexation.
"""
queryset = models.Document.objects.all()
conditions = Q()
if lower_time_bound and upper_time_bound:
conditions = (
Q(updated_at__gte=lower_time_bound, updated_at__lte=upper_time_bound)
| Q(deleted_at__gte=lower_time_bound, deleted_at__lte=upper_time_bound)
| Q(
ancestors_deleted_at__gte=lower_time_bound,
ancestors_deleted_at__lte=upper_time_bound,
)
)
elif lower_time_bound:
conditions = (
Q(updated_at__gte=lower_time_bound)
| Q(deleted_at__gte=lower_time_bound)
| Q(ancestors_deleted_at__gte=lower_time_bound)
)
elif upper_time_bound:
conditions = (
Q(updated_at__lte=upper_time_bound)
| Q(deleted_at__lte=upper_time_bound)
| Q(ancestors_deleted_at__lte=upper_time_bound)
)
return queryset.filter(conditions)
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import { import {
createDoc, createDoc,
getMenuItem,
mockedDocument, mockedDocument,
overrideConfig, overrideConfig,
verifyDocName, verifyDocName,
@@ -46,9 +47,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();
await expect( expect(
page.locator('button[data-test="convertMarkdown"]'), await page.locator('button[data-test="convertMarkdown"]').count(),
).toHaveCount(1); ).toBe(1);
await expect( await expect(
page.getByRole('button', { name: config.selector, exact: true }), page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden(); ).toBeHidden();
@@ -178,32 +179,18 @@ 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( await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
page.getByRole('menuitem', { name: 'Use as prompt' }), await expect(getMenuItem(page, 'Rephrase')).toBeVisible();
).toBeVisible(); await expect(getMenuItem(page, 'Summarize')).toBeVisible();
await expect( await expect(getMenuItem(page, 'Correct')).toBeVisible();
page.getByRole('menuitem', { name: 'Rephrase' }), await expect(getMenuItem(page, 'Language')).toBeVisible();
).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 page.getByRole('menuitem', { name: 'Language' }).hover(); await getMenuItem(page, 'Language').hover();
await expect( await expect(getMenuItem(page, 'English', { exact: true })).toBeVisible();
page.getByRole('menuitem', { name: 'English', exact: true }), await expect(getMenuItem(page, 'French', { exact: true })).toBeVisible();
).toBeVisible(); await expect(getMenuItem(page, 'German', { exact: true })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'German', exact: true }).click(); await getMenuItem(page, 'German', { exact: true }).click();
await expect(editor.getByText('Hallo Welt')).toBeVisible(); await expect(editor.getByText('Hallo Welt')).toBeVisible();
}); });
@@ -269,23 +256,15 @@ 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( await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else { } else {
await expect( await expect(getMenuItem(page, 'Use as prompt')).toBeHidden();
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
} }
if (ai_translate) { if (ai_translate) {
await expect( await expect(getMenuItem(page, 'Language')).toBeVisible();
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else { } else {
await expect( await expect(getMenuItem(page, 'Language')).toBeHidden();
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
} }
}); });
}); });
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import { import {
closeHeaderMenu, closeHeaderMenu,
createDoc, createDoc,
getMenuItem,
getOtherBrowserName, getOtherBrowserName,
verifyDocName, verifyDocName,
} from './utils-common'; } from './utils-common';
@@ -151,7 +152,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 thread.getByRole('menuitem', { name: 'Edit comment' }).click(); await getMenuItem(thread, '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 +177,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 thread.getByRole('menuitem', { name: 'Delete comment' }).click(); await getMenuItem(thread, '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();
@@ -209,7 +210,7 @@ test.describe('Doc Comments', () => {
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 thread.getByRole('menuitem', { name: 'Delete comment' }).click(); await getMenuItem(thread, 'Delete comment').click();
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark'); await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS( await expect(editor.getByText('Hello')).toHaveCSS(
@@ -5,6 +5,7 @@ import cs from 'convert-stream';
import { import {
createDoc, createDoc,
getMenuItem,
goToGridDoc, goToGridDoc,
overrideConfig, overrideConfig,
verifyDocName, verifyDocName,
@@ -147,18 +148,20 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close'); const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click(); await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click(); await getMenuItem(page, '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
webSocket = await page.waitForEvent('websocket', (webSocket) => { webSocketPromise = 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();
@@ -575,10 +578,12 @@ test.describe('Doc Editor', () => {
await page.reload(); await page.reload();
responseCanEdit = await page.waitForResponse( responseCanEditPromise = 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 };
@@ -604,7 +609,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 page.getByRole('menuitemradio', { name: 'Reading' }).click(); await getMenuItem(page, '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,6 +3,7 @@ import { expect, test } from '@playwright/test';
import { import {
createDoc, createDoc,
getGridRow, getGridRow,
getMenuItem,
getOtherBrowserName, getOtherBrowserName,
mockedListDocs, mockedListDocs,
toggleHeaderMenu, toggleHeaderMenu,
@@ -206,7 +207,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 page.getByRole('menuitem', { name: 'Move into a doc' }).click(); await getMenuItem(page, 'Move into a doc').click();
await expect( await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }), page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -294,7 +295,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 page.getByRole('menuitem', { name: 'Move into a doc' }).click(); await getMenuItem(page, 'Move into a doc').click();
await expect( await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }), page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -341,9 +342,7 @@ 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 otherPage await getMenuItem(otherPage, 'Administrator').click();
.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();
@@ -354,7 +353,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 page.getByRole('menuitem', { name: 'Move into a doc' }).click(); await getMenuItem(page, 'Move into a doc').click();
await expect( await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }), page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { createDoc, getGridRow, verifyDocName } from './utils-common'; import {
createDoc,
getGridRow,
getMenuItem,
verifyDocName,
} from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share'; import { addNewMember, connectOtherUserToDoc } from './utils-share';
type SmallDoc = { type SmallDoc = {
@@ -99,7 +104,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 page.getByRole('menuitem', { name: 'Share' }).click(); await getMenuItem(page, 'Share').click();
await expect( await expect(
page.getByRole('dialog').getByText('Share the document'), page.getByRole('dialog').getByText('Share the document'),
@@ -115,7 +120,7 @@ test.describe('Document grid item options', () => {
// Pin // Pin
await row.getByText(`more_horiz`).click(); await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Pin' }).click(); await getMenuItem(page, 'Pin').click();
// Check is pinned // Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible(); await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
@@ -142,7 +147,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 page.getByRole('menuitem', { name: 'Delete' }).click(); await getMenuItem(page, 'Delete').click();
await expect( await expect(
page.getByRole('heading', { name: 'Delete a doc' }), page.getByRole('heading', { name: 'Delete a doc' }),
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import { import {
createDoc, createDoc,
getGridRow, getGridRow,
getMenuItem,
goToGridDoc, goToGridDoc,
mockedDocument, mockedDocument,
verifyDocName, verifyDocName,
@@ -78,7 +79,7 @@ test.describe('Doc Header', () => {
await page.getByTestId('doc-visibility').click(); await page.getByTestId('doc-visibility').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click(); await getMenuItem(page, 'Public').click();
await page.getByRole('button', { name: 'close' }).first().click(); await page.getByRole('button', { name: 'close' }).first().click();
@@ -152,10 +153,8 @@ 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 = page.getByRole('menuitem', { name: 'Add emoji' }); const addEmojiMenuItem = getMenuItem(page, 'Add emoji');
const removeEmojiMenuItem = page.getByRole('menuitem', { const removeEmojiMenuItem = getMenuItem(page, 'Remove emoji');
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();
@@ -209,7 +208,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 page.getByRole('menuitem', { name: 'Delete document' }).click(); await getMenuItem(page, 'Delete document').click();
await expect( await expect(
page.getByRole('heading', { name: 'Delete a doc' }), page.getByRole('heading', { name: 'Delete a doc' }),
@@ -237,7 +236,7 @@ test.describe('Doc Header', () => {
hasText: randomDoc, hasText: randomDoc,
}); });
await expect(row).toHaveCount(0); expect(await row.count()).toBe(0);
}); });
test('it checks the options available if administrator', async ({ page }) => { test('it checks the options available if administrator', async ({ page }) => {
@@ -271,12 +270,10 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click(); await page.getByLabel('Open the document options').click();
await expect( await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options // Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
@@ -296,7 +293,7 @@ test.describe('Doc Header', () => {
await invitationRole.click(); await invitationRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click(); await getMenuItem(page, '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');
@@ -308,9 +305,7 @@ test.describe('Doc Header', () => {
await expect(roles).toBeVisible(); await expect(roles).toBeVisible();
await roles.click(); await roles.click();
await expect( await expect(getMenuItem(page, 'Remove access')).toBeEnabled();
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 }) => {
@@ -350,12 +345,10 @@ test.describe('Doc Header', () => {
).toBeVisible(); ).toBeVisible();
await page.getByLabel('Open the document options').click(); await page.getByLabel('Open the document options').click();
await expect( await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options // Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
@@ -422,12 +415,10 @@ test.describe('Doc Header', () => {
).toBeVisible(); ).toBeVisible();
await page.getByLabel('Open the document options').click(); await page.getByLabel('Open the document options').click();
await expect( await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options // Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
@@ -482,7 +473,7 @@ 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 page.getByRole('menuitem', { name: 'Copy as Markdown' }).click(); await getMenuItem(page, 'Copy as Markdown').click();
await expect( await expect(
page.getByText('Copied as Markdown to clipboard'), page.getByText('Copied as Markdown to clipboard'),
).toBeVisible(); ).toBeVisible();
@@ -546,7 +537,7 @@ test.describe('Doc Header', () => {
.click(); .click();
// Pin // Pin
await page.getByRole('menuitem', { name: 'Pin' }).click(); await getMenuItem(page, 'Pin').click();
await page await page
.getByRole('button', { name: 'Open the document options' }) .getByRole('button', { name: 'Open the document options' })
.click(); .click();
@@ -567,11 +558,11 @@ test.describe('Doc Header', () => {
.click(); .click();
// Unpin // Unpin
await page.getByRole('menuitem', { name: 'Unpin' }).click(); await getMenuItem(page, 'Unpin').click();
await page await page
.getByRole('button', { name: 'Open the document options' }) .getByRole('button', { name: 'Open the document options' })
.click(); .click();
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible(); await expect(getMenuItem(page, 'Pin')).toBeVisible();
await page.goto('/'); await page.goto('/');
@@ -589,7 +580,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click(); await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click(); await getMenuItem(page, 'Duplicate').click();
await expect( await expect(
page.getByText('Document duplicated successfully!'), page.getByText('Document duplicated successfully!'),
).toBeVisible(); ).toBeVisible();
@@ -604,7 +595,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 page.getByRole('menuitem', { name: 'Duplicate' }).click(); await getMenuItem(page, '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();
@@ -637,7 +628,7 @@ test.describe('Doc Header', () => {
const currentUrl = page.url(); const currentUrl = page.url();
await page.getByRole('menuitem', { name: 'Duplicate' }).click(); await getMenuItem(page, 'Duplicate').click();
await expect(page).not.toHaveURL(new RegExp(currentUrl)); await expect(page).not.toHaveURL(new RegExp(currentUrl));
@@ -676,10 +667,8 @@ 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( await expect(getMenuItem(page, 'Copy link')).toBeVisible();
page.getByRole('menuitem', { name: 'Copy link' }), await getMenuItem(page, 'Share').click();
).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();
}); });
@@ -702,7 +691,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 page.getByRole('menuitem', { name: 'Share' }).click(); await getMenuItem(page, '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.locator(selector).dispatchEvent('drop', { dataTransfer }); await page.dispatchEvent(selector, 'drop', { dataTransfer });
}; };
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common'; import { createDoc, getMenuItem, 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,19 +53,17 @@ 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 page.getByRole('menuitemradio', { name: 'Editing' }).click(); await getMenuItem(page, '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( await expect(getMenuItem(page, 'Private')).toBeDisabled();
page.getByRole('menuitemradio', { name: 'Private' }),
).toBeDisabled();
// Update child link // Update child link
await page.getByRole('menuitemradio', { name: 'Public' }).click(); await getMenuItem(page, 'Public').click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden(); await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect( await expect(
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import { import {
BROWSERS, BROWSERS,
createDoc, createDoc,
getMenuItem,
keyCloakSignIn, keyCloakSignIn,
randomName, randomName,
verifyDocName, verifyDocName,
@@ -16,41 +17,6 @@ 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(
@@ -110,21 +76,13 @@ 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( await expect(getMenuItem(page, 'Reader')).toBeVisible();
page.getByRole('menuitemradio', { name: 'Reader' }), await expect(getMenuItem(page, 'Editor')).toBeVisible();
).toBeVisible(); await expect(getMenuItem(page, 'Owner')).toBeVisible();
await expect( await expect(getMenuItem(page, 'Administrator')).toBeVisible();
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 page.getByRole('menuitemradio', { name: 'Administrator' }).click(); await getMenuItem(page, 'Administrator').click();
await page.getByTestId('doc-share-invite-button').click(); await page.getByTestId('doc-share-invite-button').click();
// Check invitation added // Check invitation added
@@ -170,7 +128,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 page.getByRole('menuitemradio', { name: 'Owner' }).click(); await getMenuItem(page, 'Owner').click();
const responsePromiseCreateInvitation = page.waitForResponse( const responsePromiseCreateInvitation = page.waitForResponse(
(response) => (response) =>
@@ -188,7 +146,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 page.getByRole('menuitemradio', { name: 'Owner' }).click(); await getMenuItem(page, 'Owner').click();
const responsePromiseCreateInvitationFail = page.waitForResponse( const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) => (response) =>
@@ -225,7 +183,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 page.getByRole('menuitemradio', { name: 'Administrator' }).click(); await getMenuItem(page, 'Administrator').click();
const responsePromiseCreateInvitation = page.waitForResponse( const responsePromiseCreateInvitation = page.waitForResponse(
(response) => (response) =>
@@ -252,13 +210,13 @@ test.describe('Document create member', () => {
); );
await userInvitation.getByTestId('doc-role-dropdown').click(); await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click(); await getMenuItem(page, '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 page.getByRole('menuitemradio', { name: 'Remove access' }).click(); await getMenuItem(page, 'Remove access').click();
await expect(userInvitation).toBeHidden(); await expect(userInvitation).toBeHidden();
}); });
@@ -310,7 +268,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 page.getByRole('menuitemradio', { name: 'Administrator' }).click(); await getMenuItem(page, '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, verifyDocName } from './utils-common'; import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share'; import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -160,9 +160,7 @@ 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( await expect(getMenuItem(page, 'Administrator')).toBeDisabled();
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
@@ -185,20 +183,18 @@ test.describe('Document list members', () => {
}); });
await currentUserRole.click(); await currentUserRole.click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click(); await getMenuItem(page, 'Administrator').click();
await list.click(); await list.click();
await expect(currentUserRole).toBeVisible(); await expect(currentUserRole).toBeVisible();
await newUserRoles.click(); await newUserRoles.click();
await expect( await expect(getMenuItem(page, 'Owner')).toBeDisabled();
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 page.getByRole('menuitemradio', { name: 'Reader' }).click(); await getMenuItem(page, 'Reader').click();
await list.click({ await list.click({
force: true, // Force click to close the dropdown force: true, // Force click to close the dropdown
}); });
@@ -238,11 +234,11 @@ test.describe('Document list members', () => {
await expect(userReader).toBeVisible(); await expect(userReader).toBeVisible();
await userReaderRole.click(); await userReaderRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click(); await getMenuItem(page, 'Remove access').click();
await expect(userReader).toBeHidden(); await expect(userReader).toBeHidden();
await mySelfRole.click(); await mySelfRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click(); await getMenuItem(page, '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, verifyDocName } from './utils-common'; import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createRootSubPage } from './utils-sub-pages'; import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -136,13 +136,9 @@ 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( await expect(getMenuItem(page, 'All docs')).toBeVisible();
page.getByRole('menuitemcheckbox', { name: 'All docs' }), await expect(getMenuItem(page, 'Current doc')).toBeVisible();
).toBeVisible(); await getMenuItem(page, 'All docs').click();
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();
}); });
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import { import {
createDoc, createDoc,
expectLoginPage, expectLoginPage,
getMenuItem,
keyCloakSignIn, keyCloakSignIn,
updateDocTitle, updateDocTitle,
verifyDocName, verifyDocName,
@@ -42,12 +43,15 @@ 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 = docTree.getByTestId(/^doc-sub-page-item/); const allSubPageItems = await docTree
await expect(allSubPageItems).toHaveCount(2); .getByTestId(/^doc-sub-page-item/)
.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.nth(0).getByText('first move')).toBeVisible(); await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible(); await expect(allSubPageItems[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();
@@ -87,15 +91,17 @@ 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 = const allSubPageItemsAfterReload = await docTree
docTree.getByTestId(/^doc-sub-page-item/); .getByTestId(/^doc-sub-page-item/)
await expect(allSubPageItemsAfterReload).toHaveCount(2); .all();
expect(allSubPageItemsAfterReload.length).toBe(2);
await expect( await expect(
allSubPageItemsAfterReload.nth(0).getByText('second move'), allSubPageItemsAfterReload[0].getByText('second move'),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
allSubPageItemsAfterReload.nth(1).getByText('first move'), allSubPageItemsAfterReload[1].getByText('first move'),
).toBeVisible(); ).toBeVisible();
}); });
@@ -157,7 +163,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 page.getByRole('menuitemradio', { name: 'Administrator' }).click(); await getMenuItem(page, 'Administrator').click();
await list.click(); await list.click();
await page.getByRole('button', { name: 'Ok' }).click(); await page.getByRole('button', { name: 'Ok' }).click();
@@ -187,9 +193,10 @@ test.describe('Doc Tree', () => {
const menu = child.getByText(`more_horiz`); const menu = child.getByText(`more_horiz`);
await menu.click(); await menu.click();
await expect( await expect(getMenuItem(page, 'Move to my docs')).toHaveAttribute(
page.getByRole('menuitem', { name: 'Move to my docs' }), 'aria-disabled',
).toHaveAttribute('aria-disabled', 'true'); 'true',
);
}); });
test('keyboard navigation with Enter key opens documents', async ({ test('keyboard navigation with Enter key opens documents', async ({
@@ -333,9 +340,7 @@ 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( await expect(getMenuItem(page, 'Remove emoji')).toBeHidden();
page.getByRole('menuitem', { name: 'Remove emoji' }),
).toBeHidden();
// Close the menu // Close the menu
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
@@ -355,7 +360,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 page.getByRole('menuitem', { name: 'Remove emoji' }).click(); await getMenuItem(page, 'Remove emoji').click();
await expect(row.getByText('😀')).toBeHidden(); await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).toBeHidden(); await expect(titleEmojiPicker).toBeHidden();
@@ -385,7 +390,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 page.getByRole('menuitemradio', { name: 'Public' }).click(); await getMenuItem(page, 'Public').click();
await expect( await expect(
page.getByText('The document visibility has been updated.'), page.getByText('The document visibility has been updated.'),
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { import {
createDoc, createDoc,
getMenuItem,
goToGridDoc, goToGridDoc,
mockedDocument, mockedDocument,
verifyDocName, verifyDocName,
@@ -20,7 +21,7 @@ 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 page.getByRole('menuitem', { name: 'Version history' }).click(); await getMenuItem(page, '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' });
@@ -74,14 +75,14 @@ test.describe('Doc Version', () => {
).toBeVisible(); ).toBeVisible();
await page.getByLabel('Open the document options').click(); await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click(); await getMenuItem(page, '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 = panel.locator('.version-item'); const items = await panel.locator('.version-item').all();
await expect(items).toHaveCount(2); expect(items.length).toBe(2);
await items.nth(1).click(); await items[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();
@@ -89,7 +90,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.nth(0).click(); await items[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();
@@ -100,7 +101,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.nth(1).click(); await items[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();
@@ -124,9 +125,7 @@ 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( await expect(getMenuItem(page, 'Version history')).toBeDisabled();
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 }) => {
@@ -153,7 +152,7 @@ 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 page.getByRole('menuitem', { name: 'Version history' }).click(); await getMenuItem(page, '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');
@@ -4,6 +4,7 @@ import {
BROWSERS, BROWSERS,
createDoc, createDoc,
expectLoginPage, expectLoginPage,
getMenuItem,
keyCloakSignIn, keyCloakSignIn,
verifyDocName, verifyDocName,
} from './utils-common'; } from './utils-common';
@@ -46,21 +47,17 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible(); await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect( await expect(getMenuItem(page, 'Read only')).toBeHidden();
page.getByRole('menuitemradio', { name: 'Read only' }), await expect(getMenuItem(page, 'Can read and edit')).toBeHidden();
).toBeHidden();
await expect(
page.getByRole('menuitemradio', { name: 'Can read and edit' }),
).toBeHidden();
await selectVisibility.click(); await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click(); await getMenuItem(page, '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 page.getByRole('menuitemradio', { name: 'Public' }).click(); await getMenuItem(page, 'Public').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible(); await expect(page.getByTestId('doc-access-mode')).toBeVisible();
}); });
@@ -205,7 +202,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 page.getByRole('menuitemradio', { name: 'Public' }).click(); await getMenuItem(page, 'Public').click();
await expect( await expect(
page.getByText('The document visibility has been updated.'), page.getByText('The document visibility has been updated.'),
@@ -213,7 +210,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 page.getByRole('menuitemradio', { name: 'Reading' }).click(); await getMenuItem(page, 'Reading').click();
await expect( await expect(
page.getByText('The document visibility has been updated.').first(), page.getByText('The document visibility has been updated.').first(),
@@ -299,14 +296,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 page.getByRole('menuitemradio', { name: 'Public' }).click(); await getMenuItem(page, '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 page.getByRole('menuitemradio', { name: 'Editing' }).click(); await getMenuItem(page, 'Editing').click();
await expect( await expect(
page.getByText('The document visibility has been updated.').first(), page.getByText('The document visibility has been updated.').first(),
@@ -390,7 +387,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 page.getByRole('menuitemradio', { name: 'Connected' }).click(); await getMenuItem(page, 'Connected').click();
await expect( await expect(
page.getByText('The document visibility has been updated.'), page.getByText('The document visibility has been updated.'),
@@ -438,7 +435,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 page.getByRole('menuitemradio', { name: 'Connected' }).click(); await getMenuItem(page, 'Connected').click();
await expect( await expect(
page.getByText('The document visibility has been updated.'), page.getByText('The document visibility has been updated.'),
@@ -536,7 +533,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 page.getByRole('menuitemradio', { name: 'Connected' }).click(); await getMenuItem(page, 'Connected').click();
await expect( await expect(
page.getByText('The document visibility has been updated.'), page.getByText('The document visibility has been updated.'),
@@ -544,7 +541,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 page.getByRole('menuitemradio', { name: 'Editing' }).click(); await getMenuItem(page, '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 { overrideConfig } from './utils-common'; import { getMenuItem, 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 page.getByRole('menuitemradio', { name: 'Français' }).click(); await getMenuItem(page, '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 page.getByRole('menuitemradio', { name: 'Français' }).click(); await getMenuItem(page, 'Français').click();
await expect( await expect(
page page
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { import {
TestLanguage, TestLanguage,
getMenuItem,
overrideConfig, overrideConfig,
waitForLanguageSwitch, waitForLanguageSwitch,
} from './utils-common'; } from './utils-common';
@@ -44,7 +45,7 @@ test.describe('Help feature', () => {
await page.getByRole('button', { name: 'Open help menu' }).click(); await page.getByRole('button', { name: 'Open help menu' }).click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click(); await getMenuItem(page, 'Onboarding').click();
const modal = page.getByTestId('onboarding-modal'); const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
@@ -87,7 +88,7 @@ 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 help menu' }).click(); await page.getByRole('button', { name: 'Open help menu' }).click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click(); await getMenuItem(page, 'Onboarding').click();
const modal = page.getByTestId('onboarding-modal'); const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
@@ -108,7 +109,7 @@ test.describe('Help feature', () => {
await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click(); await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click();
await page.getByRole('menuitem', { name: 'Premiers pas' }).click(); await getMenuItem(page, '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="menuitemradio"]'); const menuItems = page.locator('[role="menuitem"], [role="menuitemradio"]');
await expect(menuItems.first()).toBeVisible(); await expect(menuItems.first()).toBeVisible();
await menuItems.first().click(); await menuItems.first().click();
@@ -3,6 +3,16 @@ 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';
@@ -382,12 +392,12 @@ export async function waitForLanguageSwitch(
await languagePicker.click(); await languagePicker.click();
await page.getByRole('menuitemradio', { name: lang.label }).click(); await getMenuItem(page, 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 page.getByRole('menuitem', { name: textButton }).click(); await getMenuItem(page, textButton).click();
}; };
export const clickInGridMenu = async ( export const clickInGridMenu = async (
@@ -398,7 +408,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 page.getByRole('menuitem', { name: textButton }).click(); await getMenuItem(page, textButton).click();
}; };
export const writeReport = async ( export const writeReport = async (
@@ -2,6 +2,7 @@ import { Page, chromium, expect } from '@playwright/test';
import { import {
BrowserName, BrowserName,
getMenuItem,
getOtherBrowserName, getOtherBrowserName,
keyCloakSignIn, keyCloakSignIn,
verifyDocName, verifyDocName,
@@ -39,7 +40,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 page.getByRole('menuitemradio', { name: role }).click(); await getMenuItem(page, 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;
@@ -51,7 +52,7 @@ export const updateShareLink = async (
linkRole?: LinkRole | null, linkRole?: LinkRole | null,
) => { ) => {
await page.getByTestId('doc-visibility').click(); await page.getByTestId('doc-visibility').click();
await page.getByRole('menuitemradio', { name: linkReach }).click(); await getMenuItem(page, linkReach).click();
const visibilityUpdatedText = page const visibilityUpdatedText = page
.getByText('The document visibility has been updated') .getByText('The document visibility has been updated')
@@ -61,7 +62,7 @@ export const updateShareLink = async (
if (linkRole) { if (linkRole) {
await page.getByTestId('doc-access-mode').click(); await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: linkRole }).click(); await getMenuItem(page, linkRole).click();
await expect(visibilityUpdatedText).toBeVisible(); await expect(visibilityUpdatedText).toBeVisible();
} }
}; };
@@ -76,7 +77,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 page.getByRole('menuitemradio', { name: role }).click(); await getMenuItem(page, role).click();
await list.click(); await list.click();
}; };
+21 -20
View File
@@ -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.45", "@ai-sdk/openai": "3.0.19",
"@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.38", "@fontsource-variable/material-symbols-outlined": "5.2.35",
"@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.10", "@gouvfr-lasuite/ui-kit": "0.19.6",
"@hocuspocus/provider": "3.4.4", "@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.17", "@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.17", "@mantine/hooks": "8.3.14",
"@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.43.0", "@sentry/nextjs": "10.38.0",
"@tanstack/react-query": "5.90.21", "@tanstack/react-query": "5.90.21",
"@tiptap/extensions": "*", "@tiptap/extensions": "*",
"ai": "6.0.128", "ai": "6.0.49",
"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.18", "i18next": "25.8.12",
"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.7", "next": "16.1.7",
"posthog-js": "1.360.2", "posthog-js": "1.347.2",
"react": "*", "react": "*",
"react-aria-components": "1.16.0", "react-aria-components": "1.15.1",
"react-dom": "*", "react-dom": "*",
"react-dropzone": "15.0.0", "react-dropzone": "15.0.0",
"react-i18next": "16.5.8", "react-i18next": "16.5.4",
"react-intersection-observer": "10.0.3", "react-intersection-observer": "10.0.2",
"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.11", "styled-components": "6.3.9",
"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": "4.3.6", "zod": "3.25.28",
"zustand": "5.0.12" "zustand": "5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@svgr/webpack": "8.1.0", "@svgr/webpack": "8.1.0",
@@ -89,25 +89,26 @@
"@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.24", "@types/lodash": "4.17.23",
"@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": "6.0.1", "@vitejs/plugin-react": "5.1.4",
"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": "29.0.0", "jsdom": "28.1.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": "*",
"vitest": "4.1.0", "vite-tsconfig-paths": "6.1.1",
"webpack": "5.105.4", "vitest": "4.0.18",
"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"
@@ -1,17 +1,17 @@
import { Ref, forwardRef } from 'react'; import { 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 = Omit<BoxType, 'ref'> & { export type BoxButtonType = BoxType & {
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 = Omit<BoxType, 'ref'> & {
* </BoxButton> * </BoxButton>
* ``` * ```
*/ */
const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>( const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, disabled, ...props }, ref) => { ({ $css, ...props }, ref) => {
const theme = props.$theme || 'gray'; const theme = props.$theme || 'gray';
const variation = props.$variation || 'primary'; const variation = props.$variation || 'primary';
@@ -31,18 +31,16 @@ const BoxButton = forwardRef<HTMLButtonElement, 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: ${disabled ? 'not-allowed' : 'pointer'}; cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
border: none; border: none;
outline: none; outline: none;
font-family: inherit; font-family: inherit;
color: ${disabled && color: ${props.disabled &&
`var(--c--contextuals--content--semantic--disabled--primary)`}; `var(--c--contextuals--content--semantic--disabled--primary)`};
&:focus-visible { &:focus-visible {
transition: none; transition: none;
@@ -55,11 +53,11 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
`} `}
{...props} {...props}
className={`--docs--box-button ${props.className || ''}`} className={`--docs--box-button ${props.className || ''}`}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => { onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) { if (props.disabled) {
return; return;
} }
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>); props.onClick?.(event);
}} }}
/> />
); );
@@ -26,7 +26,6 @@ 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>;
@@ -70,10 +69,7 @@ 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<(HTMLButtonElement | null)[]>([]); const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
const isSingleSelectable = options.some(
(option) => option.isSelected !== undefined,
);
const onOpenChange = useCallback( const onOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
@@ -114,6 +110,10 @@ export const DropdownMenu = ({
[onOpenChange], [onOpenChange],
); );
const hasSelectable =
selectedValues !== undefined ||
options.some((option) => option.isSelected !== undefined);
if (disabled) { if (disabled) {
return children; return children;
} }
@@ -176,25 +176,20 @@ 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 isSelected = const ariaChecked = hasSelectable
option.isSelected === true || ? option.isSelected ||
(selectedValues?.includes(option.value ?? '') ?? false); selectedValues?.includes(option.value ?? '') ||
const itemRole = false
selectedValues !== undefined : undefined;
? 'menuitemcheckbox'
: isSingleSelectable
? 'menuitemradio'
: 'menuitem';
const optionKey = option.value ?? option.testId ?? `option-${index}`;
return ( return (
<Fragment key={optionKey}> <Fragment key={option.label}>
<BoxButton <BoxButton
ref={(el) => { ref={(el) => {
menuItemRefs.current[index] = el; menuItemRefs.current[index] = el;
}} }}
role={itemRole} role={hasSelectable ? 'menuitemradio' : 'menuitem'}
aria-checked={itemRole === 'menuitem' ? undefined : isSelected} aria-checked={ariaChecked}
data-testid={option.testId} data-testid={option.testId}
$direction="row" $direction="row"
disabled={isDisabled} disabled={isDisabled}
@@ -205,6 +200,7 @@ 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)"
@@ -275,16 +271,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'}>
<span lang={option.lang}>{option.label}</span> {option.label}
</Text> </Text>
</Box> </Box>
{isSelected && ( {(option.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 menuitemcheckbox role with aria-checked when selectedValues is provided', async () => { test('renders menuitemradio 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 checkboxes = screen.getAllByRole('menuitemcheckbox'); const radios = screen.getAllByRole('menuitemradio');
expect(checkboxes).toHaveLength(3); expect(radios).toHaveLength(3);
expect(checkboxes[0]).toHaveAttribute('aria-checked', 'false'); expect(radios[0]).toHaveAttribute('aria-checked', 'false');
expect(checkboxes[1]).toHaveAttribute('aria-checked', 'true'); expect(radios[1]).toHaveAttribute('aria-checked', 'true');
expect(checkboxes[2]).toHaveAttribute('aria-checked', 'false'); expect(radios[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<(HTMLButtonElement | null)[]>; menuItemRefs: RefObject<(HTMLDivElement | null)[]>;
setFocusedIndex: (index: number) => void; setFocusedIndex: (index: number) => void;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
}; };
@@ -48,7 +48,7 @@ export const QuickSearchInput = ({
$direction="row" $direction="row"
$align="center" $align="center"
className="quick-search-input" className="quick-search-input"
$gap={spacingsTokens['xxs']} $gap={spacingsTokens['2xs']}
$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" />
@@ -62,7 +62,6 @@ 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,15 +18,14 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-input] { [cmdk-input] {
border: none; border: none;
width: 100%; width: 100%;
font-size: 16px; font-size: 17px;
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--contextuals--content--semantic--neutral--tertiary); color: var(--c--globals--colors--gray-500);
} }
} }
@@ -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/api/useDocVersions'; import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { toBase64 } from '@/utils/string'; import { toBase64 } from '@/utils/string';
import { isFirefox } from '@/utils/userAgent'; import { isFirefox } from '@/utils/userAgent';
@@ -1,9 +1,4 @@
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', () => {
@@ -21,12 +16,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);
}); });
@@ -1,4 +1,6 @@
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';
@@ -38,11 +40,17 @@ 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(
await screen.findByLabelText('Export the document'), screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeInTheDocument(); ).toBeInTheDocument();
}, 15000); }, 10000);
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';
@@ -60,5 +68,5 @@ describe('DocToolBox - Licence', () => {
expect( expect(
screen.queryByLabelText('Export the document'), screen.queryByLabelText('Export the document'),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}, 15000); });
}); });
@@ -1,8 +1,9 @@
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 { useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
@@ -38,6 +39,7 @@ 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';
@@ -86,6 +88,7 @@ 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);
@@ -111,6 +114,16 @@ 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();
@@ -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 '@/docs/doc-editor/stores/useEditorStore'; import { useEditorStore } from '../../doc-editor';
export const useCopyCurrentEditorToClipboard = () => { export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore(); const { editor } = useEditorStore();
@@ -21,8 +21,8 @@ export const useCopyCurrentEditorToClipboard = () => {
try { try {
const editorContentFormatted = const editorContentFormatted =
asFormat === 'html' asFormat === 'html'
? editor.blocksToHTMLLossy() ? await editor.blocksToHTMLLossy()
: editor.blocksToMarkdownLossy(); : await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted); await navigator.clipboard.writeText(editorContentFormatted);
const successMessage = const successMessage =
asFormat === 'markdown' asFormat === 'markdown'
@@ -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.resetQueries({ void queryClient.invalidateQueries({
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<HTMLButtonElement>(null); const iconRef = useRef<HTMLDivElement>(null);
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false); const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
const [pickerPosition, setPickerPosition] = useState<{ const [pickerPosition, setPickerPosition] = useState<{
@@ -14,11 +14,6 @@ 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();
@@ -124,8 +124,7 @@ export const DocShareAddMemberList = ({
$scope="surface" $scope="surface"
$theme="tertiary" $theme="tertiary"
$variation="" $variation=""
$border="1px solid var(--c--contextuals--border--surface--primary)" $border="1px solid var(--c--contextuals--border--semantic--contextual--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', top: 'base' }}> <Box $padding={{ horizontal: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} /> <QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} /> <QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} /> <QuickSearchGroupMember doc={doc} />
@@ -301,7 +301,6 @@ 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>
@@ -322,35 +321,14 @@ 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 || [];
@@ -369,7 +347,7 @@ const QuickSearchInviteInputSection = ({
); );
return { return {
groupName: hint, groupName: t('Search user result'),
elements: users, elements: users,
endActions: endActions:
isEmail && !hasEmailInUsers isEmail && !hasEmailInUsers
@@ -381,12 +359,12 @@ const QuickSearchInviteInputSection = ({
] ]
: undefined, : undefined,
}; };
}, [searchUsersRawData, userQuery, hint, onSelect]); }, [onSelect, searchUsersRawData, t, userQuery]);
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', top: 'base' }} $padding={{ horizontal: 'base', bottom: '3xs' }}
> >
<QuickSearchGroup <QuickSearchGroup
group={searchUserData} group={searchUserData}
@@ -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<HTMLButtonElement | null>(null); const buttonOptionRef = useRef<HTMLDivElement | 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;
@@ -44,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<HTMLButtonElement | null>(null); const rootButtonOptionRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -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<HTMLButtonElement | null>; buttonOptionRef?: React.RefObject<HTMLDivElement | 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<HTMLButtonElement | null>(null); const internalButtonRef = useRef<HTMLDivElement | null>(null);
const targetButtonRef = buttonOptionRef ?? internalButtonRef; const targetButtonRef = buttonOptionRef ?? internalButtonRef;
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -4,12 +4,9 @@ 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 } from '@/docs/doc-editor/components/BlockNoteEditor'; import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
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';
@@ -1,4 +1,4 @@
import { Doc } from '../doc-management/types'; import { Doc } from '../doc-management';
export interface APIListVersions { export interface APIListVersions {
count: number; count: number;
@@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from '@testing-library/react'; import { 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,9 +73,7 @@ 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 act(async () => { await i18next.changeLanguage('fr');
await i18next.changeLanguage('fr');
});
render( render(
<DocsGridItemDate <DocsGridItemDate
@@ -92,9 +90,7 @@ describe('DocsGridItemDate', () => {
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument(); expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
await act(async () => { await i18next.changeLanguage('en');
await i18next.changeLanguage('en');
});
}); });
[ [
@@ -1,4 +1,3 @@
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';
@@ -18,35 +17,23 @@ 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: async () => { callback: () => changeLanguageSynchronized(backendLocale, user),
await changeLanguageSynchronized(backendLocale, user);
announce(
t('Language changed to {{language}}', {
language: backendLabel,
defaultValue: `Language changed to ${backendLabel}`,
}),
'polite',
);
},
}; };
}); });
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, t, user]); }, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
// Extract current language label for display // Extract current language label for display
const [currentLanguageCode, currentLanguageLabel] = conf?.LANGUAGES.find( const currentLanguageLabel =
([code]) => getMatchingLocales([code], [language]).length > 0, conf?.LANGUAGES.find(
) ?? [language, language]; ([code]) => getMatchingLocales([code], [language]).length > 0,
)?.[1] || language;
return ( return (
<DropdownMenu <DropdownMenu
@@ -78,9 +65,7 @@ export const LanguagePicker = () => {
$align="center" $align="center"
> >
<Icon iconName="translate" $color="inherit" $size="xl" /> <Icon iconName="translate" $color="inherit" $size="xl" />
<span lang={toLangTag(currentLanguageCode)}> {currentLanguageLabel}
{currentLanguageLabel}
</span>
</Box> </Box>
</DropdownMenu> </DropdownMenu>
); );
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/../package.json', () => ({
default: { version: '0.0.0' },
}));
describe('DocsDB', () => { describe('DocsDB', () => {
beforeEach(() => { afterEach(() => {
vi.clearAllMocks();
vi.resetModules(); vi.resetModules();
}); });
@@ -15,16 +20,17 @@ 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}`, async () => { it(`correctly computes version for ${version}`, () => {
vi.doMock('@/../package.json', () => ({ vi.doMock('@/../package.json', () => ({
default: { version }, default: { version },
})); }));
const module = await import('../DocsDB'); return vi.importActual('../DocsDB').then((module: any) => {
const result = (module as any).getCurrentVersion(); const result = module.getCurrentVersion();
expect(result).toBe(expected); expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected); expect(result).toBeGreaterThan(previousExpected);
previousExpected = result; previousExpected = result;
});
}); });
}); });
}); });
+8 -4
View File
@@ -1,9 +1,16 @@
/// <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: [react()], plugins: [
react(),
tsconfigPaths({
root: '.',
projects: ['./tsconfig.json'],
}),
],
test: { test: {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
@@ -15,7 +22,4 @@ export default defineConfig({
define: { define: {
'process.env.NODE_ENV': 'test', 'process.env.NODE_ENV': 'test',
}, },
resolve: {
tsconfigPaths: true,
},
}); });
+4 -4
View File
@@ -32,17 +32,17 @@
}, },
"resolutions": { "resolutions": {
"@tiptap/extensions": "3.19.0", "@tiptap/extensions": "3.19.0",
"@types/node": "24.12.0", "@types/node": "24.10.13",
"@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.3", "eslint": "10.0.1",
"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": "10.0.0", "wrap-ansi": "9.0.2",
"yjs": "13.6.30" "yjs": "13.6.29"
}, },
"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-x'); const importPlugin = require('eslint-plugin-import');
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');
@@ -18,22 +18,22 @@
}, },
"dependencies": { "dependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@next/eslint-plugin-next": "16.1.7", "@next/eslint-plugin-next": "16.1.6",
"@tanstack/eslint-plugin-query": "5.91.4", "@tanstack/eslint-plugin-query": "5.91.4",
"@typescript-eslint/eslint-plugin": "8.57.1", "@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.57.1", "@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/utils": "8.57.1", "@typescript-eslint/utils": "8.56.0",
"@vitest/eslint-plugin": "1.6.12", "@vitest/eslint-plugin": "1.6.9",
"eslint-config-next": "16.1.7", "eslint-config-next": "16.1.6",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-import-x": "4.16.2", "eslint-plugin-import": "2.32.0",
"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.10.0", "eslint-plugin-playwright": "2.5.1",
"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": "7.0.1", "eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-testing-library": "7.16.0", "eslint-plugin-testing-library": "7.15.4",
"prettier": "3.8.1" "prettier": "3.8.1"
}, },
"packageManager": "yarn@1.22.22" "packageManager": "yarn@1.22.22"
+2 -2
View File
@@ -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.4.0", "i18next-parser": "9.3.0",
"jest": "30.3.0", "jest": "30.2.0",
"ts-jest": "29.4.6", "ts-jest": "29.4.6",
"typescript": "*", "typescript": "*",
"yargs": "18.0.0" "yargs": "18.0.0"
@@ -51,8 +51,6 @@ RUN NODE_ENV=production yarn install --frozen-lockfile
# Remove npm, contains CVE related to cross-spawn and we don't use it. # Remove npm, contains CVE related to cross-spawn and we don't use it.
RUN rm -rf /usr/local/bin/npm /usr/local/lib/node_modules/npm RUN rm -rf /usr/local/bin/npm /usr/local/lib/node_modules/npm
ENV NODE_OPTIONS="--max-old-space-size=2048"
# Un-privileged user running the application # Un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
USER ${DOCKER_USER} USER ${DOCKER_USER}
@@ -1,6 +1,6 @@
import { ServerBlockNoteEditor } from '@blocknote/server-util'; import { ServerBlockNoteEditor } from '@blocknote/server-util';
import request from 'supertest'; import request from 'supertest';
import { afterEach, describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
vi.mock('../src/env', async (importOriginal) => { vi.mock('../src/env', async (importOriginal) => {
@@ -62,11 +62,7 @@ const expectedBlocks = [
console.error = vi.fn(); console.error = vi.fn();
describe('Conversion Testing', () => { describe('Server Tests', () => {
afterEach(() => {
vi.clearAllMocks();
});
test('POST /api/convert with incorrect API key responds with 401', async () => { test('POST /api/convert with incorrect API key responds with 401', async () => {
const app = initApp(); const app = initApp();
@@ -174,7 +170,6 @@ describe('Conversion Testing', () => {
}); });
test('POST /api/convert BlockNote to Yjs', async () => { test('POST /api/convert BlockNote to Yjs', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp(); const app = initApp();
const editor = ServerBlockNoteEditor.create(); const editor = ServerBlockNoteEditor.create();
const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown); const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown);
@@ -197,7 +192,6 @@ describe('Conversion Testing', () => {
const decodedBlocks = editor.yDocToBlocks(ydoc, 'document-store'); const decodedBlocks = editor.yDocToBlocks(ydoc, 'document-store');
expect(decodedBlocks).toStrictEqual(expectedBlocks); expect(decodedBlocks).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
}); });
test('POST /api/convert BlockNote to HTML', async () => { test('POST /api/convert BlockNote to HTML', async () => {
@@ -259,7 +253,6 @@ describe('Conversion Testing', () => {
}); });
test('POST /api/convert Yjs to JSON', async () => { test('POST /api/convert Yjs to JSON', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp(); const app = initApp();
const editor = ServerBlockNoteEditor.create(); const editor = ServerBlockNoteEditor.create();
const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown); const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown);
@@ -279,7 +272,6 @@ describe('Conversion Testing', () => {
); );
expect(response.body).toBeInstanceOf(Array); expect(response.body).toBeInstanceOf(Array);
expect(response.body).toStrictEqual(expectedBlocks); expect(response.body).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
}); });
test('POST /api/convert Markdown to JSON', async () => { test('POST /api/convert Markdown to JSON', async () => {
@@ -301,7 +293,6 @@ describe('Conversion Testing', () => {
}); });
test('POST /api/convert with invalid Yjs content returns 400', async () => { test('POST /api/convert with invalid Yjs content returns 400', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp(); const app = initApp();
const response = await request(app) const response = await request(app)
.post('/api/convert') .post('/api/convert')
@@ -313,6 +304,5 @@ describe('Conversion Testing', () => {
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body).toStrictEqual({ error: 'Invalid content' }); expect(response.body).toStrictEqual({ error: 'Invalid content' });
expect(destroySpy).toHaveBeenCalledTimes(1);
}); });
}); });
+6 -6
View File
@@ -18,10 +18,10 @@
"dependencies": { "dependencies": {
"@blocknote/server-util": "0.47.1", "@blocknote/server-util": "0.47.1",
"@hocuspocus/server": "3.4.4", "@hocuspocus/server": "3.4.4",
"@sentry/node": "10.43.0", "@sentry/node": "10.38.0",
"@sentry/profiling-node": "10.43.0", "@sentry/profiling-node": "10.38.0",
"@tiptap/extensions": "*", "@tiptap/extensions": "*",
"axios": "1.13.6", "axios": "1.13.5",
"cors": "2.8.6", "cors": "2.8.6",
"express": "5.2.1", "express": "5.2.1",
"express-ws": "5.0.2", "express-ws": "5.0.2",
@@ -36,16 +36,16 @@
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/express-ws": "3.0.6", "@types/express-ws": "3.0.6",
"@types/node": "*", "@types/node": "*",
"@types/supertest": "7.2.0", "@types/supertest": "6.0.3",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"eslint-plugin-docs": "*", "eslint-plugin-docs": "*",
"nodemon": "3.1.14", "nodemon": "3.1.11",
"supertest": "7.2.2", "supertest": "7.2.2",
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"typescript": "*", "typescript": "*",
"vitest": "4.1.0", "vitest": "4.0.18",
"vitest-mock-extended": "3.1.0", "vitest-mock-extended": "3.1.0",
"ws": "8.19.0" "ws": "8.19.0"
}, },
@@ -60,12 +60,8 @@ const readers: InputReader[] = [
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream], supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
read: async (data) => { read: async (data) => {
const ydoc = new Y.Doc(); const ydoc = new Y.Doc();
try { Y.applyUpdate(ydoc, data);
Y.applyUpdate(ydoc, data); return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
} finally {
ydoc.destroy();
}
}, },
}, },
{ {
@@ -81,14 +77,7 @@ const writers: OutputWriter[] = [
}, },
{ {
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream], supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
write: async (blocks) => { write: async (blocks) => Y.encodeStateAsUpdate(createYDocument(blocks)),
const ydoc = createYDocument(blocks);
try {
return Y.encodeStateAsUpdate(ydoc);
} finally {
ydoc.destroy();
}
},
}, },
{ {
supportedContentTypes: [ContentTypes.Markdown, ContentTypes.XMarkdown], supportedContentTypes: [ContentTypes.Markdown, ContentTypes.XMarkdown],
+2037 -2350
View File
File diff suppressed because it is too large Load Diff
@@ -145,7 +145,6 @@ yProvider:
COLLABORATION_SERVER_ORIGIN: https://{{ .Values.feature }}-docs.{{ .Values.domain }} COLLABORATION_SERVER_ORIGIN: https://{{ .Values.feature }}-docs.{{ .Values.domain }}
COLLABORATION_SERVER_SECRET: my-secret COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret Y_PROVIDER_API_KEY: my-secret
NODE_OPTIONS: "--max-old-space-size=1024"
docSpec: docSpec:
enabled: true enabled: true
-3
View File
@@ -7,9 +7,6 @@
"@html-to/text-cli": "0.5.4", "@html-to/text-cli": "0.5.4",
"mjml": "4.18.0" "mjml": "4.18.0"
}, },
"resolutions": {
"minimatch": "^9.0.7"
},
"private": true, "private": true,
"scripts": { "scripts": {
"build-mjml-to-html": "bash ./bin/mjml-to-html", "build-mjml-to-html": "bash ./bin/mjml-to-html",
+13 -6
View File
@@ -110,7 +110,7 @@ boolbase@^1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
brace-expansion@^2.0.2: brace-expansion@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
@@ -562,12 +562,19 @@ mime@^2.4.6:
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
minimatch@9.0.1, minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.7: minimatch@9.0.1:
version "9.0.9" version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
dependencies: dependencies:
brace-expansion "^2.0.2" 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"
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
version "7.1.2" version "7.1.2"