Compare commits

..

11 Commits

Author SHA1 Message Date
Anthony LC 525d8c8417 🐛(y-provider) destroy Y.Doc instances after each convert request
The Yjs reader and writer in `convertHandler.ts`
were creating `Y.Doc`instances on every request
without calling `.destroy()`, causing a slow heap
leak that could crash the server.

Fixed by wrapping both sites in `try/finally`
blocks that call `ydoc.destroy()`.
Regression tests added to assert `destroy` is
called the expected number of times per request path.
2026-03-25 12:03:12 +01:00
Cyril c886cbb41d ️(frontend) fix language dropdown ARIA for screen readers
Add missing attributes for language picker.
2026-03-25 11:08:17 +01:00
Cyril 98f3ca2763 ️(frontend) improve BoxButton a11y and native button semantics
Add type="button", aria-disabled, and align refs with HTMLButtonElement.
2026-03-25 10:05:49 +01:00
Anthony LC fb92a43755 🚸(frontend) hint min char search users
We give a hint to the user about the minimum
number of characters required to perform a search
in the quick search input of the doc share modal.
This is to improve the user experience.
2026-03-25 09:33:14 +01:00
Anthony LC 03fd1fe50e (frontend) fix vitest tests
We upgraded vitest recently, we need to adapt
some of our tests to the new version.
We brought some modules improvments as well,
problemes that was highlighted by the new version
of vitest.
2026-03-24 16:48:40 +01:00
Anthony LC fc803226ac 🔒️(js) fix security warning
Force the upgrade of some dependencies to fix
security warnings.
2026-03-24 15:54:34 +01:00
Anthony LC fb725edda3 🚨(frontend) fix eslint errors
Recent upgrade of eslint-plugin-playwright
highlighted some errors.
This commit fixes those errors.
2026-03-24 13:01:52 +01:00
Anthony LC 6838b387a2 (linter) replace eslint-plugin-import by eslint-plugin-import-x
"eslint-plugin-import" is not well maintained anymore
better to use "eslint-plugin-import-x" which is a fork
of "eslint-plugin-import" and is actively maintained.
2026-03-24 13:01:51 +01:00
Anthony LC 87f570582f ⬇️(frontend) downgrade @react-pdf/renderer and pin it
@react-pdf/renderer is not compatible with the
Blocknote version. We need to downgrade it to a
compatible version and pin it to avoid future issues.
When Blocknote updates to a compatible version,
we can upgrade @react-pdf/renderer again.
2026-03-24 13:01:51 +01:00
Anthony LC 37f56fcc22 📌(frontend) blocked upgrade stylelint
stylelint introduces lot of breaking changes
in its latest version, and since
we use it only for linting css files,
so we can block its upgrade for now and upgrade
it later when we will have more time to handle
the breaking changes.
2026-03-24 13:00:46 +01:00
renovate[bot] 19aa3a36bc ⬆️(dependencies) update js dependencies 2026-03-24 13:00:04 +01:00
71 changed files with 2958 additions and 3067 deletions
+10 -1
View File
@@ -6,15 +6,24 @@ and this project adheres to
## [Unreleased]
### Added
- 🚸(frontend) hint min char search users #2064
### Changed
- 💄(frontend) improve comments highlights #1961
- ♿️(frontend) improve BoxButton a11y and native button semantics #2103
- ♿️(frontend) improve language picker accessibility #2069
### Fixed
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129
## [v4.8.3] - 2026-03-23
### Changed
- ✨(backend) improve indexing command with checkpoint recovery and asynchronicity
- ♿️(frontend) improve version history list accessibility #2033
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983
- ♿️(frontend) add sr-only format to export download button #2088
-52
View File
@@ -1,52 +0,0 @@
# Index Command
The `index` management command is used to index documents to the remote search indexer.
It sends an asynchronous task to the Celery worker.
## Usage
### Command Line
```bash
python manage.py index \
--lower-time-bound "2024-01-01T00:00:00" \
--upper-time-bound "2024-01-31T23:59:59" \
--batch-size 200 \
--crash-safe-mode false
```
### Django Admin
The command is available in the Django admin interface:
1. Go to `/admin/`
2. Click on **"Run Indexing"** in the CORE section
3. Fill in the form with the desired parameters
4. Click **"Run Indexing Command"**
## Parameters
### `--batch-size`
- **type:** Integer
- **default:** `50`
- **description:** Number of documents to process per batch. Higher values may improve performance but use more memory.
### `--lower-time-bound`
- **optional**: true
- **type:** ISO 8601 datetime string
- **default:** `None`
- **description:** Only documents updated after this date will be indexed.
### `--upper-time-bound` (optional)
- **optional**: true
- **type:** ISO 8601 datetime string
- **default:** `None`
- **description:** Only documents updated before this date will be indexed.
### `--crash-safe-mode`
- **type:** Boolean flag
- **default:** `True`
- **description:** When enabled, the command orders documents by `updated_at` and stores the last indexed document's timestamp in cache. This allows resuming indexing from the last successful batch in case of a crash. However, it is more computationally expensive due to sorting.b
+3
View File
@@ -60,10 +60,13 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@react-pdf/renderer",
"fetch-mock",
"node",
"node-fetch",
"react-resizable-panels",
"stylelint",
"stylelint-config-standard",
"workbox-webpack-plugin"
]
}
+1 -36
View File
@@ -1,10 +1,8 @@
"""Admin classes and registrations for core app."""
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.core.management import call_command
from django.shortcuts import redirect, render
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
@@ -229,36 +227,3 @@ class InvitationAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()
@admin.register(models.RunIndexing)
class RunIndexingAdmin(admin.ModelAdmin):
"""Admin for running indexing commands."""
def changelist_view(self, request, extra_context=None):
"""Override to avoid querying the database and handle form submission."""
if request.method == "POST":
try:
call_command(
"index",
batch_size=int(request.POST.get("batch_size")),
lower_time_bound=request.POST.get("lower_time_bound"),
upper_time_bound=request.POST.get("upper_time_bound"),
crash_safe_mode=request.POST.get("crash_safe_mode"),
)
messages.success(request, _("Indexing triggered!"))
except Exception as e:
messages.error(
request,
_("Error running indexing command: %(error)s") % {"error": str(e)},
)
return render(
request=request,
template_name="runindexing.html",
context={
**self.admin_site.each_context(request),
"title": "Run Indexing Command",
"default_batch_size": settings.SEARCH_INDEXER_BATCH_SIZE,
},
)
+13 -39
View File
@@ -3,12 +3,11 @@ Handle search setup that needs to be done at bootstrap time.
"""
import logging
from datetime import datetime
import time
from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer
from core.tasks.indexing import batch_document_indexer_task
logger = logging.getLogger("docs.search.bootstrap_search")
@@ -28,31 +27,6 @@ class Command(BaseCommand):
default=50,
help="Indexation query batch size",
)
parser.add_argument(
"--lower-time-bound",
action="store",
dest="lower_time_bound",
type=datetime.fromisoformat,
default=None,
help="DateTime in ISO format. Only documents updated after this date will be indexed",
)
parser.add_argument(
"--upper-time-bound",
action="store",
dest="upper_time_bound",
type=datetime.fromisoformat,
default=None,
help="DateTime in ISO format. Only documents updated before this date will be indexed",
)
parser.add_argument(
"--crash-safe-mode",
action="store_true",
dest="crash_safe_mode",
default=True,
help="If True, order documents by updated_at and store the last indexed document.updated_at in cache. "
"This allows resuming indexing from the last successful batch in case of a crash "
"but is more computationally expensive due to sorting.",
)
def handle(self, *args, **options):
"""Launch and log search index generation."""
@@ -61,18 +35,18 @@ class Command(BaseCommand):
if not indexer:
raise CommandError("The indexer is not enabled or properly configured.")
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": options["crash_safe_mode"],
},
)
except Exception as err:
raise CommandError("Unable send indexing task to worker") from err
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
batch_size = options["batch_size"]
try:
count = indexer.index(batch_size=batch_size)
except Exception as err:
raise CommandError("Unable to regenerate index") from err
duration = time.perf_counter() - start
logger.info(
"Document indexing task sent to worker",
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)
@@ -1,23 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-26 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0031_clean_onboarding_accesses'),
]
operations = [
migrations.CreateModel(
name='RunIndexing',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
options={
'verbose_name': 'Run Indexing',
'managed': False,
},
),
]
-10
View File
@@ -2029,13 +2029,3 @@ class Invitation(BaseModel):
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
}
class RunIndexing(models.Model):
"""Proxy model for indexing management in admin."""
class Meta:
"""Meta options."""
managed = False
verbose_name = _("Run Indexing")
+16 -23
View File
@@ -1,6 +1,5 @@
"""Document search index management utilities and indexers"""
import itertools
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
@@ -8,7 +7,6 @@ from functools import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
@@ -20,9 +18,6 @@ from core.enums import SearchType
logger = logging.getLogger(__name__)
BULK_INDEXER_CHECKPOINT = "bulk-indexer-checkpoint"
@cache
def get_document_indexer():
"""Returns an instance of indexer service if enabled and properly configured."""
@@ -130,7 +125,7 @@ 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, crash_safe_mode=False):
def index(self, queryset=None, batch_size=None):
"""
Fetch documents in batches, serialize them, and push to the search backend.
@@ -139,37 +134,35 @@ class BaseDocumentIndexer(ABC):
Defaults to all documents without filter.
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
and store the last indexed document.updated_at in cache.
This allows resuming indexing from the last successful batch in case of a crash
but is more computationally expensive due to sorting.
"""
last_id = 0
count = 0
queryset = queryset or models.Document.objects.all()
batch_size = batch_size or self.batch_size
if crash_safe_mode:
queryset = queryset.order_by("updated_at")
while True:
documents_batch = list(
queryset.filter(
id__gt=last_id,
).order_by("id")[:batch_size]
)
if not documents_batch:
break
for documents_batch in itertools.batched(queryset.iterator(), batch_size):
doc_paths = [doc.path for doc in documents_batch]
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 not serialized_batch:
continue
self.push(serialized_batch)
count += len(serialized_batch)
if crash_safe_mode:
django_cache.set(
BULK_INDEXER_CHECKPOINT, serialized_batch[-1]["updated_at"]
)
if serialized_batch:
self.push(serialized_batch)
count += len(serialized_batch)
return count
+1 -1
View File
@@ -10,7 +10,7 @@ from django.db.models import signals
from django.dispatch import receiver
from core import models
from core.tasks.indexing import trigger_batch_document_indexer
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
-130
View File
@@ -1,130 +0,0 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from core.utils import build_indexable_documents_queryset
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""
Celery Task: Indexes a single document by its ID.
Args:
document_id: Primary key of the document to index.
"""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Acquire a throttle lock to prevent multiple batch indexation tasks during countdown.
implements a debouncing pattern: only the first call during the timeout period
will succeed, subsequent calls are skipped until the timeout expires.
Args:
timeout (int): Lock duration in seconds (countdown period).
atomic (bool): Use Redis locks for atomic operations if available.
Returns:
bool: True if lock acquired (first call), False if already held (subsequent calls).
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# cache.add() is atomic test-and-set operation:
# - If key doesn't exist: creates it with timeout and returns True
# - If key already exists: does nothing and returns False
# The key expires after timeout seconds, releasing the lock.
# The value 1 is irrelevant, only the key presence/absence matters.
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(lower_time_bound=None, upper_time_bound=None, **kwargs):
"""
Celery Task: Batch indexes all documents modified since timestamp.
Args:
lower_time_bound (datetime|isoformat, optional):
indexes documents updated or deleted after this timestamp.
upper_time_bound (datetime|isoformat, optional):
indexes documents updated or deleted before this timestamp.
"""
indexer = get_document_indexer()
if not indexer:
logger.warning("Indexing task triggered but no indexer configured: skipping")
return
count = indexer.index(
queryset=build_indexable_documents_queryset(
lower_time_bound=lower_time_bound, upper_time_bound=upper_time_bound
),
**kwargs,
)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(document):
"""
Trigger document indexation with optional debounce mechanism.
behavior depends on SEARCH_INDEXER_COUNTDOWN setting:
- if countdown > 0 sec (async batch mode):
* schedules a batch indexation task after countdown in seconds
* uses throttle mechanism to ensure only ONE batch task runs per countdown period
* all documents modified since first trigger are indexed together
- if countdown == 0 sec (sync mode):
* executes indexation synchronously in the current thread
* no batching, no throttling, no Celery task queuing
Args:
document (Document): the document instance that triggered the indexation.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation is disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# use throttle to ensure only one task is scheduled per countdown period.
# if throttle acquired, schedule batch task; otherwise skip.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
document.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
kwargs={"lower_time_bound": document.updated_at}, countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", document.pk)
else:
document_indexer_task.apply(args=[document.pk])
+95
View File
@@ -0,0 +1,95 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Enable the task throttle flag for a delay.
Uses redis locks if available to ensure atomic changes
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# Use add() here :
# - set the flag and returns true if not exist
# - do nothing and return false if exist
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(timestamp):
"""Celery Task : Sends indexation query for a batch of documents."""
indexer = get_document_indexer()
if indexer:
queryset = models.Document.objects.filter(
Q(updated_at__gte=timestamp)
| Q(deleted_at__gte=timestamp)
| Q(ancestors_deleted_at__gte=timestamp)
)
count = indexer.index(queryset)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(document):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# Each time this method is called during a countdown, we increment the
# counter and each task decrease it, so the index be run only once.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
document.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
args=[document.updated_at], countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", document.pk)
else:
document_indexer_task.apply(args=[document.pk])
@@ -1,43 +0,0 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<form method="POST" >
{% csrf_token %}
<hr style="margin-bottom: 10px;">
<p>
This command triggers the indexing of all documents within the specified time bound.
</p>
<p>
See <b>docs/commands/index.md</b> for more details.
</p>
<hr style="margin-bottom: 20px;">
<div>
<label for="batch_size" style="margin-right: 10px">Batch size</label>
<input id="batch_size" type="number" name="batch_size" value={{ default_batch_size }} style="width: 80px;" >
</div>
<div>
<label for="lower_time_bound" style="margin-right: 10px">Lower time bound (optional)</label>
<input id="lower_time_bound" type="datetime-local" name="lower_time_bound" >
</div>
<div>
<label for="upper_time_bound" style="margin-right: 10px">Upper time bound (optional)</label>
<input id="upper_time_bound" type="datetime-local" name="upper_time_bound" >
</div>
<div>
<label for="crash_safe_mode" style="margin-right: 10px">Crash save mode</label>
<input id="crash_safe_mode" type="checkbox" name="crash_safe_mode" value="true" checked>
</div>
<input type="submit" value="{% translate 'Run Indexing' %}" style="margin-top: 20px;">
</form>
{% endblock %}
+12 -147
View File
@@ -2,24 +2,21 @@
Unit test for `index` command.
"""
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.services.search_indexers import BULK_INDEXER_CHECKPOINT, FindDocumentIndexer
from core.tests.conftest import create_document_with_updated_at
from core.services.search_indexers import FindDocumentIndexer
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_without_bound_success():
def test_index():
"""Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
@@ -40,152 +37,20 @@ def test_index_without_bound_success():
}
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command("index", crash_safe_mode=False)
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"),
)
# crash_safe_mode deactivated -> no checkpoint stored
assert cache.get(BULK_INDEXER_CHECKPOINT) is None
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_with_both_bounds_success():
"""Test the command `index` for all documents within time bound."""
cache.clear()
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=30)
document_too_early = create_document_with_updated_at(
lower_time_bound - timedelta(days=10)
)
document_in_window_1 = create_document_with_updated_at(
lower_time_bound + timedelta(days=5)
)
document_in_window_2 = create_document_with_updated_at(
lower_time_bound + timedelta(days=15)
)
document_too_late = create_document_with_updated_at(
upper_time_bound + timedelta(days=10)
)
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index",
lower_time_bound=lower_time_bound.isoformat(),
upper_time_bound=upper_time_bound.isoformat(),
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"),
)
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# Only documents in window should be indexed
assert str(document_too_early.id) not in all_push_call_args
assert str(document_in_window_1.id) in all_push_call_args
assert str(document_in_window_2.id) in all_push_call_args
assert str(document_too_late.id) not in all_push_call_args
# Checkpoint should be set to last indexed document's updated_at
assert (
cache.get(BULK_INDEXER_CHECKPOINT)
== document_in_window_2.updated_at.isoformat()
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_with_crash_recovery():
"""Test resuming indexing from checkpoint after a crash."""
cache.clear()
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=60)
batch_size = 2
documents = [
# batch 0
create_document_with_updated_at(lower_time_bound + timedelta(days=5)),
create_document_with_updated_at(lower_time_bound + timedelta(days=10)),
# batch 1
create_document_with_updated_at(lower_time_bound + timedelta(days=20)),
create_document_with_updated_at(lower_time_bound + timedelta(days=25)),
# batch 2 - will crash here
create_document_with_updated_at(lower_time_bound + timedelta(days=30)),
create_document_with_updated_at(lower_time_bound + timedelta(days=35)),
# batch 3
create_document_with_updated_at(lower_time_bound + timedelta(days=40)),
create_document_with_updated_at(lower_time_bound + timedelta(days=45)),
]
# First run: simulate crash on batch 3
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
def push_with_failure(data):
# Crash when we encounter document at index 4 (batch 2 with batch_size=2)
if str(documents[4].id) in [document["id"] for document in data]:
raise ConnectionError("Simulated indexing error")
mock_push.side_effect = push_with_failure
call_command(
"index",
batch_size=batch_size,
lower_time_bound=lower_time_bound.isoformat(),
upper_time_bound=upper_time_bound.isoformat(),
)
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# First 2 batches should have been indexed and checkpoint saved
checkpoint = cache.get(BULK_INDEXER_CHECKPOINT)
assert checkpoint == documents[3].updated_at.isoformat()
# first 2 batches should be indexed successfully
for i in range(0, 4):
assert str(documents[i].id) in all_push_call_args
# next batch should have been attempted but failed
for i in range(4, 6):
assert str(documents[i].id) in all_push_call_args
# last batches indexing should not have been attempted
for i in range(6, 8):
assert str(documents[i].id) not in all_push_call_args
# Second run: resume from checkpoint
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index",
batch_size=batch_size,
lower_time_bound=checkpoint,
upper_time_bound=upper_time_bound.isoformat(),
)
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# first 2 batches should NOT be re-indexed
# except the last document of the last batch which is on the checkpoint boundary
for i in range(0, 3):
assert str(documents[i].id) not in all_push_call_args
# next batches should be indexed including the document at the checkpoint boundary
# which has already been indexed and is re-indexed
for i in range(4, 8):
assert str(documents[i].id) in all_push_call_args
@pytest.mark.django_db
+1 -8
View File
@@ -8,7 +8,7 @@ from django.core.cache import cache
import pytest
import responses
from core import factories, models
from core import factories
from core.tests.utils.urls import reload_urls
USER = "user"
@@ -143,10 +143,3 @@ def user_token():
A fixture to create a user token for testing.
"""
return build_authorization_bearer("some_token")
def create_document_with_updated_at(updated_at, **kwargs):
"""Helper to create a document with a specific updated_at, bypassing auto_now=True."""
document = factories.DocumentFactory(**kwargs)
models.Document.objects.filter(pk=document.pk).update(updated_at=updated_at)
return models.Document.objects.get(pk=document.pk)
-126
View File
@@ -2,7 +2,6 @@
import base64
import uuid
from datetime import datetime, timedelta, timezone
from django.core.cache import cache
@@ -10,7 +9,6 @@ import pycrdt
import pytest
from core import factories, utils
from core.tests.conftest import create_document_with_updated_at
pytestmark = pytest.mark.django_db
@@ -242,127 +240,3 @@ def test_utils_get_value_by_pattern_no_match():
result = utils.get_value_by_pattern(data, r"^title\.")
assert result == []
def test_utils_build_indexable_documents_queryset_no_filters():
"""Test build_indexable_documents_queryset with no filters returns all documents."""
factories.DocumentFactory.create_batch(3)
queryset = utils.build_indexable_documents_queryset()
assert queryset.count() == 3
def test_utils_build_indexable_documents_queryset_oldest_updated_at():
"""Test filtering by oldest_updated_at includes documents updated after timestamp."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_too_old = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
document_at_boundary = create_document_with_updated_at(lower_time_bound)
document_recent = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=15)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 2
assert document_too_old not in queryset
assert document_at_boundary in queryset
assert document_recent in queryset
def test_utils_build_indexable_documents_queryset_newest_updated_at():
"""Test filtering by newest_updated_at includes documents updated before timestamp."""
upper_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_old = create_document_with_updated_at(
updated_at=upper_time_bound - timedelta(days=30)
)
document_at_boundary = create_document_with_updated_at(upper_time_bound)
document_too_recent = create_document_with_updated_at(
updated_at=upper_time_bound + timedelta(days=15)
)
queryset = utils.build_indexable_documents_queryset(
upper_time_bound=upper_time_bound
)
assert queryset.count() == 2
assert document_old in queryset
assert document_at_boundary in queryset
assert document_too_recent not in queryset
def test_utils_build_indexable_documents_queryset_both_bounds():
"""Test filtering with both oldest and newest bounds."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=30)
document_too_early = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=10)
)
document_in_window = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=5)
)
other_document_in_window = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=15)
)
document_too_late = create_document_with_updated_at(
updated_at=upper_time_bound + timedelta(days=10)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound, upper_time_bound=upper_time_bound
)
assert queryset.count() == 2
assert document_too_early not in queryset
assert document_in_window in queryset
assert other_document_in_window in queryset
assert document_too_late not in queryset
def test_utils_build_indexable_documents_queryset_deleted_at():
"""Test filtering includes documents with deleted_at in range."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_deleted_in_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30),
deleted_at=lower_time_bound + timedelta(days=15),
)
document_updated_before_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 1
assert document_deleted_in_range in queryset
assert document_updated_before_range not in queryset
def test_utils_build_indexable_documents_queryset_ancestors_deleted_at():
"""Test filtering includes documents with ancestors_deleted_at in range."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_ancestor_deleted_in_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30),
ancestors_deleted_at=lower_time_bound + timedelta(days=15),
)
document_updated_before_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 1
assert document_ancestor_deleted_in_range in queryset
assert document_updated_before_range not in queryset
+1 -45
View File
@@ -8,7 +8,7 @@ from collections import defaultdict
from django.core.cache import cache
from django.db import models as db
from django.db.models import Q, Subquery
from django.db.models import Subquery
import pycrdt
from bs4 import BeautifulSoup
@@ -168,47 +168,3 @@ def users_sharing_documents_with(user):
elapsed,
)
return result
def build_indexable_documents_queryset(lower_time_bound=None, upper_time_bound=None):
"""
Build a queryset of documents to index filtered by timestamp range.
filters out documents that have been updated, or deleted outside the time range.
Args:
lower_time_bound (datetime, optional):
keeps documents updated or deleted after this timestamp.
upper_time_bound (datetime, optional):
keeps documents updated or deleted before this timestamp.
Returns:
QuerySet: Filtered Document queryset ready for indexation.
"""
queryset = models.Document.objects.all()
conditions = Q()
if lower_time_bound and upper_time_bound:
conditions = (
Q(updated_at__gte=lower_time_bound, updated_at__lte=upper_time_bound)
| Q(deleted_at__gte=lower_time_bound, deleted_at__lte=upper_time_bound)
| Q(
ancestors_deleted_at__gte=lower_time_bound,
ancestors_deleted_at__lte=upper_time_bound,
)
)
elif lower_time_bound:
conditions = (
Q(updated_at__gte=lower_time_bound)
| Q(deleted_at__gte=lower_time_bound)
| Q(ancestors_deleted_at__gte=lower_time_bound)
)
elif upper_time_bound:
conditions = (
Q(updated_at__lte=upper_time_bound)
| Q(deleted_at__lte=upper_time_bound)
| Q(ancestors_deleted_at__lte=upper_time_bound)
)
return queryset.filter(conditions)
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
mockedDocument,
overrideConfig,
verifyDocName,
@@ -47,9 +46,9 @@ test.describe('Doc AI feature', () => {
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
await expect(
page.locator('button[data-test="convertMarkdown"]'),
).toHaveCount(1);
await expect(
page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden();
@@ -179,18 +178,32 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
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 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 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: '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, 'German', { exact: true }).click();
await page.getByRole('menuitem', { name: 'German', exact: true }).click();
await expect(editor.getByText('Hallo Welt')).toBeVisible();
});
@@ -256,15 +269,23 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else {
await expect(getMenuItem(page, 'Use as prompt')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
}
if (ai_translate) {
await expect(getMenuItem(page, 'Language')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else {
await expect(getMenuItem(page, 'Language')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
});
});
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
closeHeaderMenu,
createDoc,
getMenuItem,
getOtherBrowserName,
verifyDocName,
} from './utils-common';
@@ -152,7 +151,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 getMenuItem(thread, 'Edit comment').click();
await thread.getByRole('menuitem', { name: '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();
@@ -177,7 +176,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 getMenuItem(thread, 'Delete comment').click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(
thread.getByText('This is a second comment').first(),
).toBeHidden();
@@ -210,7 +209,7 @@ test.describe('Doc Comments', () => {
await thread.getByText('This is a new comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await getMenuItem(thread, 'Delete comment').click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS(
@@ -5,7 +5,6 @@ import cs from 'convert-stream';
import {
createDoc,
getMenuItem,
goToGridDoc,
overrideConfig,
verifyDocName,
@@ -148,20 +147,18 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:4444/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
@@ -578,12 +575,10 @@ test.describe('Doc Editor', () => {
await page.reload();
responseCanEditPromise = page.waitForResponse(
responseCanEdit = await page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
@@ -609,7 +604,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, 'Reading').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
getOtherBrowserName,
mockedListDocs,
toggleHeaderMenu,
@@ -207,7 +206,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Move into a doc').click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -295,7 +294,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Move into a doc').click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -342,7 +341,9 @@ test.describe('Doc grid move', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await getMenuItem(otherPage, 'Administrator').click();
await otherPage
.getByRole('menuitemradio', { name: 'Administrator' })
.click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(otherPage.getByText('Access Requests')).toBeHidden();
@@ -353,7 +354,7 @@ test.describe('Doc grid move', () => {
await page.reload();
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Move into a doc').click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -1,11 +1,6 @@
import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
verifyDocName,
} from './utils-common';
import { createDoc, getGridRow, verifyDocName } from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
type SmallDoc = {
@@ -104,7 +99,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(
page.getByRole('dialog').getByText('Share the document'),
@@ -120,7 +115,7 @@ test.describe('Document grid item options', () => {
// Pin
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Pin').click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
// Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
@@ -147,7 +142,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Delete').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -79,7 +78,7 @@ test.describe('Doc Header', () => {
await page.getByTestId('doc-visibility').click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await page.getByRole('button', { name: 'close' }).first().click();
@@ -153,8 +152,10 @@ test.describe('Doc Header', () => {
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = getMenuItem(page, 'Add emoji');
const removeEmojiMenuItem = getMenuItem(page, 'Remove emoji');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
@@ -208,7 +209,7 @@ test.describe('Doc Header', () => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Delete document').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -236,7 +237,7 @@ test.describe('Doc Header', () => {
hasText: randomDoc,
});
expect(await row.count()).toBe(0);
await expect(row).toHaveCount(0);
});
test('it checks the options available if administrator', async ({ page }) => {
@@ -270,10 +271,12 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -293,7 +296,7 @@ test.describe('Doc Header', () => {
await invitationRole.click();
await getMenuItem(page, 'Remove access').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
@@ -305,7 +308,9 @@ test.describe('Doc Header', () => {
await expect(roles).toBeVisible();
await roles.click();
await expect(getMenuItem(page, 'Remove access')).toBeEnabled();
await expect(
page.getByRole('menuitemradio', { name: 'Remove access' }),
).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -345,10 +350,12 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -415,10 +422,12 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -473,7 +482,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Copy as Markdown').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(
page.getByText('Copied as Markdown to clipboard'),
).toBeVisible();
@@ -537,7 +546,7 @@ test.describe('Doc Header', () => {
.click();
// Pin
await getMenuItem(page, 'Pin').click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
@@ -558,11 +567,11 @@ test.describe('Doc Header', () => {
.click();
// Unpin
await getMenuItem(page, 'Unpin').click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(getMenuItem(page, 'Pin')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await page.goto('/');
@@ -580,7 +589,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Duplicate').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await expect(
page.getByText('Document duplicated successfully!'),
).toBeVisible();
@@ -595,7 +604,7 @@ test.describe('Doc Header', () => {
await expect(row.getByText(duplicateTitle)).toBeVisible();
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Duplicate').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
await page.getByText(duplicateDuplicateTitle).click();
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
@@ -628,7 +637,7 @@ test.describe('Doc Header', () => {
const currentUrl = page.url();
await getMenuItem(page, 'Duplicate').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await expect(page).not.toHaveURL(new RegExp(currentUrl));
@@ -667,8 +676,10 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Copy link')).toBeVisible();
await getMenuItem(page, 'Share').click();
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -691,7 +702,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', {
name: 'Share the document',
@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
return dt;
}, filesData);
await page.dispatchEvent(selector, 'drop', { dataTransfer });
await page.locator(selector).dispatchEvent('drop', { dataTransfer });
};
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createDoc, verifyDocName } from './utils-common';
import { updateShareLink } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -53,17 +53,19 @@ test.describe('Inherited share accesses', () => {
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
await docVisibilityCard.getByText('Reading').click();
await getMenuItem(page, 'Editing').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
// Verify inherited link
await docVisibilityCard.getByText('Connected').click();
await expect(getMenuItem(page, 'Private')).toBeDisabled();
await expect(
page.getByRole('menuitemradio', { name: 'Private' }),
).toBeDisabled();
// Update child link
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect(
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
BROWSERS,
createDoc,
getMenuItem,
keyCloakSignIn,
randomName,
verifyDocName,
@@ -17,6 +16,41 @@ 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(
@@ -76,13 +110,21 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByTestId('doc-role-dropdown').click();
await expect(getMenuItem(page, 'Reader')).toBeVisible();
await expect(getMenuItem(page, 'Editor')).toBeVisible();
await expect(getMenuItem(page, 'Owner')).toBeVisible();
await expect(getMenuItem(page, 'Administrator')).toBeVisible();
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();
// Validate
await getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await page.getByTestId('doc-share-invite-button').click();
// Check invitation added
@@ -128,7 +170,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 getMenuItem(page, 'Owner').click();
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -146,7 +188,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, 'Owner').click();
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -183,7 +225,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 getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -210,13 +252,13 @@ test.describe('Document create member', () => {
);
await userInvitation.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, 'Reader').click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
await userInvitation.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, 'Remove access').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(userInvitation).toBeHidden();
});
@@ -268,7 +310,7 @@ test.describe('Document create member', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createDoc, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
@@ -160,7 +160,9 @@ test.describe('Document list members', () => {
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await expect(getMenuItem(page, 'Administrator')).toBeDisabled();
await expect(
page.getByRole('menuitemradio', { name: 'Administrator' }),
).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
@@ -183,18 +185,20 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await newUserRoles.click();
await expect(getMenuItem(page, 'Owner')).toBeDisabled();
await expect(
page.getByRole('menuitemradio', { name: 'Owner' }),
).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await getMenuItem(page, 'Reader').click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
await list.click({
force: true, // Force click to close the dropdown
});
@@ -234,11 +238,11 @@ test.describe('Document list members', () => {
await expect(userReader).toBeVisible();
await userReaderRole.click();
await getMenuItem(page, 'Remove access').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(userReader).toBeHidden();
await mySelfRole.click();
await getMenuItem(page, 'Remove access').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createDoc, verifyDocName } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -136,9 +136,13 @@ test.describe('Document search', () => {
await filters.click();
await filters.getByRole('button', { name: 'Current doc' }).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('menuitemcheckbox', { name: 'All docs' }),
).toBeVisible();
await expect(
page.getByRole('menuitemcheckbox', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('menuitemcheckbox', { name: 'All docs' }).click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
updateDocTitle,
verifyDocName,
@@ -43,15 +42,12 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
const allSubPageItems = docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItems).toHaveCount(2);
// Check that elements are in the correct order
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
await expect(allSubPageItems.nth(0).getByText('first move')).toBeVisible();
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
// Will move the first sub page to the second position
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
@@ -91,17 +87,15 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check that elements are in the correct order
const allSubPageItemsAfterReload = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItemsAfterReload.length).toBe(2);
const allSubPageItemsAfterReload =
docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItemsAfterReload).toHaveCount(2);
await expect(
allSubPageItemsAfterReload[0].getByText('second move'),
allSubPageItemsAfterReload.nth(0).getByText('second move'),
).toBeVisible();
await expect(
allSubPageItemsAfterReload[1].getByText('first move'),
allSubPageItemsAfterReload.nth(1).getByText('first move'),
).toBeVisible();
});
@@ -163,7 +157,7 @@ test.describe('Doc Tree', () => {
);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -193,10 +187,9 @@ test.describe('Doc Tree', () => {
const menu = child.getByText(`more_horiz`);
await menu.click();
await expect(getMenuItem(page, 'Move to my docs')).toHaveAttribute(
'aria-disabled',
'true',
);
await expect(
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
});
test('keyboard navigation with Enter key opens documents', async ({
@@ -340,7 +333,9 @@ test.describe('Doc Tree', () => {
await row.hover();
const menu = row.getByText(`more_horiz`);
await menu.click();
await expect(getMenuItem(page, 'Remove emoji')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Remove emoji' }),
).toBeHidden();
// Close the menu
await page.keyboard.press('Escape');
@@ -360,7 +355,7 @@ test.describe('Doc Tree', () => {
// Now remove the emoji using the new action
await row.hover();
await menu.click();
await getMenuItem(page, 'Remove emoji').click();
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).toBeHidden();
@@ -390,7 +385,7 @@ test.describe('Doc Tree: Inheritance', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -21,7 +20,7 @@ test.describe('Doc Version', () => {
// Initially, there is no version
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByRole('dialog', { name: 'Version history' });
@@ -75,14 +74,14 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(2);
await items[1].click();
const items = panel.locator('.version-item');
await expect(items).toHaveCount(2);
await items.nth(1).click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -90,7 +89,7 @@ test.describe('Doc Version', () => {
modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden();
await items[0].click();
await items.nth(0).click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeVisible();
@@ -101,7 +100,7 @@ test.describe('Doc Version', () => {
modal.getByText('It will create a second version'),
).toBeHidden();
await items[1].click();
await items.nth(1).click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -125,7 +124,9 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Version history')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -152,7 +153,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('Version list');
@@ -4,7 +4,6 @@ import {
BROWSERS,
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
@@ -47,17 +46,21 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(getMenuItem(page, 'Read only')).toBeHidden();
await expect(getMenuItem(page, 'Can read and edit')).toBeHidden();
await expect(
page.getByRole('menuitemradio', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitemradio', { name: 'Can read and edit' }),
).toBeHidden();
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -202,7 +205,7 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -210,7 +213,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, 'Reading').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -296,14 +299,14 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, 'Editing').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -387,7 +390,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -435,7 +438,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -533,7 +536,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -541,7 +544,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, 'Editing').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { getMenuItem, overrideConfig } from './utils-common';
import { 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 getMenuItem(page, 'Français').click();
await page.getByRole('menuitemradio', { name: '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 getMenuItem(page, 'Français').click();
await page.getByRole('menuitemradio', { name: 'Français' }).click();
await expect(
page
@@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
import {
TestLanguage,
getMenuItem,
overrideConfig,
waitForLanguageSwitch,
} from './utils-common';
@@ -45,7 +44,7 @@ test.describe('Help feature', () => {
await page.getByRole('button', { name: 'Open help menu' }).click();
await getMenuItem(page, 'Onboarding').click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible();
@@ -88,7 +87,7 @@ test.describe('Help feature', () => {
test('closes modal with Skip button', async ({ page }) => {
await page.getByRole('button', { name: 'Open help menu' }).click();
await getMenuItem(page, 'Onboarding').click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible();
@@ -109,7 +108,7 @@ test.describe('Help feature', () => {
await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click();
await getMenuItem(page, 'Premiers pas').click();
await page.getByRole('menuitem', { name: 'Premiers pas' }).click();
const modal = page.getByLabel('Apprenez les principes fondamentaux');
@@ -75,7 +75,7 @@ test.describe('Language', () => {
await expect(page.locator('[role="menu"]')).toBeVisible();
const menuItems = page.locator('[role="menuitem"], [role="menuitemradio"]');
const menuItems = page.locator('[role="menuitemradio"]');
await expect(menuItems.first()).toBeVisible();
await menuItems.first().click();
@@ -3,16 +3,6 @@ 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';
@@ -392,12 +382,12 @@ export async function waitForLanguageSwitch(
await languagePicker.click();
await getMenuItem(page, lang.label).click();
await page.getByRole('menuitemradio', { name: lang.label }).click();
}
export const clickInEditorMenu = async (page: Page, textButton: string) => {
await page.getByRole('button', { name: 'Open the document options' }).click();
await getMenuItem(page, textButton).click();
await page.getByRole('menuitem', { name: textButton }).click();
};
export const clickInGridMenu = async (
@@ -408,7 +398,7 @@ export const clickInGridMenu = async (
await row
.getByRole('button', { name: /Open the menu of actions for the document/ })
.click();
await getMenuItem(page, textButton).click();
await page.getByRole('menuitem', { name: textButton }).click();
};
export const writeReport = async (
@@ -2,7 +2,6 @@ import { Page, chromium, expect } from '@playwright/test';
import {
BrowserName,
getMenuItem,
getOtherBrowserName,
keyCloakSignIn,
verifyDocName,
@@ -40,7 +39,7 @@ export const addNewMember = async (
// Choose a role
await page.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, role).click();
await page.getByRole('menuitemradio', { name: role }).click();
await page.getByTestId('doc-share-invite-button').click();
return users[index].email;
@@ -52,7 +51,7 @@ export const updateShareLink = async (
linkRole?: LinkRole | null,
) => {
await page.getByTestId('doc-visibility').click();
await getMenuItem(page, linkReach).click();
await page.getByRole('menuitemradio', { name: linkReach }).click();
const visibilityUpdatedText = page
.getByText('The document visibility has been updated')
@@ -62,7 +61,7 @@ export const updateShareLink = async (
if (linkRole) {
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, linkRole).click();
await page.getByRole('menuitemradio', { name: linkRole }).click();
await expect(visibilityUpdatedText).toBeVisible();
}
};
@@ -77,7 +76,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 getMenuItem(page, role).click();
await page.getByRole('menuitemradio', { name: role }).click();
await list.click();
};
+20 -21
View File
@@ -23,7 +23,7 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@ai-sdk/openai": "3.0.19",
"@ai-sdk/openai": "3.0.45",
"@blocknote/code-block": "0.47.1",
"@blocknote/core": "0.47.1",
"@blocknote/mantine": "0.47.1",
@@ -38,20 +38,20 @@
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.35",
"@fontsource-variable/material-symbols-outlined": "5.2.38",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.19.6",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.14",
"@mantine/core": "8.3.17",
"@mantine/hooks": "8.3.17",
"@react-aria/live-announcer": "3.4.4",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.38.0",
"@sentry/nextjs": "10.43.0",
"@tanstack/react-query": "5.90.21",
"@tiptap/extensions": "*",
"ai": "6.0.49",
"ai": "6.0.128",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -59,28 +59,28 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.8.12",
"i18next": "25.8.18",
"i18next-browser-languagedetector": "8.2.1",
"idb": "8.0.3",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "16.1.7",
"posthog-js": "1.347.2",
"posthog-js": "1.360.2",
"react": "*",
"react-aria-components": "1.15.1",
"react-aria-components": "1.16.0",
"react-dom": "*",
"react-dropzone": "15.0.0",
"react-i18next": "16.5.4",
"react-intersection-observer": "10.0.2",
"react-i18next": "16.5.8",
"react-intersection-observer": "10.0.3",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.3.9",
"styled-components": "6.3.11",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zod": "3.25.28",
"zustand": "5.0.11"
"zod": "4.3.6",
"zustand": "5.0.12"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
@@ -89,26 +89,25 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.23",
"@types/lodash": "4.17.24",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.1.4",
"@vitejs/plugin-react": "6.0.1",
"cross-env": "10.1.0",
"dotenv": "17.3.1",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "28.1.0",
"jsdom": "29.0.0",
"node-fetch": "2.7.0",
"prettier": "3.8.1",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"webpack": "5.105.2",
"vitest": "4.1.0",
"webpack": "5.105.4",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"
@@ -1,17 +1,17 @@
import { forwardRef } from 'react';
import { Ref, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = BoxType & {
export type BoxButtonType = Omit<BoxType, 'ref'> & {
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 = BoxType & {
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
({ $css, disabled, ...props }, ref) => {
const theme = props.$theme || 'gray';
const variation = props.$variation || 'primary';
@@ -31,16 +31,18 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
<Box
ref={ref}
as="button"
type="button"
$background="none"
$margin="none"
$padding="none"
$hasTransition
aria-disabled={disabled || undefined}
$css={css`
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
cursor: ${disabled ? 'not-allowed' : 'pointer'};
border: none;
outline: none;
font-family: inherit;
color: ${props.disabled &&
color: ${disabled &&
`var(--c--contextuals--content--semantic--disabled--primary)`};
&:focus-visible {
transition: none;
@@ -53,11 +55,11 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
`}
{...props}
className={`--docs--box-button ${props.className || ''}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) {
return;
}
props.onClick?.(event);
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
}}
/>
);
@@ -26,6 +26,7 @@ import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: ReactNode;
label: string;
lang?: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
@@ -69,7 +70,10 @@ export const DropdownMenu = ({
const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null);
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const isSingleSelectable = options.some(
(option) => option.isSelected !== undefined,
);
const onOpenChange = useCallback(
(isOpen: boolean) => {
@@ -110,10 +114,6 @@ export const DropdownMenu = ({
[onOpenChange],
);
const hasSelectable =
selectedValues !== undefined ||
options.some((option) => option.isSelected !== undefined);
if (disabled) {
return children;
}
@@ -176,20 +176,25 @@ export const DropdownMenu = ({
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
const ariaChecked = hasSelectable
? option.isSelected ||
selectedValues?.includes(option.value ?? '') ||
false
: undefined;
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}`;
return (
<Fragment key={option.label}>
<Fragment key={optionKey}>
<BoxButton
ref={(el) => {
menuItemRefs.current[index] = el;
}}
role={hasSelectable ? 'menuitemradio' : 'menuitem'}
aria-checked={ariaChecked}
role={itemRole}
aria-checked={itemRole === 'menuitem' ? undefined : isSelected}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
@@ -200,7 +205,6 @@ export const DropdownMenu = ({
triggerOption(option);
}}
onKeyDown={keyboardAction(() => triggerOption(option))}
key={option.label}
$align="center"
$justify="space-between"
$background="var(--c--contextuals--background--surface--primary)"
@@ -271,16 +275,16 @@ export const DropdownMenu = ({
<Box
$theme="neutral"
$variation={isDisabled ? 'tertiary' : 'primary'}
aria-hidden="true"
>
{option.icon}
</Box>
)}
<Text $variation={isDisabled ? 'tertiary' : 'primary'}>
{option.label}
<span lang={option.lang}>{option.label}</span>
</Text>
</Box>
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
{isSelected && (
<Icon
iconName="check"
$size="20px"
@@ -58,7 +58,7 @@ describe('<DropdownMenu />', () => {
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
});
test('renders menuitemradio role with aria-checked when selectedValues is provided', async () => {
test('renders menuitemcheckbox role with aria-checked when selectedValues is provided', async () => {
const optionsWithValues: DropdownMenuOption[] = [
{ label: 'English', value: 'en', callback: vi.fn() },
{ label: 'Français', value: 'fr', callback: vi.fn() },
@@ -77,12 +77,12 @@ describe('<DropdownMenu />', () => {
{ wrapper: AppWrapper },
);
const radios = screen.getAllByRole('menuitemradio');
expect(radios).toHaveLength(3);
const checkboxes = screen.getAllByRole('menuitemcheckbox');
expect(checkboxes).toHaveLength(3);
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
expect(checkboxes[0]).toHaveAttribute('aria-checked', 'false');
expect(checkboxes[1]).toHaveAttribute('aria-checked', 'true');
expect(checkboxes[2]).toHaveAttribute('aria-checked', 'false');
});
test('trigger button has aria-haspopup and aria-expanded', async () => {
@@ -6,7 +6,7 @@ type UseDropdownKeyboardNavProps = {
isOpen: boolean;
focusedIndex: number;
options: DropdownMenuOption[];
menuItemRefs: RefObject<(HTMLDivElement | null)[]>;
menuItemRefs: RefObject<(HTMLButtonElement | null)[]>;
setFocusedIndex: (index: number) => void;
onOpenChange: (isOpen: boolean) => void;
};
@@ -48,7 +48,7 @@ export const QuickSearchInput = ({
$direction="row"
$align="center"
className="quick-search-input"
$gap={spacingsTokens['2xs']}
$gap={spacingsTokens['xxs']}
$padding={{ horizontal: 'base', vertical: 'xxs' }}
>
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
@@ -62,6 +62,7 @@ export const QuickSearchInput = ({
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
minLength={6}
data-testid="quick-search-input"
/>
</Box>
@@ -18,14 +18,15 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-input] {
border: none;
width: 100%;
font-size: 17px;
font-size: 16px;
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--globals--colors--gray-500);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
}
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { toBase64 } from '@/utils/string';
import { isFirefox } from '@/utils/userAgent';
@@ -1,4 +1,9 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/docs/doc-export/components/ModalExport', () => ({
ModalExport: vi.fn(),
}));
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
describe('useModuleExport', () => {
@@ -16,12 +21,12 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
}, 15000);
});
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toHaveProperty('ModalExport');
}, 15000);
});
});
@@ -1,6 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
@@ -40,17 +38,11 @@ describe('DocToolBox - Licence', () => {
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = await screen.findByLabelText('Export the document');
await userEvent.click(optionsButton);
// Wait for the export modal to be visible, then assert on its content text.
await screen.findByTestId('modal-export-title');
expect(
screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
await screen.findByLabelText('Export the document'),
).toBeInTheDocument();
}, 10000);
}, 15000);
test('The export button is not rendered when MIT version is activated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
@@ -68,5 +60,5 @@ describe('DocToolBox - Licence', () => {
expect(
screen.queryByLabelText('Export the document'),
).not.toBeInTheDocument();
});
}, 15000);
});
@@ -1,9 +1,8 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -39,7 +38,6 @@ import {
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { useFocusStore, useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -88,7 +86,6 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const treeContext = useTreeContext<Doc>();
const queryClient = useQueryClient();
const router = useRouter();
const { isChild, isTopRoot } = useDocUtils(doc);
@@ -114,16 +111,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
});
useEffect(() => {
if (selectHistoryModal.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [selectHistoryModal.isOpen, queryClient]);
// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();
@@ -4,7 +4,7 @@ import {
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../doc-editor';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore();
@@ -21,8 +21,8 @@ export const useCopyCurrentEditorToClipboard = () => {
try {
const editorContentFormatted =
asFormat === 'html'
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
? editor.blocksToHTMLLossy()
: editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
const successMessage =
asFormat === 'markdown'
@@ -44,7 +44,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
void queryClient.resetQueries({
queryKey: [queryKey],
});
});
@@ -44,7 +44,7 @@ export const DocIcon = ({
const { t } = useTranslation();
const { addLastFocus, restoreFocus } = useFocusStore();
const iconRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
const [pickerPosition, setPickerPosition] = useState<{
@@ -14,6 +14,11 @@ vi.mock('@/stores', () => ({
}),
}));
vi.mock('@gouvfr-lasuite/ui-kit', async () => ({
...(await vi.importActual('@gouvfr-lasuite/ui-kit')),
useTreeContext: () => null,
}));
describe('useDocTitleUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -124,7 +124,8 @@ export const DocShareAddMemberList = ({
$scope="surface"
$theme="tertiary"
$variation=""
$border="1px solid var(--c--contextuals--border--semantic--contextual--primary)"
$border="1px solid var(--c--contextuals--border--surface--primary)"
$margin={{ bottom: 'sm' }}
>
<Box
$direction="row"
@@ -289,7 +289,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
/>
)}
{showMemberSection && isRootDoc && (
<Box $padding={{ horizontal: 'base' }}>
<Box $padding={{ horizontal: 'base', top: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
@@ -301,6 +301,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect}
userQuery={userQuery}
minLength={API_USERS_SEARCH_QUERY_MIN_LENGTH}
/>
)}
</QuickSearch>
@@ -321,14 +322,35 @@ 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 || [];
@@ -347,7 +369,7 @@ const QuickSearchInviteInputSection = ({
);
return {
groupName: t('Search user result'),
groupName: hint,
elements: users,
endActions:
isEmail && !hasEmailInUsers
@@ -359,12 +381,12 @@ const QuickSearchInviteInputSection = ({
]
: undefined,
};
}, [onSelect, searchUsersRawData, t, userQuery]);
}, [searchUsersRawData, userQuery, hint, onSelect]);
return (
<Box
aria-label={t('List search user result card')}
$padding={{ horizontal: 'base', bottom: '3xs' }}
$padding={{ horizontal: 'base', bottom: '3xs', top: 'base' }}
>
<QuickSearchGroup
group={searchUserData}
@@ -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<HTMLDivElement | null>(null);
const buttonOptionRef = useRef<HTMLButtonElement | null>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement | null;
@@ -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<HTMLDivElement | null>(null);
const rootButtonOptionRef = useRef<HTMLButtonElement | null>(null);
const { t } = useTranslation();
@@ -33,7 +33,7 @@ type DocTreeItemActionsProps = {
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement | null>;
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
buttonOptionRef?: React.RefObject<HTMLButtonElement | 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<HTMLDivElement | null>(null);
const internalButtonRef = useRef<HTMLButtonElement | null>(null);
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
const router = useRouter();
const { t } = useTranslation();
@@ -4,9 +4,12 @@ import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
import { BlockNoteReader } from '@/docs/doc-editor/components/BlockNoteEditor';
import { DocEditorContainer } from '@/docs/doc-editor/components/DocEditor';
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
import { useDocVersion } from '../api/useDocVersion';
import { Versions } from '../types';
import { DocVersionHeader } from './DocVersionHeader';
@@ -1,4 +1,4 @@
import { Doc } from '../doc-management';
import { Doc } from '../doc-management/types';
export interface APIListVersions {
count: number;
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import i18next from 'i18next';
import { DateTime } from 'luxon';
@@ -73,7 +73,9 @@ describe('DocsGridItemDate', () => {
});
it(`should render rendered the updated_at field in the correct language`, async () => {
await i18next.changeLanguage('fr');
await act(async () => {
await i18next.changeLanguage('fr');
});
render(
<DocsGridItemDate
@@ -90,7 +92,9 @@ describe('DocsGridItemDate', () => {
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
await i18next.changeLanguage('en');
await act(async () => {
await i18next.changeLanguage('en');
});
});
[
@@ -1,3 +1,4 @@
import { announce } from '@react-aria/live-announcer';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -17,23 +18,35 @@ 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: () => changeLanguageSynchronized(backendLocale, user),
callback: async () => {
await changeLanguageSynchronized(backendLocale, user);
announce(
t('Language changed to {{language}}', {
language: backendLabel,
defaultValue: `Language changed to ${backendLabel}`,
}),
'polite',
);
},
};
});
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, t, user]);
// Extract current language label for display
const currentLanguageLabel =
conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
)?.[1] || language;
const [currentLanguageCode, currentLanguageLabel] = conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
) ?? [language, language];
return (
<DropdownMenu
@@ -65,7 +78,9 @@ export const LanguagePicker = () => {
$align="center"
>
<Icon iconName="translate" $color="inherit" $size="xl" />
{currentLanguageLabel}
<span lang={toLangTag(currentLanguageCode)}>
{currentLanguageLabel}
</span>
</Box>
</DropdownMenu>
);
@@ -1,12 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/../package.json', () => ({
default: { version: '0.0.0' },
}));
import { describe, expect, it, vi } from 'vitest';
describe('DocsDB', () => {
afterEach(() => {
vi.clearAllMocks();
beforeEach(() => {
vi.resetModules();
});
@@ -20,17 +15,16 @@ describe('DocsDB', () => {
{ version: '3.0.0', expected: 3000000 },
{ version: '10.20.30', expected: 10020030 },
].forEach(({ version, expected }) => {
it(`correctly computes version for ${version}`, () => {
it(`correctly computes version for ${version}`, async () => {
vi.doMock('@/../package.json', () => ({
default: { version },
}));
return vi.importActual('../DocsDB').then((module: any) => {
const result = module.getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
});
const module = await import('../DocsDB');
const result = (module as any).getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
});
});
});
+4 -8
View File
@@ -1,16 +1,9 @@
/// <reference types="vitest" />
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
react(),
tsconfigPaths({
root: '.',
projects: ['./tsconfig.json'],
}),
],
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
@@ -22,4 +15,7 @@ export default defineConfig({
define: {
'process.env.NODE_ENV': 'test',
},
resolve: {
tsconfigPaths: true,
},
});
+4 -4
View File
@@ -32,17 +32,17 @@
},
"resolutions": {
"@tiptap/extensions": "3.19.0",
"@types/node": "24.10.13",
"@types/node": "24.12.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "10.0.1",
"eslint": "10.0.3",
"glob": "13.0.6",
"prosemirror-view": "1.41.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "5.9.3",
"wrap-ansi": "9.0.2",
"yjs": "13.6.29"
"wrap-ansi": "10.0.0",
"yjs": "13.6.30"
},
"packageManager": "yarn@1.22.22"
}
@@ -2,7 +2,7 @@ const js = require('@eslint/js');
const nextPlugin = require('@next/eslint-plugin-next');
const tanstackQuery = require('@tanstack/eslint-plugin-query');
const { defineConfig } = require('eslint/config');
const importPlugin = require('eslint-plugin-import');
const importPlugin = require('eslint-plugin-import-x');
const jsxA11y = require('eslint-plugin-jsx-a11y');
const prettier = require('eslint-plugin-prettier');
const react = require('eslint-plugin-react');
@@ -18,22 +18,22 @@
},
"dependencies": {
"@eslint/js": "10.0.1",
"@next/eslint-plugin-next": "16.1.6",
"@next/eslint-plugin-next": "16.1.7",
"@tanstack/eslint-plugin-query": "5.91.4",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"@vitest/eslint-plugin": "1.6.9",
"eslint-config-next": "16.1.6",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"@vitest/eslint-plugin": "1.6.12",
"eslint-config-next": "16.1.7",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-jest": "29.15.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.5.1",
"eslint-plugin-playwright": "2.10.0",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-testing-library": "7.15.4",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-testing-library": "7.16.0",
"prettier": "3.8.1"
},
"packageManager": "yarn@1.22.22"
+2 -2
View File
@@ -19,8 +19,8 @@
"@types/node": "*",
"eslint-plugin-docs": "*",
"eslint-plugin-import": "2.32.0",
"i18next-parser": "9.3.0",
"jest": "30.2.0",
"i18next-parser": "9.4.0",
"jest": "30.3.0",
"ts-jest": "29.4.6",
"typescript": "*",
"yargs": "18.0.0"
@@ -1,6 +1,6 @@
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import request from 'supertest';
import { describe, expect, test, vi } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
vi.mock('../src/env', async (importOriginal) => {
@@ -62,7 +62,11 @@ const expectedBlocks = [
console.error = vi.fn();
describe('Server Tests', () => {
describe('Conversion Testing', () => {
afterEach(() => {
vi.clearAllMocks();
});
test('POST /api/convert with incorrect API key responds with 401', async () => {
const app = initApp();
@@ -170,6 +174,7 @@ describe('Server Tests', () => {
});
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);
@@ -192,6 +197,7 @@ describe('Server Tests', () => {
const decodedBlocks = editor.yDocToBlocks(ydoc, 'document-store');
expect(decodedBlocks).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
test('POST /api/convert BlockNote to HTML', async () => {
@@ -253,6 +259,7 @@ describe('Server Tests', () => {
});
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);
@@ -272,6 +279,7 @@ describe('Server Tests', () => {
);
expect(response.body).toBeInstanceOf(Array);
expect(response.body).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
test('POST /api/convert Markdown to JSON', async () => {
@@ -293,6 +301,7 @@ describe('Server Tests', () => {
});
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')
@@ -304,5 +313,6 @@ describe('Server Tests', () => {
expect(response.status).toBe(400);
expect(response.body).toStrictEqual({ error: 'Invalid content' });
expect(destroySpy).toHaveBeenCalledTimes(1);
});
});
+6 -6
View File
@@ -18,10 +18,10 @@
"dependencies": {
"@blocknote/server-util": "0.47.1",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.38.0",
"@sentry/profiling-node": "10.38.0",
"@sentry/node": "10.43.0",
"@sentry/profiling-node": "10.43.0",
"@tiptap/extensions": "*",
"axios": "1.13.5",
"axios": "1.13.6",
"cors": "2.8.6",
"express": "5.2.1",
"express-ws": "5.0.2",
@@ -36,16 +36,16 @@
"@types/express": "5.0.6",
"@types/express-ws": "3.0.6",
"@types/node": "*",
"@types/supertest": "6.0.3",
"@types/supertest": "7.2.0",
"@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.11",
"nodemon": "3.1.14",
"supertest": "7.2.2",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"typescript": "*",
"vitest": "4.0.18",
"vitest": "4.1.0",
"vitest-mock-extended": "3.1.0",
"ws": "8.19.0"
},
@@ -60,8 +60,12 @@ const readers: InputReader[] = [
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
read: async (data) => {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
try {
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
} finally {
ydoc.destroy();
}
},
},
{
@@ -77,7 +81,14 @@ const writers: OutputWriter[] = [
},
{
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
write: async (blocks) => Y.encodeStateAsUpdate(createYDocument(blocks)),
write: async (blocks) => {
const ydoc = createYDocument(blocks);
try {
return Y.encodeStateAsUpdate(ydoc);
} finally {
ydoc.destroy();
}
},
},
{
supportedContentTypes: [ContentTypes.Markdown, ContentTypes.XMarkdown],
+2350 -2037
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -7,6 +7,9 @@
"@html-to/text-cli": "0.5.4",
"mjml": "4.18.0"
},
"resolutions": {
"minimatch": "^9.0.7"
},
"private": true,
"scripts": {
"build-mjml-to-html": "bash ./bin/mjml-to-html",
+6 -13
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.1:
brace-expansion@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
@@ -562,19 +562,12 @@ mime@^2.4.6:
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
minimatch@9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
minimatch@9.0.1, minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.7:
version "9.0.9"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
dependencies:
brace-expansion "^2.0.1"
minimatch@^9.0.3, minimatch@^9.0.4:
version "9.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
dependencies:
brace-expansion "^2.0.1"
brace-expansion "^2.0.2"
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
version "7.1.2"