Compare commits

..

7 Commits

Author SHA1 Message Date
charles
2213d7a3af (backend) update document.updated_at on restore
If a file is restored, its deleted_at field is deleted,
and its `updated_at` and `ancestors_deleted_at` fields are
not updated to the current date. this prevents using the crash
safe mode.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-04-28 15:26:44 +02:00
charles
c75f9b7843 (backend) update make index command
the index command has new params to add on the make command

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-04-24 12:02:34 +02:00
charles
9d7f876705 (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>

(backend) add async_mode flag

the command must be killable.
this adds a async_mode flag to preserve async
feature and allow running sync.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-04-24 12:02:34 +02:00
charles
71ff92097c 📝(backend) add docs
i am documenting the index command

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-04-24 12:02:33 +02:00
charles
3427c68ae2 (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
2026-04-24 12:02:33 +02:00
charles
718184477b (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-04-24 10:40:39 +02:00
charles
a223e49509 📝(backend) improve docstrings
i think some docstrings, about a counter, were outdated
i add more details to help understand the logic
2026-03-30 17:50:19 +02:00
75 changed files with 3213 additions and 2909 deletions

View File

@@ -6,19 +6,13 @@ and this project adheres to
## [Unreleased]
### Added
- 🚸(frontend) hint min char search users #2064
### Changed
- ✨(backend) improve indexing command
- checkpoint recovery
- asynchronicity
- admin command trigger
- 💄(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

View File

@@ -260,7 +260,7 @@ demo: ## flush db then create a demo for load testing purpose
.PHONY: demo
index: ## index all documents to remote search
@$(MANAGE) index
@$(MANAGE) index $(args)
.PHONY: index
# Nota bene: Black should come after isort just in case they don't agree...

63
docs/commands/index.md Normal file
View File

@@ -0,0 +1,63 @@
# Index Command
The `index` management command is used to index documents to the remote search indexer.
## Usage
### Make Command
```bash
# Basic usage with defaults
make index
# With custom parameters
make index args="--batch-size 100 --lower-time-bound 2024-01-01T00:00:00 --upper-time-bound 2026-01-01T00:00:00"
```
### 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 \
--async
```
### Django Admin
The command is available in the Django admin interface:
1. Go to `/admin/run-indexing/`, you arrive at the "Run Indexing Command" page
2. Fill in the form with the desired parameters
3. Click **"Run Indexing Command"**
## Parameters
### `--batch-size`
- **type:** Integer
- **default:** `settings.SEARCH_INDEXER_BATCH_SIZE`
- **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**: true
- **type:** ISO 8601 datetime string
- **default:** `None`
- **description:** Only documents updated before this date will be indexed.
### `--async`
- **type:** Boolean flag
- **default:** `False`
+- **description:** When set, dispatches the indexing job to a Celery worker instead of running it synchronously.
## Crash Safe Mode
The command saves the `updated_at` of the last document of each successful batch into the `bulk-indexer-checkpoint` cache variable.
If the process crashes, this value can be used as `lower-time-bound` to resume from the last successfully indexed document.

View File

@@ -60,13 +60,10 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@react-pdf/renderer",
"fetch-mock",
"node",
"node-fetch",
"react-resizable-panels",
"stylelint",
"stylelint-config-standard",
"workbox-webpack-plugin"
]
}

View File

@@ -1,15 +1,54 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin, messages
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth import admin as auth_admin
from django.shortcuts import redirect
from django.core.management import call_command
from django.http import HttpRequest
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from core import models
from core.forms import RunIndexingForm
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
# Customize the default admin site's get_app_list method
_original_get_app_list = admin.site.get_app_list
def custom_get_app_list(_self, request, app_label=None):
"""Add custom commands to the app list."""
app_list = _original_get_app_list(request, app_label)
# Add Commands app with Run Indexing command
commands_app = {
"name": _("Commands"),
"app_label": "commands",
"app_url": "#",
"has_module_perms": True,
"models": [
{
"name": _("Run indexing"),
"object_name": "RunIndexing",
"admin_url": "/admin/run-indexing/",
"view_only": False,
"add_url": None,
"change_url": None,
}
],
}
app_list.append(commands_app)
return app_list
# Monkey-patch the admin site
admin.site.get_app_list = lambda request, app_label=None: custom_get_app_list(
admin.site, request, app_label
)
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
@@ -227,3 +266,39 @@ class InvitationAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()
@staff_member_required
def run_indexing_view(request: HttpRequest):
"""Custom admin view for running indexing commands."""
if request.method == "POST":
form = RunIndexingForm(request.POST)
if form.is_valid():
lower_time_bound = form.cleaned_data.get("lower_time_bound")
upper_time_bound = form.cleaned_data.get("upper_time_bound")
call_command(
"index",
batch_size=form.cleaned_data["batch_size"],
lower_time_bound=lower_time_bound.isoformat()
if lower_time_bound
else None,
upper_time_bound=upper_time_bound.isoformat()
if upper_time_bound
else None,
async_mode=True,
)
messages.success(request, _("Indexing triggered!"))
return redirect("run_indexing")
messages.error(request, _("Please correct the errors below."))
else:
form = RunIndexingForm()
return render(
request=request,
template_name="runindexing.html",
context={
**admin.site.each_context(request),
"title": "Run Indexing Command",
"form": form,
},
)

View File

@@ -6,6 +6,7 @@ from django.conf import settings
from django.contrib.auth.hashers import make_password
import factory.fuzzy
from factory import post_generation
from faker import Faker
from core import models
@@ -159,6 +160,20 @@ class DocumentFactory(factory.django.DjangoModelFactory):
document=self, user=item, defaults={"is_masked": True}
)
@post_generation
def updated_at(self, create, extracted, **kwargs):
"""
the BaseModel.updated_at has auto_now=True.
This prevents setting a specific updated_at value with the factory.
This post_generation method bypasses this behavior.
"""
if not create or not extracted:
return
self.__class__.objects.filter(pk=self.pk).update(updated_at=extracted)
self.refresh_from_db()
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""

42
src/backend/core/forms.py Normal file
View File

@@ -0,0 +1,42 @@
"""Forms for the core app."""
from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _
class RunIndexingForm(forms.Form):
"""
Form for running the indexing process.
"""
batch_size = forms.IntegerField(
min_value=1,
initial=settings.SEARCH_INDEXER_BATCH_SIZE,
)
lower_time_bound = forms.DateTimeField(
required=False, widget=forms.TextInput(attrs={"type": "datetime-local"})
)
upper_time_bound = forms.DateTimeField(
required=False, widget=forms.TextInput(attrs={"type": "datetime-local"})
)
def clean(self):
"""Override clean to validate time bounds."""
cleaned_data = super().clean()
self.check_time_bounds()
return cleaned_data
def check_time_bounds(self):
"""Validate that lower_time_bound is before upper_time_bound."""
lower_time_bound = self.cleaned_data.get("lower_time_bound")
upper_time_bound = self.cleaned_data.get("upper_time_bound")
if (
lower_time_bound
and upper_time_bound
and lower_time_bound > upper_time_bound
):
self.add_error(
"upper_time_bound",
_("Upper time bound must be after lower time bound."),
)

View File

@@ -4,12 +4,16 @@ Handle search setup that needs to be done at bootstrap time.
import logging
import time
from datetime import datetime
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from core import models
from core.services.search_indexers import get_document_indexer
from core.tasks.search import batch_document_indexer_task
logger = logging.getLogger("docs.search.bootstrap_search")
logger = logging.getLogger(__name__)
class Command(BaseCommand):
@@ -24,9 +28,32 @@ class Command(BaseCommand):
action="store",
dest="batch_size",
type=int,
default=50,
default=settings.SEARCH_INDEXER_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(
"--async",
action="store_true",
dest="async_mode",
default=False,
help="Whether to execute indexing asynchronously in a Celery task (default: False)",
)
def handle(self, *args, **options):
"""Launch and log search index generation."""
@@ -35,18 +62,38 @@ class Command(BaseCommand):
if not indexer:
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"]
if options["async_mode"]:
try:
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": True,
},
)
except Exception as err:
raise CommandError("Unable to dispatch indexing task") from err
logger.info("Document indexing task sent to worker")
else:
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
try:
count = indexer.index(batch_size=batch_size)
except Exception as err:
raise CommandError("Unable to regenerate index") from err
try:
count = indexer.index(
queryset=models.Document.objects.filter_updated_at(
lower_time_bound=options["lower_time_bound"],
upper_time_bound=options["upper_time_bound"],
),
batch_size=options["batch_size"],
crash_safe_mode=True,
)
except Exception as err:
raise CommandError("Unable to regenerate index") from err
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)

View File

@@ -859,6 +859,32 @@ class DocumentQuerySet(MP_NodeQuerySet):
user_roles=models.Value([], output_field=output_field),
)
def filter_updated_at(self, lower_time_bound=None, upper_time_bound=None):
"""
Filter documents by update_at.
Args:
lower_time_bound (datetime, optional):
Keep documents updated after this timestamp.
upper_time_bound (datetime, optional):
Keep documents updated before this timestamp.
Returns:
QuerySet: Filtered queryset ready for indexation.
"""
conditions = models.Q()
if lower_time_bound and upper_time_bound:
conditions = models.Q(
updated_at__gte=lower_time_bound,
updated_at__lte=upper_time_bound,
)
elif lower_time_bound:
conditions = models.Q(updated_at__gte=lower_time_bound)
elif upper_time_bound:
conditions = models.Q(updated_at__lte=upper_time_bound)
return self.filter(conditions)
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
"""
@@ -1459,13 +1485,16 @@ class Document(MP_Node, BaseModel):
.first()
)
self.ancestors_deleted_at = ancestors_deleted_at
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
self.save(update_fields=["deleted_at", "ancestors_deleted_at", "updated_at"])
self.invalidate_nb_accesses_cache()
self.get_descendants().exclude(
models.Q(deleted_at__isnull=False)
| models.Q(ancestors_deleted_at__lt=current_deleted_at)
).update(ancestors_deleted_at=self.ancestors_deleted_at)
).update(
ancestors_deleted_at=self.ancestors_deleted_at,
updated_at=self.updated_at,
)
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(

View File

@@ -1,5 +1,6 @@
"""Document search index management utilities and indexers"""
import itertools
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
@@ -125,44 +126,44 @@ class BaseDocumentIndexer(ABC):
if not self.search_url:
raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
def index(self, queryset=None, batch_size=None):
def index(self, queryset, batch_size=None, crash_safe_mode=False):
"""
Fetch documents in batches, serialize them, and push to the search backend.
Args:
queryset (optional): Document queryset
Defaults to all documents without filter.
queryset: Document queryset
batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
crash_safe_mode (bool, optional): If True, order documents by updated_at
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
queryset = queryset or models.Document.objects.all()
batch_size = batch_size or self.batch_size
while True:
documents_batch = list(
queryset.filter(
id__gt=last_id,
).order_by("id")[:batch_size]
)
if not documents_batch:
break
if crash_safe_mode:
queryset = queryset.order_by("updated_at")
for documents_batch in itertools.batched(queryset.iterator(), batch_size):
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)
serialized_batch = [
self.serialize_document(document, accesses_by_document_path)
for document in documents_batch
if document.content or document.title
]
if serialized_batch:
self.push(serialized_batch)
count += len(serialized_batch)
if not serialized_batch:
continue
self.push(serialized_batch)
count += len(serialized_batch)
if crash_safe_mode:
logger.info(
"Indexing checkpoint: %s.",
serialized_batch[-1]["updated_at"],
)
return count

View File

@@ -4,7 +4,6 @@ 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
@@ -20,7 +19,12 @@ logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
"""
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:
@@ -30,8 +34,17 @@ def document_indexer_task(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
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"
@@ -41,44 +54,65 @@ def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
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
# 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(timestamp):
"""Celery Task : Sends indexation query for a batch of documents."""
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, optional):
indexes documents updated or deleted after this timestamp.
upper_time_bound (datetime, optional):
indexes documents updated or deleted before this timestamp.
"""
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)
)
if not indexer:
logger.warning("Indexing task triggered but no indexer configured: skipping")
return
count = indexer.index(queryset)
logger.info("Indexed %d documents", count)
count = indexer.index(
queryset=models.Document.objects.filter_updated_at(
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 indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
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.
document (Document): the document instance that triggered the indexation.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation if disabled
# DO NOT create a task if indexation is 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.
# 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",
@@ -87,7 +121,7 @@ def trigger_batch_document_indexer(document):
)
batch_document_indexer_task.apply_async(
args=[document.updated_at], countdown=countdown
kwargs={"lower_time_bound": document.updated_at}, countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", document.pk)

View File

@@ -0,0 +1,22 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<form method="POST" >
{% csrf_token %}
<hr style="margin-bottom: 10px;">
<p>
{% translate "This command triggers the indexing of all documents within the specified time bound." %}
</p>
<hr style="margin-bottom: 20px;">
{{ form.as_p }}
<input type="submit" value="{% translate 'Run Indexing' %}" style="margin-top: 20px;">
</form>
{% endblock %}

View File

@@ -2,21 +2,25 @@
Unit test for `index` command.
"""
import logging
from datetime import datetime, timedelta, timezone
from operator import itemgetter
from unittest import mock
from django.core.cache import cache
from django.core.management import CommandError, call_command
from django.db import transaction
import pytest
from core import factories
from core.factories import DocumentFactory
from core.services.search_indexers import FindDocumentIndexer
@pytest.mark.django_db
@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."""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
@@ -39,18 +43,152 @@ def test_index():
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command("index")
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
mock_push.assert_called_once()
# called once but with a batch of docs
mock_push.assert_called_once()
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
)
@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 = DocumentFactory(
updated_at=lower_time_bound - timedelta(days=10)
)
document_in_window_1 = DocumentFactory(
updated_at=lower_time_bound + timedelta(days=5)
)
document_in_window_2 = DocumentFactory(
updated_at=lower_time_bound + timedelta(days=15)
)
document_too_late = DocumentFactory(
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(),
)
pushed_document_ids = [
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 pushed_document_ids
assert str(document_in_window_1.id) in pushed_document_ids
assert str(document_in_window_2.id) in pushed_document_ids
assert str(document_too_late.id) not in pushed_document_ids
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_with_crash_recovery(caplog_with_propagate):
"""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
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=5)),
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=10)),
# batch 1
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=20)),
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=25)),
# batch 2 - will crash here
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=30)),
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=35)),
# batch 3
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=40)),
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=45)),
]
def push_with_failure_on_batch_2(data):
# Crash when encounters 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")
# First run: simulate crash on batch 3
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
mock_push.side_effect = push_with_failure_on_batch_2
with pytest.raises(CommandError):
with caplog_with_propagate.at_level(logging.INFO):
call_command(
"index",
batch_size=batch_size,
lower_time_bound=lower_time_bound.isoformat(),
upper_time_bound=upper_time_bound.isoformat(),
)
pushed_document_ids = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# the updated at of the last document of each batch are logged as checkpoint
# -> documents[3].updated_at is the most advanced checkpoint
for i in [1, 3]:
assert any(
f"Indexing checkpoint: {documents[i].updated_at.isoformat()}." in message
for message in caplog_with_propagate.messages
)
for i in [0, 2, 4, 5, 6]:
assert not any(
f"Indexing checkpoint: {documents[i].updated_at.isoformat()}" in message
for message in caplog_with_propagate.messages
)
# first 2 batches should be indexed successfully
for i in range(0, 4):
assert str(documents[i].id) in pushed_document_ids
# next batch should have been attempted but failed
for i in range(4, 6):
assert str(documents[i].id) in pushed_document_ids
# last batches indexing should not have been attempted
for i in range(6, 8):
assert str(documents[i].id) not in pushed_document_ids
# Second run: resume from checkpoint
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index",
batch_size=batch_size,
lower_time_bound=documents[3].updated_at,
upper_time_bound=upper_time_bound.isoformat(),
)
pushed_document_ids = [
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
# -> doc 0, 1 and 2
for i in range(0, 3):
assert str(documents[i].id) not in pushed_document_ids
# next batches should be indexed including the document at the checkpoint boundary
# which has already been indexed and is re-indexed
# -> doc 3 to the end
for i in range(3, 8):
assert str(documents[i].id) in pushed_document_ids
@pytest.mark.django_db
@@ -63,3 +201,57 @@ def test_index_improperly_configured(indexer_settings):
call_command("index")
assert str(err.value) == "The indexer is not enabled or properly configured."
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_with_async_flag(settings):
"""Test the command `index` with --async=True runs task asynchronously."""
cache.clear()
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
with mock.patch(
"core.management.commands.index.batch_document_indexer_task"
) as mock_task:
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index", async_mode=True, lower_time_bound=lower_time_bound.isoformat()
)
# push not be called synchronously
mock_push.assert_not_called()
# task called asynchronously
mock_task.apply_async.assert_called_once_with(
kwargs={
"lower_time_bound": lower_time_bound.isoformat(),
"upper_time_bound": None,
"batch_size": settings.SEARCH_INDEXER_BATCH_SIZE,
"crash_safe_mode": True,
}
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_without_async_flag():
"""Test the command `index` with --async=False runs synchronously."""
cache.clear()
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document = DocumentFactory(updated_at=lower_time_bound + timedelta(days=10))
with mock.patch(
"core.management.commands.index.batch_document_indexer_task"
) as mock_task:
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index", async_mode=False, lower_time_bound=lower_time_bound.isoformat()
)
# push is called synchronously to index the document
pushed_document_ids = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
assert str(document.id) in pushed_document_ids
# async task not called
mock_task.apply_async.assert_not_called()

View File

@@ -1,6 +1,7 @@
"""Fixtures for tests in the impress core application"""
import base64
import logging
from unittest import mock
from django.core.cache import cache
@@ -22,6 +23,30 @@ def clear_cache():
cache.clear()
@pytest.fixture
def caplog_with_propagate(settings, caplog):
"""
propagate=False on settings.LOGGING loggers.
This prevents caplog from capturing logs.
This fixture enables propagation on all configured loggers.
"""
# Save original propagate state
original_propagate = {}
for logger_name in settings.LOGGING.get("loggers", {}):
logger = logging.getLogger(logger_name)
original_propagate[logger_name] = logger.propagate
logger.propagate = True
try:
yield caplog
finally:
# Restore original propagate states
for logger_name, original_value in original_propagate.items():
logging.getLogger(logger_name).propagate = original_value
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""

View File

@@ -96,8 +96,9 @@ def test_api_documents_delete_authenticated_owner_of_ancestor(depth):
)
assert models.Document.objects.count() == depth
document_to_delete = documents[-1]
response = client.delete(
f"/api/v1.0/documents/{documents[-1].id}/",
f"/api/v1.0/documents/{document_to_delete.id}/",
)
assert response.status_code == 204
@@ -105,7 +106,11 @@ def test_api_documents_delete_authenticated_owner_of_ancestor(depth):
# Make sure it is only a soft delete
assert models.Document.objects.count() == depth
assert models.Document.objects.filter(deleted_at__isnull=True).count() == depth - 1
assert models.Document.objects.filter(deleted_at__isnull=False).count() == 1
deleted_documents = models.Document.objects.filter(deleted_at__isnull=False)
assert len(deleted_documents) == 1
deleted_document = deleted_documents[0]
# updated_at is updated by the soft delete
assert deleted_document.updated_at > document_to_delete.updated_at
@pytest.mark.parametrize("via", VIA)

View File

@@ -91,11 +91,15 @@ def test_api_documents_restore_authenticated_owner_ancestor_deleted():
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
document.soft_delete()
document.refresh_from_db()
document_deleted_at = document.deleted_at
document_updated_at = document.updated_at
assert document_deleted_at is not None
grand_parent.soft_delete()
grand_parent_deleted_at = grand_parent.deleted_at
assert grand_parent_deleted_at is not None
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
@@ -105,6 +109,8 @@ def test_api_documents_restore_authenticated_owner_ancestor_deleted():
document.refresh_from_db()
assert document.deleted_at is None
# document is updated by restore
assert document.updated_at > document_updated_at
# document is still marked as deleted
assert document.ancestors_deleted_at == grand_parent_deleted_at
assert grand_parent_deleted_at > document_deleted_at

View File

@@ -0,0 +1,54 @@
"""Tests for run_indexing_view admin endpoint."""
from unittest.mock import patch
from django.http import HttpResponse
import pytest
from core import factories
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db
@pytest.mark.parametrize(
"is_authenticated,is_staff,should_call_command",
[
(False, False, False),
(True, False, False),
(True, True, True),
],
)
def test_run_indexing_view_post_authentication(
client,
is_authenticated,
is_staff,
should_call_command,
):
"""Test that POST to run_indexing_view requires staff authentication."""
if is_authenticated:
user = factories.UserFactory(is_staff=is_staff)
client.force_login(user)
batch_size = 100
with patch("core.admin.call_command") as mock_call_command:
mock_call_command.return_value = HttpResponse("Mocked render")
response = client.post("/admin/run-indexing/", {"batch_size": batch_size})
# redirects in all cases
assert response.status_code == 302
if should_call_command:
assert "/admin/run-indexing/" == response.url
mock_call_command.assert_called_once()
assert mock_call_command.call_args.kwargs == {
"batch_size": batch_size,
"lower_time_bound": None,
"upper_time_bound": None,
"async_mode": True,
}
else:
assert "/admin/login/" in response.url
mock_call_command.assert_not_called()

View File

@@ -5,6 +5,8 @@ Unit tests for the Document model
import random
import smtplib
from datetime import datetime, timedelta
from datetime import timezone as base_timezone
from logging import Logger
from unittest import mock
@@ -19,6 +21,7 @@ from django.utils import timezone
import pytest
from core import factories, models
from core.factories import DocumentFactory
pytestmark = pytest.mark.django_db
@@ -87,7 +90,8 @@ def test_models_documents_tree_alphabet():
@pytest.mark.parametrize("depth", range(5))
def test_models_documents_soft_delete(depth):
"""Trying to delete a document that is already deleted or is a descendant of
"""
Trying to delete a document that is already deleted or is a descendant of
a deleted document should raise an error.
"""
documents = []
@@ -99,6 +103,8 @@ def test_models_documents_soft_delete(depth):
)
assert models.Document.objects.count() == depth + 1
document_pk_to_updated_at = {d.pk: d.updated_at for d in documents}
# Delete any one of the documents...
deleted_document = random.choice(documents)
deleted_document.soft_delete()
@@ -106,19 +112,26 @@ def test_models_documents_soft_delete(depth):
with pytest.raises(RuntimeError):
documents[-1].soft_delete()
deleted_document.refresh_from_db()
assert deleted_document.deleted_at is not None
assert deleted_document.ancestors_deleted_at == deleted_document.deleted_at
# updated_at is updated on the deleted document
assert deleted_document.updated_at > document_pk_to_updated_at[deleted_document.pk]
descendants = deleted_document.get_descendants()
for child in descendants:
assert child.deleted_at is None
assert child.ancestors_deleted_at is not None
assert child.ancestors_deleted_at == deleted_document.deleted_at
# updated_at is updated on descendants
assert child.updated_at > document_pk_to_updated_at[child.pk]
ancestors = deleted_document.get_ancestors()
for parent in ancestors:
assert parent.deleted_at is None
assert parent.ancestors_deleted_at is None
# updated_at is not affected on parents
assert parent.updated_at == document_pk_to_updated_at[parent.pk]
assert len(ancestors) + len(descendants) == depth
@@ -1419,16 +1432,20 @@ def test_models_documents_restore_tempering_with_instance():
def test_models_documents_restore(django_assert_num_queries):
"""The restore method should restore a soft-deleted document."""
document = factories.DocumentFactory()
document.soft_delete()
document.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
original_updated_after_delete = document.updated_at
with django_assert_num_queries(10):
document.restore()
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at == document.deleted_at
assert document.ancestors_deleted_at is None
# updated_at is updated by restore
assert original_updated_after_delete < document.updated_at
def test_models_documents_restore_complex(django_assert_num_queries):
@@ -1445,6 +1462,7 @@ def test_models_documents_restore_complex(django_assert_num_queries):
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
@@ -1454,13 +1472,18 @@ def test_models_documents_restore_complex(django_assert_num_queries):
grand_parent.soft_delete()
grand_parent.refresh_from_db()
parent.refresh_from_db()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
grand_parent_updated_at = grand_parent.updated_at
document_updated_at = document.updated_at
child1_updated_at = child2.updated_at
child2_updated_at = child2.updated_at
assert grand_parent.deleted_at is not None
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
assert parent.ancestors_deleted_at == grand_parent.deleted_at
# item, child1 and child2 should not be affected
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
@@ -1469,15 +1492,23 @@ def test_models_documents_restore_complex(django_assert_num_queries):
# Restore the item
with django_assert_num_queries(14):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
grand_parent.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at == grand_parent.deleted_at
# child 1 and child 2 should now have the same ancestors_deleted_at as the grand parent
assert child1.ancestors_deleted_at == grand_parent.deleted_at
assert child2.ancestors_deleted_at == grand_parent.deleted_at
# updated_at is updated for document and children after restore
assert document.updated_at > document_updated_at
assert child1.updated_at > child1_updated_at
assert child2.updated_at > child2_updated_at
# grand_parent updated_at is not affected
assert grand_parent.updated_at == grand_parent_updated_at
def test_models_documents_restore_complex_bis(django_assert_num_queries):
@@ -1485,31 +1516,37 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child1 = factories.DocumentFactory(parent=document)
child2 = factories.DocumentFactory(parent=document)
# Soft delete first the document
document.soft_delete()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Soft delete the grand parent
# Soft delete the grand_parent
grand_parent.soft_delete()
grand_parent.refresh_from_db()
parent.refresh_from_db()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
original_parent_updated_at = parent.updated_at
original_child1_updated_at = child1.updated_at
original_child2_updated_at = child2.updated_at
assert grand_parent.deleted_at is not None
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
assert parent.ancestors_deleted_at == grand_parent.deleted_at
# item, child1 and child2 should not be affected
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
@@ -1525,14 +1562,20 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert grand_parent.deleted_at is None
assert grand_parent.ancestors_deleted_at is None
assert parent.deleted_at is None
assert parent.ancestors_deleted_at is None
# parent should have updated_at updated (descendant of restored grand_parent)
assert parent.updated_at > original_parent_updated_at
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
# children are not restored and then there updated_at should not be affected
assert child1.ancestors_deleted_at == document.deleted_at
assert child1.updated_at == original_child1_updated_at
assert child2.ancestors_deleted_at == document.deleted_at
assert child2.updated_at == original_child2_updated_at
@pytest.mark.parametrize(
@@ -1691,3 +1734,82 @@ def test_models_documents_compute_ancestors_links_paths_mapping_structure(
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
],
}
def test_models_documents_manager_time_filter_no_filters():
"""Test time_filter with no filters returns all documents."""
factories.DocumentFactory.create_batch(3)
queryset = models.Document.objects.filter_updated_at()
assert queryset.count() == 3
def test_models_documents_manager_time_filter_oldest_updated_at():
"""
Test filtering by oldest_updated_at includes documents updated after or at
lower_time_bound.
"""
lower_time_bound = datetime(2024, 2, 1, tzinfo=base_timezone.utc)
DocumentFactory(updated_at=lower_time_bound - timedelta(days=30))
document_at_boundary = DocumentFactory(updated_at=lower_time_bound)
document_recent = DocumentFactory(updated_at=lower_time_bound + timedelta(days=15))
queryset = models.Document.objects.filter_updated_at(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 2
assert sorted(queryset.values_list("pk", flat=True)) == sorted(
[document_at_boundary.pk, document_recent.pk]
)
def test_models_documents_manager_time_filter_newest_updated_at():
"""Test filtering by newest_updated_at includes documents updated before timestamp."""
upper_time_bound = datetime(2024, 2, 1, tzinfo=base_timezone.utc)
document_old = DocumentFactory(updated_at=upper_time_bound - timedelta(days=30))
document_at_boundary = DocumentFactory(updated_at=upper_time_bound)
document_too_recent = DocumentFactory(
updated_at=upper_time_bound + timedelta(days=15)
)
queryset = models.Document.objects.filter_updated_at(
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_models_documents_manager_time_filter_both_bounds():
"""Test filtering with both oldest and newest bounds."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=base_timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=30)
document_too_early = DocumentFactory(
updated_at=lower_time_bound - timedelta(days=10)
)
document_in_window = DocumentFactory(
updated_at=lower_time_bound + timedelta(days=5)
)
other_document_in_window = DocumentFactory(
updated_at=lower_time_bound + timedelta(days=15)
)
document_too_late = DocumentFactory(
updated_at=upper_time_bound + timedelta(days=10)
)
queryset = models.Document.objects.filter_updated_at(
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

View File

@@ -252,7 +252,7 @@ def test_services_search_indexers_index_errors(indexer_settings):
)
with pytest.raises(HTTPError):
FindDocumentIndexer().index()
FindDocumentIndexer().index(models.Document.objects.all())
@patch.object(FindDocumentIndexer, "push")
@@ -272,7 +272,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert FindDocumentIndexer().index() == 5
assert FindDocumentIndexer().index(models.Document.objects.all()) == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
@@ -310,7 +310,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert FindDocumentIndexer().index(batch_size=2) == 5
assert FindDocumentIndexer().index(models.Document.objects.all(), batch_size=2) == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
@@ -345,7 +345,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
empty_title = factories.DocumentFactory(title="")
empty_content = factories.DocumentFactory(content="")
assert FindDocumentIndexer().index() == 3
assert FindDocumentIndexer().index(models.Document.objects.all()) == 3
assert mock_push.call_count == 1
@@ -373,7 +373,7 @@ def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings
# Only empty docs
factories.DocumentFactory.create_batch(5, content="", title="")
assert FindDocumentIndexer().index() == 1
assert FindDocumentIndexer().index(models.Document.objects.all()) == 1
assert mock_push.call_count == 1
results = [doc["id"] for doc in mock_push.call_args[0][0]]
@@ -391,7 +391,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
assert FindDocumentIndexer().index() == 4
assert FindDocumentIndexer().index(models.Document.objects.all()) == 4
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 4
@@ -411,7 +411,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
document = factories.DocumentFactory(parent=parent, users=[user_d])
assert FindDocumentIndexer().index() == 3
assert FindDocumentIndexer().index(models.Document.objects.all()) == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
@@ -432,7 +432,7 @@ def test_services_search_indexers_ancestors_teams(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
assert FindDocumentIndexer().index() == 3
assert FindDocumentIndexer().index(models.Document.objects.all()) == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3

View File

@@ -12,7 +12,10 @@ from drf_spectacular.views import (
SpectacularSwaggerView,
)
from core.admin import run_indexing_view
urlpatterns = [
path("admin/run-indexing/", run_indexing_view, name="run_indexing"),
path("admin/", admin.site.urls),
path("", include("core.urls")),
]

View File

@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
mockedDocument,
overrideConfig,
verifyDocName,
@@ -46,9 +47,9 @@ test.describe('Doc AI feature', () => {
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
await expect(
page.locator('button[data-test="convertMarkdown"]'),
).toHaveCount(1);
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
await expect(
page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden();
@@ -178,32 +179,18 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Rephrase' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Summarize' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
await expect(getMenuItem(page, 'Rephrase')).toBeVisible();
await expect(getMenuItem(page, 'Summarize')).toBeVisible();
await expect(getMenuItem(page, 'Correct')).toBeVisible();
await expect(getMenuItem(page, 'Language')).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover();
await expect(
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await getMenuItem(page, 'Language').hover();
await expect(getMenuItem(page, 'English', { exact: true })).toBeVisible();
await expect(getMenuItem(page, 'French', { exact: true })).toBeVisible();
await expect(getMenuItem(page, '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();
});
@@ -269,23 +256,15 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
await expect(getMenuItem(page, 'Use as prompt')).toBeHidden();
}
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await expect(getMenuItem(page, 'Language')).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
await expect(getMenuItem(page, 'Language')).toBeHidden();
}
});
});

View File

@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
closeHeaderMenu,
createDoc,
getMenuItem,
getOtherBrowserName,
verifyDocName,
} from './utils-common';
@@ -151,7 +152,7 @@ test.describe('Doc Comments', () => {
// Edit Comment
await thread.getByText('This is a comment').first().hover();
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();
await commentEditor.fill('This is an edited comment');
const saveBtn = thread.locator('button[data-test="save"]').first();
@@ -176,7 +177,7 @@ test.describe('Doc Comments', () => {
// Delete second comment
await thread.getByText('This is a second comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await getMenuItem(thread, 'Delete comment').click();
await expect(
thread.getByText('This is a second comment').first(),
).toBeHidden();
@@ -209,7 +210,7 @@ test.describe('Doc Comments', () => {
await thread.getByText('This is a new comment').first().hover();
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')).toHaveCSS(

View File

@@ -5,6 +5,7 @@ import cs from 'convert-stream';
import {
createDoc,
getMenuItem,
goToGridDoc,
overrideConfig,
verifyDocName,
@@ -147,18 +148,20 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:4444/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
@@ -575,10 +578,12 @@ test.describe('Doc Editor', () => {
await page.reload();
responseCanEdit = await page.waitForResponse(
responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
@@ -604,7 +609,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
await getMenuItem(page, 'Reading').click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();

View File

@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
getOtherBrowserName,
mockedListDocs,
toggleHeaderMenu,
@@ -206,7 +207,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
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(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -294,7 +295,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
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(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -341,9 +342,7 @@ test.describe('Doc grid move', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await otherPage
.getByRole('menuitemradio', { name: 'Administrator' })
.click();
await getMenuItem(otherPage, 'Administrator').click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(otherPage.getByText('Access Requests')).toBeHidden();
@@ -354,7 +353,7 @@ test.describe('Doc grid move', () => {
await page.reload();
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(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),

View File

@@ -1,6 +1,11 @@
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';
type SmallDoc = {
@@ -99,7 +104,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await getMenuItem(page, 'Share').click();
await expect(
page.getByRole('dialog').getByText('Share the document'),
@@ -115,7 +120,7 @@ test.describe('Document grid item options', () => {
// Pin
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await getMenuItem(page, 'Pin').click();
// Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
@@ -142,7 +147,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await getMenuItem(page, 'Delete').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),

View File

@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -78,7 +79,7 @@ test.describe('Doc Header', () => {
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();
@@ -152,10 +153,8 @@ test.describe('Doc Header', () => {
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
const addEmojiMenuItem = getMenuItem(page, 'Add emoji');
const removeEmojiMenuItem = getMenuItem(page, 'Remove emoji');
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
@@ -209,7 +208,7 @@ test.describe('Doc Header', () => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await getMenuItem(page, 'Delete document').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -237,7 +236,7 @@ test.describe('Doc Header', () => {
hasText: randomDoc,
});
await expect(row).toHaveCount(0);
expect(await row.count()).toBe(0);
});
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 expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// 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();
@@ -296,7 +293,7 @@ test.describe('Doc Header', () => {
await invitationRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
@@ -308,9 +305,7 @@ test.describe('Doc Header', () => {
await expect(roles).toBeVisible();
await roles.click();
await expect(
page.getByRole('menuitemradio', { name: 'Remove access' }),
).toBeEnabled();
await expect(getMenuItem(page, 'Remove access')).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -350,12 +345,10 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// 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();
@@ -422,12 +415,10 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// 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();
@@ -482,7 +473,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
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(
page.getByText('Copied as Markdown to clipboard'),
).toBeVisible();
@@ -546,7 +537,7 @@ test.describe('Doc Header', () => {
.click();
// Pin
await page.getByRole('menuitem', { name: 'Pin' }).click();
await getMenuItem(page, 'Pin').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
@@ -567,11 +558,11 @@ test.describe('Doc Header', () => {
.click();
// Unpin
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await getMenuItem(page, 'Unpin').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await expect(getMenuItem(page, 'Pin')).toBeVisible();
await page.goto('/');
@@ -589,7 +580,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
await expect(
page.getByText('Document duplicated successfully!'),
).toBeVisible();
@@ -604,7 +595,7 @@ test.describe('Doc Header', () => {
await expect(row.getByText(duplicateTitle)).toBeVisible();
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
await page.getByText(duplicateDuplicateTitle).click();
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
@@ -637,7 +628,7 @@ test.describe('Doc Header', () => {
const currentUrl = page.url();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
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 page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(getMenuItem(page, 'Copy link')).toBeVisible();
await getMenuItem(page, 'Share').click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -702,7 +691,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
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', {
name: 'Share the document',

View File

@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
return dt;
}, filesData);
await page.locator(selector).dispatchEvent('drop', { dataTransfer });
await page.dispatchEvent(selector, 'drop', { dataTransfer });
};

View File

@@ -1,6 +1,6 @@
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 { createRootSubPage } from './utils-sub-pages';
@@ -53,19 +53,17 @@ test.describe('Inherited share accesses', () => {
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
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('Editing')).toBeVisible();
// Verify inherited link
await docVisibilityCard.getByText('Connected').click();
await expect(
page.getByRole('menuitemradio', { name: 'Private' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Private')).toBeDisabled();
// Update child link
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect(

View File

@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
BROWSERS,
createDoc,
getMenuItem,
keyCloakSignIn,
randomName,
verifyDocName,
@@ -16,41 +17,6 @@ test.describe('Document create member', () => {
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 }) => {
const inputFill = 'user.test';
const responsePromise = page.waitForResponse(
@@ -110,21 +76,13 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByTestId('doc-role-dropdown').click();
await expect(
page.getByRole('menuitemradio', { name: 'Reader' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Editor' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Owner' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Administrator' }),
).toBeVisible();
await expect(getMenuItem(page, 'Reader')).toBeVisible();
await expect(getMenuItem(page, 'Editor')).toBeVisible();
await expect(getMenuItem(page, 'Owner')).toBeVisible();
await expect(getMenuItem(page, 'Administrator')).toBeVisible();
// Validate
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await page.getByTestId('doc-share-invite-button').click();
// Check invitation added
@@ -170,7 +128,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
await getMenuItem(page, 'Owner').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -188,7 +146,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
await getMenuItem(page, 'Owner').click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -225,7 +183,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -252,13 +210,13 @@ test.describe('Document create member', () => {
);
await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
await getMenuItem(page, 'Reader').click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
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();
});
@@ -310,7 +268,7 @@ test.describe('Document create member', () => {
`doc-share-access-request-row-${emailRequest}`,
);
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 expect(page.getByText('Access Requests')).toBeHidden();

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
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.`,
);
await expect(soloOwner).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Administrator' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Administrator')).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
@@ -185,20 +183,18 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await list.click();
await expect(currentUserRole).toBeVisible();
await newUserRoles.click();
await expect(
page.getByRole('menuitemradio', { name: 'Owner' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Owner')).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
await getMenuItem(page, 'Reader').click();
await list.click({
force: true, // Force click to close the dropdown
});
@@ -238,11 +234,11 @@ test.describe('Document list members', () => {
await expect(userReader).toBeVisible();
await userReaderRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(userReader).toBeHidden();
await mySelfRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible();

View File

@@ -1,6 +1,6 @@
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';
test.beforeEach(async ({ page }) => {
@@ -136,13 +136,9 @@ test.describe('Document search', () => {
await filters.click();
await filters.getByRole('button', { name: 'Current doc' }).click();
await expect(
page.getByRole('menuitemcheckbox', { name: 'All docs' }),
).toBeVisible();
await expect(
page.getByRole('menuitemcheckbox', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('menuitemcheckbox', { name: 'All docs' }).click();
await expect(getMenuItem(page, 'All docs')).toBeVisible();
await expect(getMenuItem(page, 'Current doc')).toBeVisible();
await getMenuItem(page, 'All docs').click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});

View File

@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
updateDocTitle,
verifyDocName,
@@ -42,12 +43,15 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItems).toHaveCount(2);
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
// Check that elements are in the correct order
await expect(allSubPageItems.nth(0).getByText('first move')).toBeVisible();
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
// Will move the first sub page to the second position
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
@@ -87,15 +91,17 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check that elements are in the correct order
const allSubPageItemsAfterReload =
docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItemsAfterReload).toHaveCount(2);
const allSubPageItemsAfterReload = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItemsAfterReload.length).toBe(2);
await expect(
allSubPageItemsAfterReload.nth(0).getByText('second move'),
allSubPageItemsAfterReload[0].getByText('second move'),
).toBeVisible();
await expect(
allSubPageItemsAfterReload.nth(1).getByText('first move'),
allSubPageItemsAfterReload[1].getByText('first move'),
).toBeVisible();
});
@@ -157,7 +163,7 @@ test.describe('Doc Tree', () => {
);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -187,9 +193,10 @@ test.describe('Doc Tree', () => {
const menu = child.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
await expect(getMenuItem(page, 'Move to my docs')).toHaveAttribute(
'aria-disabled',
'true',
);
});
test('keyboard navigation with Enter key opens documents', async ({
@@ -333,9 +340,7 @@ test.describe('Doc Tree', () => {
await row.hover();
const menu = row.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Remove emoji' }),
).toBeHidden();
await expect(getMenuItem(page, 'Remove emoji')).toBeHidden();
// Close the menu
await page.keyboard.press('Escape');
@@ -355,7 +360,7 @@ test.describe('Doc Tree', () => {
// Now remove the emoji using the new action
await row.hover();
await menu.click();
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await getMenuItem(page, 'Remove emoji').click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).toBeHidden();
@@ -385,7 +390,7 @@ test.describe('Doc Tree: Inheritance', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(
page.getByText('The document visibility has been updated.'),

View File

@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -20,7 +21,7 @@ test.describe('Doc Version', () => {
// Initially, there is no version
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();
const modal = page.getByRole('dialog', { name: 'Version history' });
@@ -74,14 +75,14 @@ test.describe('Doc Version', () => {
).toBeVisible();
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(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = panel.locator('.version-item');
await expect(items).toHaveCount(2);
await items.nth(1).click();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(2);
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
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(),
).toBeHidden();
await items.nth(0).click();
await items[0].click();
await expect(modal.getByText('Hello World')).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'),
).toBeHidden();
await items.nth(1).click();
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -124,9 +125,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Version history')).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -153,7 +152,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
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 panel = modal.getByLabel('Version list');

View File

@@ -4,6 +4,7 @@ import {
BROWSERS,
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
@@ -46,21 +47,17 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitemradio', { name: 'Can read and edit' }),
).toBeHidden();
await expect(getMenuItem(page, 'Read only')).toBeHidden();
await expect(getMenuItem(page, 'Can read and edit')).toBeHidden();
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -205,7 +202,7 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(
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 page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
await getMenuItem(page, 'Reading').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -299,14 +296,14 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await getMenuItem(page, 'Editing').click();
await expect(
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();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -438,7 +435,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -536,7 +533,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -544,7 +541,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await getMenuItem(page, 'Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { overrideConfig } from './utils-common';
import { getMenuItem, overrideConfig } from './utils-common';
test.describe('Footer', () => {
test.use({ storageState: { cookies: [], origins: [] } });
@@ -47,7 +47,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByRole('menuitemradio', { name: 'Français' }).click();
await getMenuItem(page, 'Français').click();
await expect(
page.locator('footer').getByText('Mentions légales'),
@@ -131,7 +131,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByRole('menuitemradio', { name: 'Français' }).click();
await getMenuItem(page, 'Français').click();
await expect(
page

View File

@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import {
TestLanguage,
getMenuItem,
overrideConfig,
waitForLanguageSwitch,
} from './utils-common';
@@ -44,7 +45,7 @@ test.describe('Help feature', () => {
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');
await expect(modal).toBeVisible();
@@ -87,7 +88,7 @@ test.describe('Help feature', () => {
test('closes modal with Skip button', async ({ page }) => {
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');
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('menuitem', { name: 'Premiers pas' }).click();
await getMenuItem(page, 'Premiers pas').click();
const modal = page.getByLabel('Apprenez les principes fondamentaux');

View File

@@ -75,7 +75,7 @@ test.describe('Language', () => {
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 menuItems.first().click();

View File

@@ -3,6 +3,16 @@ import path from 'path';
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';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
@@ -382,12 +392,12 @@ export async function waitForLanguageSwitch(
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) => {
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 (
@@ -398,7 +408,7 @@ export const clickInGridMenu = async (
await row
.getByRole('button', { name: /Open the menu of actions for the document/ })
.click();
await page.getByRole('menuitem', { name: textButton }).click();
await getMenuItem(page, textButton).click();
};
export const writeReport = async (

View File

@@ -2,6 +2,7 @@ import { Page, chromium, expect } from '@playwright/test';
import {
BrowserName,
getMenuItem,
getOtherBrowserName,
keyCloakSignIn,
verifyDocName,
@@ -39,7 +40,7 @@ export const addNewMember = async (
// Choose a role
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();
return users[index].email;
@@ -51,7 +52,7 @@ export const updateShareLink = async (
linkRole?: LinkRole | null,
) => {
await page.getByTestId('doc-visibility').click();
await page.getByRole('menuitemradio', { name: linkReach }).click();
await getMenuItem(page, linkReach).click();
const visibilityUpdatedText = page
.getByText('The document visibility has been updated')
@@ -61,7 +62,7 @@ export const updateShareLink = async (
if (linkRole) {
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: linkRole }).click();
await getMenuItem(page, linkRole).click();
await expect(visibilityUpdatedText).toBeVisible();
}
};
@@ -76,7 +77,7 @@ export const updateRoleUser = async (
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitemradio', { name: role }).click();
await getMenuItem(page, role).click();
await list.click();
};

View File

@@ -23,7 +23,7 @@
},
"dependencies": {
"@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/core": "0.47.1",
"@blocknote/mantine": "0.47.1",
@@ -38,20 +38,20 @@
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.38",
"@fontsource-variable/material-symbols-outlined": "5.2.35",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@gouvfr-lasuite/ui-kit": "0.19.6",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.17",
"@mantine/hooks": "8.3.17",
"@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.14",
"@react-aria/live-announcer": "3.4.4",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.43.0",
"@sentry/nextjs": "10.38.0",
"@tanstack/react-query": "5.90.21",
"@tiptap/extensions": "*",
"ai": "6.0.128",
"ai": "6.0.49",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -59,28 +59,28 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.8.18",
"i18next": "25.8.12",
"i18next-browser-languagedetector": "8.2.1",
"idb": "8.0.3",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "16.1.7",
"posthog-js": "1.360.2",
"posthog-js": "1.347.2",
"react": "*",
"react-aria-components": "1.16.0",
"react-aria-components": "1.15.1",
"react-dom": "*",
"react-dropzone": "15.0.0",
"react-i18next": "16.5.8",
"react-intersection-observer": "10.0.3",
"react-i18next": "16.5.4",
"react-intersection-observer": "10.0.2",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.3.11",
"styled-components": "6.3.9",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zod": "4.3.6",
"zustand": "5.0.12"
"zod": "3.25.28",
"zustand": "5.0.11"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
@@ -89,25 +89,26 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.24",
"@types/lodash": "4.17.23",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "6.0.1",
"@vitejs/plugin-react": "5.1.4",
"cross-env": "10.1.0",
"dotenv": "17.3.1",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "29.0.0",
"jsdom": "28.1.0",
"node-fetch": "2.7.0",
"prettier": "3.8.1",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vitest": "4.1.0",
"webpack": "5.105.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"webpack": "5.105.2",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -1,17 +1,17 @@
import { Ref, forwardRef } from 'react';
import { forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = Omit<BoxType, 'ref'> & {
export type BoxButtonType = BoxType & {
disabled?: boolean;
ref?: Ref<HTMLButtonElement>;
};
/**
/**
* Styleless button that extends the Box component.
* 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 ref
* @see Box
@@ -22,8 +22,8 @@ export type BoxButtonType = Omit<BoxType, 'ref'> & {
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
({ $css, disabled, ...props }, ref) => {
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
const theme = props.$theme || 'gray';
const variation = props.$variation || 'primary';
@@ -31,18 +31,16 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
<Box
ref={ref}
as="button"
type="button"
$background="none"
$margin="none"
$padding="none"
$hasTransition
aria-disabled={disabled || undefined}
$css={css`
cursor: ${disabled ? 'not-allowed' : 'pointer'};
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
border: none;
outline: none;
font-family: inherit;
color: ${disabled &&
color: ${props.disabled &&
`var(--c--contextuals--content--semantic--disabled--primary)`};
&:focus-visible {
transition: none;
@@ -55,11 +53,11 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
`}
{...props}
className={`--docs--box-button ${props.className || ''}`}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) {
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
}
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
props.onClick?.(event);
}}
/>
);

View File

@@ -26,7 +26,6 @@ import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: ReactNode;
label: string;
lang?: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
@@ -70,10 +69,7 @@ export const DropdownMenu = ({
const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null);
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const isSingleSelectable = options.some(
(option) => option.isSelected !== undefined,
);
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onOpenChange = useCallback(
(isOpen: boolean) => {
@@ -114,6 +110,10 @@ export const DropdownMenu = ({
[onOpenChange],
);
const hasSelectable =
selectedValues !== undefined ||
options.some((option) => option.isSelected !== undefined);
if (disabled) {
return children;
}
@@ -176,25 +176,20 @@ export const DropdownMenu = ({
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
const isSelected =
option.isSelected === true ||
(selectedValues?.includes(option.value ?? '') ?? false);
const itemRole =
selectedValues !== undefined
? 'menuitemcheckbox'
: isSingleSelectable
? 'menuitemradio'
: 'menuitem';
const optionKey = option.value ?? option.testId ?? `option-${index}`;
const ariaChecked = hasSelectable
? option.isSelected ||
selectedValues?.includes(option.value ?? '') ||
false
: undefined;
return (
<Fragment key={optionKey}>
<Fragment key={option.label}>
<BoxButton
ref={(el) => {
menuItemRefs.current[index] = el;
}}
role={itemRole}
aria-checked={itemRole === 'menuitem' ? undefined : isSelected}
role={hasSelectable ? 'menuitemradio' : 'menuitem'}
aria-checked={ariaChecked}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
@@ -205,6 +200,7 @@ export const DropdownMenu = ({
triggerOption(option);
}}
onKeyDown={keyboardAction(() => triggerOption(option))}
key={option.label}
$align="center"
$justify="space-between"
$background="var(--c--contextuals--background--surface--primary)"
@@ -275,16 +271,16 @@ export const DropdownMenu = ({
<Box
$theme="neutral"
$variation={isDisabled ? 'tertiary' : 'primary'}
aria-hidden="true"
>
{option.icon}
</Box>
)}
<Text $variation={isDisabled ? 'tertiary' : 'primary'}>
<span lang={option.lang}>{option.label}</span>
{option.label}
</Text>
</Box>
{isSelected && (
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
<Icon
iconName="check"
$size="20px"

View File

@@ -58,7 +58,7 @@ describe('<DropdownMenu />', () => {
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[] = [
{ label: 'English', value: 'en', callback: vi.fn() },
{ label: 'Français', value: 'fr', callback: vi.fn() },
@@ -77,12 +77,12 @@ describe('<DropdownMenu />', () => {
{ wrapper: AppWrapper },
);
const checkboxes = screen.getAllByRole('menuitemcheckbox');
expect(checkboxes).toHaveLength(3);
const radios = screen.getAllByRole('menuitemradio');
expect(radios).toHaveLength(3);
expect(checkboxes[0]).toHaveAttribute('aria-checked', 'false');
expect(checkboxes[1]).toHaveAttribute('aria-checked', 'true');
expect(checkboxes[2]).toHaveAttribute('aria-checked', 'false');
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
});
test('trigger button has aria-haspopup and aria-expanded', async () => {

View File

@@ -6,7 +6,7 @@ type UseDropdownKeyboardNavProps = {
isOpen: boolean;
focusedIndex: number;
options: DropdownMenuOption[];
menuItemRefs: RefObject<(HTMLButtonElement | null)[]>;
menuItemRefs: RefObject<(HTMLDivElement | null)[]>;
setFocusedIndex: (index: number) => void;
onOpenChange: (isOpen: boolean) => void;
};

View File

@@ -48,7 +48,7 @@ export const QuickSearchInput = ({
$direction="row"
$align="center"
className="quick-search-input"
$gap={spacingsTokens['xxs']}
$gap={spacingsTokens['2xs']}
$padding={{ horizontal: 'base', vertical: 'xxs' }}
>
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
@@ -62,7 +62,6 @@ export const QuickSearchInput = ({
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
minLength={6}
data-testid="quick-search-input"
/>
</Box>

View File

@@ -18,15 +18,14 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-input] {
border: none;
width: 100%;
font-size: 16px;
font-size: 17px;
background: white;
outline: none;
color: var(--c--contextuals--content--semantic--neutral--primary);
border-radius: var(--c--globals--spacings--0);
font-family: var(--c--globals--font--families--base);
&::placeholder {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
color: var(--c--globals--colors--gray-500);
}
}

View File

@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { toBase64 } from '@/utils/string';
import { isFirefox } from '@/utils/userAgent';

View File

@@ -1,9 +1,4 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/docs/doc-export/components/ModalExport', () => ({
ModalExport: vi.fn(),
}));
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
describe('useModuleExport', () => {
@@ -21,12 +16,12 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
});
}, 15000);
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toHaveProperty('ModalExport');
});
}, 15000);
});

View File

@@ -1,4 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
@@ -38,11 +40,17 @@ describe('DocToolBox - Licence', () => {
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = await screen.findByLabelText('Export the document');
await userEvent.click(optionsButton);
// Wait for the export modal to be visible, then assert on its content text.
await screen.findByTestId('modal-export-title');
expect(
await screen.findByLabelText('Export the document'),
screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeInTheDocument();
}, 15000);
}, 10000);
test('The export button is not rendered when MIT version is activated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
@@ -60,5 +68,5 @@ describe('DocToolBox - Licence', () => {
expect(
screen.queryByLabelText('Export the document'),
).not.toBeInTheDocument();
}, 15000);
});
});

View File

@@ -1,8 +1,9 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -38,6 +39,7 @@ import {
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { useFocusStore, useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -86,6 +88,7 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const treeContext = useTreeContext<Doc>();
const queryClient = useQueryClient();
const router = useRouter();
const { isChild, isTopRoot } = useDocUtils(doc);
@@ -111,6 +114,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
});
useEffect(() => {
if (selectHistoryModal.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [selectHistoryModal.isOpen, queryClient]);
// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();

View File

@@ -4,7 +4,7 @@ import {
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { useEditorStore } from '../../doc-editor';
export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore();
@@ -21,8 +21,8 @@ export const useCopyCurrentEditorToClipboard = () => {
try {
const editorContentFormatted =
asFormat === 'html'
? editor.blocksToHTMLLossy()
: editor.blocksToMarkdownLossy();
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
const successMessage =
asFormat === 'markdown'

View File

@@ -44,7 +44,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});

View File

@@ -44,7 +44,7 @@ export const DocIcon = ({
const { t } = useTranslation();
const { addLastFocus, restoreFocus } = useFocusStore();
const iconRef = useRef<HTMLButtonElement>(null);
const iconRef = useRef<HTMLDivElement>(null);
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
const [pickerPosition, setPickerPosition] = useState<{

View File

@@ -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', () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -124,8 +124,7 @@ export const DocShareAddMemberList = ({
$scope="surface"
$theme="tertiary"
$variation=""
$border="1px solid var(--c--contextuals--border--surface--primary)"
$margin={{ bottom: 'sm' }}
$border="1px solid var(--c--contextuals--border--semantic--contextual--primary)"
>
<Box
$direction="row"

View File

@@ -289,7 +289,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
/>
)}
{showMemberSection && isRootDoc && (
<Box $padding={{ horizontal: 'base', top: 'base' }}>
<Box $padding={{ horizontal: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
@@ -301,7 +301,6 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect}
userQuery={userQuery}
minLength={API_USERS_SEARCH_QUERY_MIN_LENGTH}
/>
)}
</QuickSearch>
@@ -322,35 +321,14 @@ interface QuickSearchInviteInputSectionProps {
onSelect: (usr: User) => void;
searchUsersRawData: User[] | undefined;
userQuery: string;
minLength: number;
}
const QuickSearchInviteInputSection = ({
onSelect,
searchUsersRawData,
userQuery,
minLength,
}: QuickSearchInviteInputSectionProps) => {
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 users = searchUsersRawData || [];
@@ -369,7 +347,7 @@ const QuickSearchInviteInputSection = ({
);
return {
groupName: hint,
groupName: t('Search user result'),
elements: users,
endActions:
isEmail && !hasEmailInUsers
@@ -381,12 +359,12 @@ const QuickSearchInviteInputSection = ({
]
: undefined,
};
}, [searchUsersRawData, userQuery, hint, onSelect]);
}, [onSelect, searchUsersRawData, t, userQuery]);
return (
<Box
aria-label={t('List search user result card')}
$padding={{ horizontal: 'base', bottom: '3xs', top: 'base' }}
$padding={{ horizontal: 'base', bottom: '3xs' }}
>
<QuickSearchGroup
group={searchUserData}

View File

@@ -96,7 +96,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const ariaLabel = docTitle;
const isDisabled = !!doc.deleted_at;
const actionsRef = useRef<HTMLDivElement>(null);
const buttonOptionRef = useRef<HTMLButtonElement | null>(null);
const buttonOptionRef = useRef<HTMLDivElement | null>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement | null;

View File

@@ -44,7 +44,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
const rootItemRef = useRef<HTMLDivElement>(null);
const rootActionsRef = useRef<HTMLDivElement>(null);
const rootButtonOptionRef = useRef<HTMLButtonElement | null>(null);
const rootButtonOptionRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation();

View File

@@ -33,7 +33,7 @@ type DocTreeItemActionsProps = {
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement | null>;
buttonOptionRef?: React.RefObject<HTMLButtonElement | null>;
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
};
export const DocTreeItemActions = ({
@@ -48,7 +48,7 @@ export const DocTreeItemActions = ({
}: DocTreeItemActionsProps) => {
const internalActionsRef = useRef<HTMLDivElement | null>(null);
const targetActionsRef = actionsRef ?? internalActionsRef;
const internalButtonRef = useRef<HTMLButtonElement | null>(null);
const internalButtonRef = useRef<HTMLDivElement | null>(null);
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
const router = useRouter();
const { t } = useTranslation();

View File

@@ -4,12 +4,9 @@ import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { BlockNoteReader } from '@/docs/doc-editor/components/BlockNoteEditor';
import { DocEditorContainer } from '@/docs/doc-editor/components/DocEditor';
import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
import { useDocVersion } from '../api/useDocVersion';
import { Versions } from '../types';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
import { DocVersionHeader } from './DocVersionHeader';

View File

@@ -1,4 +1,4 @@
import { Doc } from '../doc-management/types';
import { Doc } from '../doc-management';
export interface APIListVersions {
count: number;

View File

@@ -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 i18next from 'i18next';
import { DateTime } from 'luxon';
@@ -73,9 +73,7 @@ describe('DocsGridItemDate', () => {
});
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(
<DocsGridItemDate
@@ -92,9 +90,7 @@ describe('DocsGridItemDate', () => {
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
await act(async () => {
await i18next.changeLanguage('en');
});
await i18next.changeLanguage('en');
});
[

View File

@@ -1,4 +1,3 @@
import { announce } from '@react-aria/live-announcer';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -18,35 +17,23 @@ export const LanguagePicker = () => {
const { changeLanguageSynchronized } = useSynchronizedLanguage();
const language = i18n.language;
const toLangTag = (locale: string) => locale.replace('_', '-');
// Compute options for dropdown
const optionsPicker = useMemo(() => {
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
return backendOptions.map(([backendLocale, backendLabel]) => {
return {
label: backendLabel,
lang: toLangTag(backendLocale),
value: backendLocale,
isSelected: getMatchingLocales([backendLocale], [language]).length > 0,
callback: async () => {
await changeLanguageSynchronized(backendLocale, user);
announce(
t('Language changed to {{language}}', {
language: backendLabel,
defaultValue: `Language changed to ${backendLabel}`,
}),
'polite',
);
},
callback: () => changeLanguageSynchronized(backendLocale, user),
};
});
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, t, user]);
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
// Extract current language label for display
const [currentLanguageCode, currentLanguageLabel] = conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
) ?? [language, language];
const currentLanguageLabel =
conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
)?.[1] || language;
return (
<DropdownMenu
@@ -78,9 +65,7 @@ export const LanguagePicker = () => {
$align="center"
>
<Icon iconName="translate" $color="inherit" $size="xl" />
<span lang={toLangTag(currentLanguageCode)}>
{currentLanguageLabel}
</span>
{currentLanguageLabel}
</Box>
</DropdownMenu>
);

View File

@@ -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', () => {
beforeEach(() => {
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
@@ -15,16 +20,17 @@ describe('DocsDB', () => {
{ version: '3.0.0', expected: 3000000 },
{ version: '10.20.30', expected: 10020030 },
].forEach(({ version, expected }) => {
it(`correctly computes version for ${version}`, async () => {
it(`correctly computes version for ${version}`, () => {
vi.doMock('@/../package.json', () => ({
default: { version },
}));
const module = await import('../DocsDB');
const result = (module as any).getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
return vi.importActual('../DocsDB').then((module: any) => {
const result = module.getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
});
});
});
});

View File

@@ -1,9 +1,16 @@
/// <reference types="vitest" />
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
plugins: [
react(),
tsconfigPaths({
root: '.',
projects: ['./tsconfig.json'],
}),
],
test: {
globals: true,
environment: 'jsdom',
@@ -15,7 +22,4 @@ export default defineConfig({
define: {
'process.env.NODE_ENV': 'test',
},
resolve: {
tsconfigPaths: true,
},
});

View File

@@ -32,17 +32,17 @@
},
"resolutions": {
"@tiptap/extensions": "3.19.0",
"@types/node": "24.12.0",
"@types/node": "24.10.13",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "10.0.3",
"eslint": "10.0.1",
"glob": "13.0.6",
"prosemirror-view": "1.41.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "5.9.3",
"wrap-ansi": "10.0.0",
"yjs": "13.6.30"
"wrap-ansi": "9.0.2",
"yjs": "13.6.29"
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -2,7 +2,7 @@ const js = require('@eslint/js');
const nextPlugin = require('@next/eslint-plugin-next');
const tanstackQuery = require('@tanstack/eslint-plugin-query');
const { defineConfig } = require('eslint/config');
const importPlugin = require('eslint-plugin-import-x');
const importPlugin = require('eslint-plugin-import');
const jsxA11y = require('eslint-plugin-jsx-a11y');
const prettier = require('eslint-plugin-prettier');
const react = require('eslint-plugin-react');

View File

@@ -18,22 +18,22 @@
},
"dependencies": {
"@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",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"@vitest/eslint-plugin": "1.6.12",
"eslint-config-next": "16.1.7",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"@vitest/eslint-plugin": "1.6.9",
"eslint-config-next": "16.1.6",
"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-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-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-testing-library": "7.16.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-testing-library": "7.15.4",
"prettier": "3.8.1"
},
"packageManager": "yarn@1.22.22"

View File

@@ -19,8 +19,8 @@
"@types/node": "*",
"eslint-plugin-docs": "*",
"eslint-plugin-import": "2.32.0",
"i18next-parser": "9.4.0",
"jest": "30.3.0",
"i18next-parser": "9.3.0",
"jest": "30.2.0",
"ts-jest": "29.4.6",
"typescript": "*",
"yargs": "18.0.0"

View File

@@ -1,6 +1,6 @@
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import request from 'supertest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
vi.mock('../src/env', async (importOriginal) => {
@@ -62,11 +62,7 @@ const expectedBlocks = [
console.error = vi.fn();
describe('Conversion Testing', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('Server Tests', () => {
test('POST /api/convert with incorrect API key responds with 401', async () => {
const app = initApp();
@@ -174,7 +170,6 @@ describe('Conversion Testing', () => {
});
test('POST /api/convert BlockNote to Yjs', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp();
const editor = ServerBlockNoteEditor.create();
const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown);
@@ -197,7 +192,6 @@ describe('Conversion Testing', () => {
const decodedBlocks = editor.yDocToBlocks(ydoc, 'document-store');
expect(decodedBlocks).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
test('POST /api/convert BlockNote to HTML', async () => {
@@ -259,7 +253,6 @@ describe('Conversion Testing', () => {
});
test('POST /api/convert Yjs to JSON', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp();
const editor = ServerBlockNoteEditor.create();
const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown);
@@ -279,7 +272,6 @@ describe('Conversion Testing', () => {
);
expect(response.body).toBeInstanceOf(Array);
expect(response.body).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
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 () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp();
const response = await request(app)
.post('/api/convert')
@@ -313,6 +304,5 @@ describe('Conversion Testing', () => {
expect(response.status).toBe(400);
expect(response.body).toStrictEqual({ error: 'Invalid content' });
expect(destroySpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -18,10 +18,10 @@
"dependencies": {
"@blocknote/server-util": "0.47.1",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.43.0",
"@sentry/profiling-node": "10.43.0",
"@sentry/node": "10.38.0",
"@sentry/profiling-node": "10.38.0",
"@tiptap/extensions": "*",
"axios": "1.13.6",
"axios": "1.13.5",
"cors": "2.8.6",
"express": "5.2.1",
"express-ws": "5.0.2",
@@ -36,16 +36,16 @@
"@types/express": "5.0.6",
"@types/express-ws": "3.0.6",
"@types/node": "*",
"@types/supertest": "7.2.0",
"@types/supertest": "6.0.3",
"@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.14",
"nodemon": "3.1.11",
"supertest": "7.2.2",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"typescript": "*",
"vitest": "4.1.0",
"vitest": "4.0.18",
"vitest-mock-extended": "3.1.0",
"ws": "8.19.0"
},

View File

@@ -60,12 +60,8 @@ const readers: InputReader[] = [
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
read: async (data) => {
const ydoc = new Y.Doc();
try {
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
} finally {
ydoc.destroy();
}
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
},
},
{
@@ -81,14 +77,7 @@ const writers: OutputWriter[] = [
},
{
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
write: async (blocks) => {
const ydoc = createYDocument(blocks);
try {
return Y.encodeStateAsUpdate(ydoc);
} finally {
ydoc.destroy();
}
},
write: async (blocks) => Y.encodeStateAsUpdate(createYDocument(blocks)),
},
{
supportedContentTypes: [ContentTypes.Markdown, ContentTypes.XMarkdown],

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,6 @@
"@html-to/text-cli": "0.5.4",
"mjml": "4.18.0"
},
"resolutions": {
"minimatch": "^9.0.7"
},
"private": true,
"scripts": {
"build-mjml-to-html": "bash ./bin/mjml-to-html",

View File

@@ -110,7 +110,7 @@ boolbase@^1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
brace-expansion@^2.0.2:
brace-expansion@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
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"
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
minimatch@9.0.1, minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.7:
version "9.0.9"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
minimatch@9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
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:
version "7.1.2"