mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
11 Commits
improve-in
...
fix/docs-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
525d8c8417 | ||
|
|
c886cbb41d | ||
|
|
98f3ca2763 | ||
|
|
fb92a43755 | ||
|
|
03fd1fe50e | ||
|
|
fc803226ac | ||
|
|
fb725edda3 | ||
|
|
6838b387a2 | ||
|
|
87f570582f | ||
|
|
37f56fcc22 | ||
|
|
19aa3a36bc |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -6,13 +6,19 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 🚸(frontend) hint min char search users #2064
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(backend) improve indexing command
|
||||
- checkpoint recovery
|
||||
- asynchronicity
|
||||
- admin command trigger
|
||||
- 💄(frontend) improve comments highlights #1961
|
||||
- ♿️(frontend) improve BoxButton a11y and native button semantics #2103
|
||||
- ♿️(frontend) improve language picker accessibility #2069
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129
|
||||
|
||||
## [v4.8.3] - 2026-03-23
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -260,7 +260,7 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
.PHONY: demo
|
||||
|
||||
index: ## index all documents to remote search
|
||||
@$(MANAGE) index $(args)
|
||||
@$(MANAGE) index
|
||||
.PHONY: index
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# Index Command
|
||||
|
||||
The `index` management command is used to index documents to the remote search indexer.
|
||||
|
||||
## Usage
|
||||
|
||||
### Make Command
|
||||
|
||||
```bash
|
||||
# Basic usage with defaults
|
||||
make index
|
||||
|
||||
# With custom parameters
|
||||
make index args="--batch-size 100 --lower-time-bound 2024-01-01T00:00:00 --upper-time-bound 2026-01-01T00:00:00"
|
||||
|
||||
```
|
||||
|
||||
### Command line
|
||||
|
||||
```bash
|
||||
python manage.py index \
|
||||
--lower-time-bound "2024-01-01T00:00:00" \
|
||||
--upper-time-bound "2024-01-31T23:59:59" \
|
||||
--batch-size 200 \
|
||||
--async
|
||||
```
|
||||
|
||||
### Django Admin
|
||||
|
||||
The command is available in the Django admin interface:
|
||||
|
||||
1. Go to `/admin/run-indexing/`, you arrive at the "Run Indexing Command" page
|
||||
2. Fill in the form with the desired parameters
|
||||
3. Click **"Run Indexing Command"**
|
||||
|
||||
## Parameters
|
||||
|
||||
### `--batch-size`
|
||||
- **type:** Integer
|
||||
- **default:** `settings.SEARCH_INDEXER_BATCH_SIZE`
|
||||
- **description:** Number of documents to process per batch. Higher values may improve performance but use more memory.
|
||||
|
||||
### `--lower-time-bound`
|
||||
- **optional**: true
|
||||
- **type:** ISO 8601 datetime string
|
||||
- **default:** `None`
|
||||
- **description:** Only documents updated after this date will be indexed.
|
||||
|
||||
### `--upper-time-bound`
|
||||
- **optional**: true
|
||||
- **type:** ISO 8601 datetime string
|
||||
- **default:** `None`
|
||||
- **description:** Only documents updated before this date will be indexed.
|
||||
|
||||
### `--async`
|
||||
- **type:** Boolean flag
|
||||
- **default:** `False`
|
||||
+- **description:** When set, dispatches the indexing job to a Celery worker instead of running it synchronously.
|
||||
|
||||
## Crash Safe Mode
|
||||
|
||||
The command saves the `updated_at` of the last document of each successful batch into the `bulk-indexer-checkpoint` cache variable.
|
||||
If the process crashes, this value can be used as `lower-time-bound` to resume from the last successfully indexed document.
|
||||
@@ -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,54 +1,15 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.core.management import call_command
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect, render
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from treebeard.admin import TreeAdmin
|
||||
|
||||
from core import models
|
||||
from core.forms import RunIndexingForm
|
||||
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
|
||||
|
||||
# Customize the default admin site's get_app_list method
|
||||
_original_get_app_list = admin.site.get_app_list
|
||||
|
||||
|
||||
def custom_get_app_list(_self, request, app_label=None):
|
||||
"""Add custom commands to the app list."""
|
||||
app_list = _original_get_app_list(request, app_label)
|
||||
|
||||
# Add Commands app with Run Indexing command
|
||||
commands_app = {
|
||||
"name": _("Commands"),
|
||||
"app_label": "commands",
|
||||
"app_url": "#",
|
||||
"has_module_perms": True,
|
||||
"models": [
|
||||
{
|
||||
"name": _("Run indexing"),
|
||||
"object_name": "RunIndexing",
|
||||
"admin_url": "/admin/run-indexing/",
|
||||
"view_only": False,
|
||||
"add_url": None,
|
||||
"change_url": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
app_list.append(commands_app)
|
||||
return app_list
|
||||
|
||||
|
||||
# Monkey-patch the admin site
|
||||
admin.site.get_app_list = lambda request, app_label=None: custom_get_app_list(
|
||||
admin.site, request, app_label
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
@@ -266,39 +227,3 @@ class InvitationAdmin(admin.ModelAdmin):
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.issuer = request.user
|
||||
obj.save()
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def run_indexing_view(request: HttpRequest):
|
||||
"""Custom admin view for running indexing commands."""
|
||||
if request.method == "POST":
|
||||
form = RunIndexingForm(request.POST)
|
||||
if form.is_valid():
|
||||
lower_time_bound = form.cleaned_data.get("lower_time_bound")
|
||||
upper_time_bound = form.cleaned_data.get("upper_time_bound")
|
||||
call_command(
|
||||
"index",
|
||||
batch_size=form.cleaned_data["batch_size"],
|
||||
lower_time_bound=lower_time_bound.isoformat()
|
||||
if lower_time_bound
|
||||
else None,
|
||||
upper_time_bound=upper_time_bound.isoformat()
|
||||
if upper_time_bound
|
||||
else None,
|
||||
async_mode=True,
|
||||
)
|
||||
messages.success(request, _("Indexing triggered!"))
|
||||
return redirect("run_indexing")
|
||||
messages.error(request, _("Please correct the errors below."))
|
||||
else:
|
||||
form = RunIndexingForm()
|
||||
|
||||
return render(
|
||||
request=request,
|
||||
template_name="runindexing.html",
|
||||
context={
|
||||
**admin.site.each_context(request),
|
||||
"title": "Run Indexing Command",
|
||||
"form": form,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
import factory.fuzzy
|
||||
from factory import post_generation
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
@@ -160,20 +159,6 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
document=self, user=item, defaults={"is_masked": True}
|
||||
)
|
||||
|
||||
@post_generation
|
||||
def updated_at(self, create, extracted, **kwargs):
|
||||
"""
|
||||
the BaseModel.updated_at has auto_now=True.
|
||||
This prevents setting a specific updated_at value with the factory.
|
||||
|
||||
This post_generation method bypasses this behavior.
|
||||
"""
|
||||
if not create or not extracted:
|
||||
return
|
||||
|
||||
self.__class__.objects.filter(pk=self.pk).update(updated_at=extracted)
|
||||
self.refresh_from_db()
|
||||
|
||||
|
||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document user accesses for testing."""
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Forms for the core app."""
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class RunIndexingForm(forms.Form):
|
||||
"""
|
||||
Form for running the indexing process.
|
||||
"""
|
||||
|
||||
batch_size = forms.IntegerField(
|
||||
min_value=1,
|
||||
initial=settings.SEARCH_INDEXER_BATCH_SIZE,
|
||||
)
|
||||
lower_time_bound = forms.DateTimeField(
|
||||
required=False, widget=forms.TextInput(attrs={"type": "datetime-local"})
|
||||
)
|
||||
upper_time_bound = forms.DateTimeField(
|
||||
required=False, widget=forms.TextInput(attrs={"type": "datetime-local"})
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
"""Override clean to validate time bounds."""
|
||||
cleaned_data = super().clean()
|
||||
self.check_time_bounds()
|
||||
return cleaned_data
|
||||
|
||||
def check_time_bounds(self):
|
||||
"""Validate that lower_time_bound is before upper_time_bound."""
|
||||
lower_time_bound = self.cleaned_data.get("lower_time_bound")
|
||||
upper_time_bound = self.cleaned_data.get("upper_time_bound")
|
||||
if (
|
||||
lower_time_bound
|
||||
and upper_time_bound
|
||||
and lower_time_bound > upper_time_bound
|
||||
):
|
||||
self.add_error(
|
||||
"upper_time_bound",
|
||||
_("Upper time bound must be after lower time bound."),
|
||||
)
|
||||
@@ -4,16 +4,12 @@ Handle search setup that needs to be done at bootstrap time.
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core import models
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
from core.tasks.search import batch_document_indexer_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("docs.search.bootstrap_search")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -28,32 +24,9 @@ class Command(BaseCommand):
|
||||
action="store",
|
||||
dest="batch_size",
|
||||
type=int,
|
||||
default=settings.SEARCH_INDEXER_BATCH_SIZE,
|
||||
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(
|
||||
"--async",
|
||||
action="store_true",
|
||||
dest="async_mode",
|
||||
default=False,
|
||||
help="Whether to execute indexing asynchronously in a Celery task (default: False)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Launch and log search index generation."""
|
||||
@@ -62,38 +35,18 @@ class Command(BaseCommand):
|
||||
if not indexer:
|
||||
raise CommandError("The indexer is not enabled or properly configured.")
|
||||
|
||||
if options["async_mode"]:
|
||||
try:
|
||||
batch_document_indexer_task.apply_async(
|
||||
kwargs={
|
||||
"lower_time_bound": options["lower_time_bound"],
|
||||
"upper_time_bound": options["upper_time_bound"],
|
||||
"batch_size": options["batch_size"],
|
||||
"crash_safe_mode": True,
|
||||
},
|
||||
)
|
||||
except Exception as err:
|
||||
raise CommandError("Unable to dispatch indexing task") from err
|
||||
logger.info("Document indexing task sent to worker")
|
||||
else:
|
||||
logger.info("Starting to regenerate Find index...")
|
||||
start = time.perf_counter()
|
||||
logger.info("Starting to regenerate Find index...")
|
||||
start = time.perf_counter()
|
||||
batch_size = options["batch_size"]
|
||||
|
||||
try:
|
||||
count = indexer.index(
|
||||
queryset=models.Document.objects.filter_updated_at(
|
||||
lower_time_bound=options["lower_time_bound"],
|
||||
upper_time_bound=options["upper_time_bound"],
|
||||
),
|
||||
batch_size=options["batch_size"],
|
||||
crash_safe_mode=True,
|
||||
)
|
||||
except Exception as err:
|
||||
raise CommandError("Unable to regenerate index") from err
|
||||
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(
|
||||
"Search index regenerated from %d document(s) in %.2f seconds.",
|
||||
count,
|
||||
duration,
|
||||
)
|
||||
duration = time.perf_counter() - start
|
||||
logger.info(
|
||||
"Search index regenerated from %d document(s) in %.2f seconds.",
|
||||
count,
|
||||
duration,
|
||||
)
|
||||
|
||||
@@ -859,32 +859,6 @@ class DocumentQuerySet(MP_NodeQuerySet):
|
||||
user_roles=models.Value([], output_field=output_field),
|
||||
)
|
||||
|
||||
def filter_updated_at(self, lower_time_bound=None, upper_time_bound=None):
|
||||
"""
|
||||
Filter documents by update_at.
|
||||
|
||||
Args:
|
||||
lower_time_bound (datetime, optional):
|
||||
Keep documents updated after this timestamp.
|
||||
upper_time_bound (datetime, optional):
|
||||
Keep documents updated before this timestamp.
|
||||
|
||||
Returns:
|
||||
QuerySet: Filtered queryset ready for indexation.
|
||||
"""
|
||||
conditions = models.Q()
|
||||
if lower_time_bound and upper_time_bound:
|
||||
conditions = models.Q(
|
||||
updated_at__gte=lower_time_bound,
|
||||
updated_at__lte=upper_time_bound,
|
||||
)
|
||||
elif lower_time_bound:
|
||||
conditions = models.Q(updated_at__gte=lower_time_bound)
|
||||
elif upper_time_bound:
|
||||
conditions = models.Q(updated_at__lte=upper_time_bound)
|
||||
|
||||
return self.filter(conditions)
|
||||
|
||||
|
||||
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
|
||||
"""
|
||||
@@ -1485,16 +1459,13 @@ class Document(MP_Node, BaseModel):
|
||||
.first()
|
||||
)
|
||||
self.ancestors_deleted_at = ancestors_deleted_at
|
||||
self.save(update_fields=["deleted_at", "ancestors_deleted_at", "updated_at"])
|
||||
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
|
||||
self.invalidate_nb_accesses_cache()
|
||||
|
||||
self.get_descendants().exclude(
|
||||
models.Q(deleted_at__isnull=False)
|
||||
| models.Q(ancestors_deleted_at__lt=current_deleted_at)
|
||||
).update(
|
||||
ancestors_deleted_at=self.ancestors_deleted_at,
|
||||
updated_at=self.updated_at,
|
||||
)
|
||||
).update(ancestors_deleted_at=self.ancestors_deleted_at)
|
||||
|
||||
if self.depth > 1:
|
||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Document search index management utilities and indexers"""
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
@@ -126,44 +125,44 @@ class BaseDocumentIndexer(ABC):
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
|
||||
|
||||
def index(self, queryset, 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.
|
||||
|
||||
Args:
|
||||
queryset: Document queryset
|
||||
queryset (optional): Document queryset
|
||||
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
|
||||
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:
|
||||
logger.info(
|
||||
"Indexing checkpoint: %s.",
|
||||
serialized_batch[-1]["updated_at"],
|
||||
)
|
||||
if serialized_batch:
|
||||
self.push(serialized_batch)
|
||||
count += len(serialized_batch)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -19,12 +20,7 @@ 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.
|
||||
"""
|
||||
"""Celery Task : Sends indexation query for a document."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
@@ -34,17 +30,8 @@ def document_indexer_task(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).
|
||||
Enable the task throttle flag for a delay.
|
||||
Uses redis locks if available to ensure atomic changes
|
||||
"""
|
||||
key = "document-batch-indexer-throttle"
|
||||
|
||||
@@ -54,65 +41,44 @@ def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
|
||||
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.
|
||||
# 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(lower_time_bound=None, upper_time_bound=None, **kwargs):
|
||||
"""
|
||||
Celery Task: Batch indexes all documents modified since timestamp.
|
||||
|
||||
Args:
|
||||
lower_time_bound (datetime, optional):
|
||||
indexes documents updated or deleted after this timestamp.
|
||||
upper_time_bound (datetime, optional):
|
||||
indexes documents updated or deleted before this timestamp.
|
||||
"""
|
||||
def batch_document_indexer_task(timestamp):
|
||||
"""Celery Task : Sends indexation query for a batch of documents."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if not indexer:
|
||||
logger.warning("Indexing task triggered but no indexer configured: skipping")
|
||||
return
|
||||
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=models.Document.objects.filter_updated_at(
|
||||
lower_time_bound=lower_time_bound, upper_time_bound=upper_time_bound
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
logger.info("Indexed %d documents", count)
|
||||
count = indexer.index(queryset)
|
||||
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
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
Args:
|
||||
document (Document): the document instance that triggered the indexation.
|
||||
document (Document): The document instance.
|
||||
"""
|
||||
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
|
||||
|
||||
# DO NOT create a task if indexation is disabled
|
||||
# DO NOT create a task if indexation if 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.
|
||||
# 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",
|
||||
@@ -121,7 +87,7 @@ def trigger_batch_document_indexer(document):
|
||||
)
|
||||
|
||||
batch_document_indexer_task.apply_async(
|
||||
kwargs={"lower_time_bound": document.updated_at}, countdown=countdown
|
||||
args=[document.updated_at], countdown=countdown
|
||||
)
|
||||
else:
|
||||
logger.info("Skip task for batch document %s indexation", document.pk)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form method="POST" >
|
||||
{% csrf_token %}
|
||||
|
||||
<hr style="margin-bottom: 10px;">
|
||||
|
||||
<p>
|
||||
{% translate "This command triggers the indexing of all documents within the specified time bound." %}
|
||||
</p>
|
||||
|
||||
<hr style="margin-bottom: 20px;">
|
||||
|
||||
{{ form.as_p }}
|
||||
|
||||
<input type="submit" value="{% translate 'Run Indexing' %}" style="margin-top: 20px;">
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -2,25 +2,21 @@
|
||||
Unit test for `index` command.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.factories import DocumentFactory
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_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()
|
||||
@@ -43,152 +39,18 @@ def test_index_without_bound_success():
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# called once but with a batch of docs
|
||||
mock_push.assert_called_once()
|
||||
# called once but with a batch of docs
|
||||
mock_push.assert_called_once()
|
||||
|
||||
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(no_title_doc, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_with_both_bounds_success():
|
||||
"""Test the command `index` for all documents within time bound."""
|
||||
cache.clear()
|
||||
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
|
||||
upper_time_bound = lower_time_bound + timedelta(days=30)
|
||||
|
||||
document_too_early = DocumentFactory(
|
||||
updated_at=lower_time_bound - timedelta(days=10)
|
||||
)
|
||||
document_in_window_1 = DocumentFactory(
|
||||
updated_at=lower_time_bound + timedelta(days=5)
|
||||
)
|
||||
document_in_window_2 = DocumentFactory(
|
||||
updated_at=lower_time_bound + timedelta(days=15)
|
||||
)
|
||||
document_too_late = DocumentFactory(
|
||||
updated_at=upper_time_bound + timedelta(days=10)
|
||||
)
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command(
|
||||
"index",
|
||||
lower_time_bound=lower_time_bound.isoformat(),
|
||||
upper_time_bound=upper_time_bound.isoformat(),
|
||||
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"),
|
||||
)
|
||||
pushed_document_ids = [
|
||||
document["id"]
|
||||
for call_arg_list in mock_push.call_args_list
|
||||
for document in call_arg_list.args[0]
|
||||
]
|
||||
|
||||
# Only documents in window should be indexed
|
||||
assert str(document_too_early.id) not in pushed_document_ids
|
||||
assert str(document_in_window_1.id) in pushed_document_ids
|
||||
assert str(document_in_window_2.id) in pushed_document_ids
|
||||
assert str(document_too_late.id) not in pushed_document_ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_with_crash_recovery(caplog_with_propagate):
|
||||
"""Test resuming indexing from checkpoint after a crash."""
|
||||
cache.clear()
|
||||
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
|
||||
upper_time_bound = lower_time_bound + timedelta(days=60)
|
||||
|
||||
batch_size = 2
|
||||
documents = [
|
||||
# batch 0
|
||||
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=5)),
|
||||
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=10)),
|
||||
# batch 1
|
||||
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=20)),
|
||||
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=25)),
|
||||
# batch 2 - will crash here
|
||||
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=30)),
|
||||
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=35)),
|
||||
# batch 3
|
||||
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=40)),
|
||||
factories.DocumentFactory(updated_at=lower_time_bound + timedelta(days=45)),
|
||||
]
|
||||
|
||||
def push_with_failure_on_batch_2(data):
|
||||
# Crash when encounters document at index 4 (batch 2 with batch_size=2)
|
||||
if str(documents[4].id) in [document["id"] for document in data]:
|
||||
raise ConnectionError("Simulated indexing error")
|
||||
|
||||
# First run: simulate crash on batch 3
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
mock_push.side_effect = push_with_failure_on_batch_2
|
||||
with pytest.raises(CommandError):
|
||||
with caplog_with_propagate.at_level(logging.INFO):
|
||||
call_command(
|
||||
"index",
|
||||
batch_size=batch_size,
|
||||
lower_time_bound=lower_time_bound.isoformat(),
|
||||
upper_time_bound=upper_time_bound.isoformat(),
|
||||
)
|
||||
pushed_document_ids = [
|
||||
document["id"]
|
||||
for call_arg_list in mock_push.call_args_list
|
||||
for document in call_arg_list.args[0]
|
||||
]
|
||||
|
||||
# the updated at of the last document of each batch are logged as checkpoint
|
||||
# -> documents[3].updated_at is the most advanced checkpoint
|
||||
for i in [1, 3]:
|
||||
assert any(
|
||||
f"Indexing checkpoint: {documents[i].updated_at.isoformat()}." in message
|
||||
for message in caplog_with_propagate.messages
|
||||
)
|
||||
for i in [0, 2, 4, 5, 6]:
|
||||
assert not any(
|
||||
f"Indexing checkpoint: {documents[i].updated_at.isoformat()}" in message
|
||||
for message in caplog_with_propagate.messages
|
||||
)
|
||||
# first 2 batches should be indexed successfully
|
||||
for i in range(0, 4):
|
||||
assert str(documents[i].id) in pushed_document_ids
|
||||
# next batch should have been attempted but failed
|
||||
for i in range(4, 6):
|
||||
assert str(documents[i].id) in pushed_document_ids
|
||||
# last batches indexing should not have been attempted
|
||||
for i in range(6, 8):
|
||||
assert str(documents[i].id) not in pushed_document_ids
|
||||
|
||||
# Second run: resume from checkpoint
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command(
|
||||
"index",
|
||||
batch_size=batch_size,
|
||||
lower_time_bound=documents[3].updated_at,
|
||||
upper_time_bound=upper_time_bound.isoformat(),
|
||||
)
|
||||
pushed_document_ids = [
|
||||
document["id"]
|
||||
for call_arg_list in mock_push.call_args_list
|
||||
for document in call_arg_list.args[0]
|
||||
]
|
||||
|
||||
# first 2 batches should NOT be re-indexed
|
||||
# except the last document of the last batch which is on the checkpoint boundary
|
||||
# -> doc 0, 1 and 2
|
||||
for i in range(0, 3):
|
||||
assert str(documents[i].id) not in pushed_document_ids
|
||||
# next batches should be indexed including the document at the checkpoint boundary
|
||||
# which has already been indexed and is re-indexed
|
||||
# -> doc 3 to the end
|
||||
for i in range(3, 8):
|
||||
assert str(documents[i].id) in pushed_document_ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -201,57 +63,3 @@ def test_index_improperly_configured(indexer_settings):
|
||||
call_command("index")
|
||||
|
||||
assert str(err.value) == "The indexer is not enabled or properly configured."
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_with_async_flag(settings):
|
||||
"""Test the command `index` with --async=True runs task asynchronously."""
|
||||
cache.clear()
|
||||
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
|
||||
|
||||
with mock.patch(
|
||||
"core.management.commands.index.batch_document_indexer_task"
|
||||
) as mock_task:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command(
|
||||
"index", async_mode=True, lower_time_bound=lower_time_bound.isoformat()
|
||||
)
|
||||
# push not be called synchronously
|
||||
mock_push.assert_not_called()
|
||||
# task called asynchronously
|
||||
mock_task.apply_async.assert_called_once_with(
|
||||
kwargs={
|
||||
"lower_time_bound": lower_time_bound.isoformat(),
|
||||
"upper_time_bound": None,
|
||||
"batch_size": settings.SEARCH_INDEXER_BATCH_SIZE,
|
||||
"crash_safe_mode": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_without_async_flag():
|
||||
"""Test the command `index` with --async=False runs synchronously."""
|
||||
cache.clear()
|
||||
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
|
||||
|
||||
document = DocumentFactory(updated_at=lower_time_bound + timedelta(days=10))
|
||||
|
||||
with mock.patch(
|
||||
"core.management.commands.index.batch_document_indexer_task"
|
||||
) as mock_task:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command(
|
||||
"index", async_mode=False, lower_time_bound=lower_time_bound.isoformat()
|
||||
)
|
||||
# push is called synchronously to index the document
|
||||
pushed_document_ids = [
|
||||
document["id"]
|
||||
for call_arg_list in mock_push.call_args_list
|
||||
for document in call_arg_list.args[0]
|
||||
]
|
||||
assert str(document.id) in pushed_document_ids
|
||||
# async task not called
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Fixtures for tests in the impress core application"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
@@ -23,30 +22,6 @@ def clear_cache():
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def caplog_with_propagate(settings, caplog):
|
||||
"""
|
||||
propagate=False on settings.LOGGING loggers.
|
||||
This prevents caplog from capturing logs.
|
||||
|
||||
This fixture enables propagation on all configured loggers.
|
||||
"""
|
||||
# Save original propagate state
|
||||
original_propagate = {}
|
||||
|
||||
for logger_name in settings.LOGGING.get("loggers", {}):
|
||||
logger = logging.getLogger(logger_name)
|
||||
original_propagate[logger_name] = logger.propagate
|
||||
logger.propagate = True
|
||||
|
||||
try:
|
||||
yield caplog
|
||||
finally:
|
||||
# Restore original propagate states
|
||||
for logger_name, original_value in original_propagate.items():
|
||||
logging.getLogger(logger_name).propagate = original_value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_teams():
|
||||
"""Mock for the "teams" property on the User model."""
|
||||
|
||||
@@ -96,9 +96,8 @@ def test_api_documents_delete_authenticated_owner_of_ancestor(depth):
|
||||
)
|
||||
assert models.Document.objects.count() == depth
|
||||
|
||||
document_to_delete = documents[-1]
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document_to_delete.id}/",
|
||||
f"/api/v1.0/documents/{documents[-1].id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
@@ -106,11 +105,7 @@ def test_api_documents_delete_authenticated_owner_of_ancestor(depth):
|
||||
# Make sure it is only a soft delete
|
||||
assert models.Document.objects.count() == depth
|
||||
assert models.Document.objects.filter(deleted_at__isnull=True).count() == depth - 1
|
||||
deleted_documents = models.Document.objects.filter(deleted_at__isnull=False)
|
||||
assert len(deleted_documents) == 1
|
||||
deleted_document = deleted_documents[0]
|
||||
# updated_at is updated by the soft delete
|
||||
assert deleted_document.updated_at > document_to_delete.updated_at
|
||||
assert models.Document.objects.filter(deleted_at__isnull=False).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
|
||||
@@ -91,15 +91,11 @@ def test_api_documents_restore_authenticated_owner_ancestor_deleted():
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
document_deleted_at = document.deleted_at
|
||||
document_updated_at = document.updated_at
|
||||
|
||||
assert document_deleted_at is not None
|
||||
|
||||
grand_parent.soft_delete()
|
||||
grand_parent_deleted_at = grand_parent.deleted_at
|
||||
|
||||
assert grand_parent_deleted_at is not None
|
||||
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
|
||||
@@ -109,8 +105,6 @@ def test_api_documents_restore_authenticated_owner_ancestor_deleted():
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is None
|
||||
# document is updated by restore
|
||||
assert document.updated_at > document_updated_at
|
||||
# document is still marked as deleted
|
||||
assert document.ancestors_deleted_at == grand_parent_deleted_at
|
||||
assert grand_parent_deleted_at > document_deleted_at
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Tests for run_indexing_view admin endpoint."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"is_authenticated,is_staff,should_call_command",
|
||||
[
|
||||
(False, False, False),
|
||||
(True, False, False),
|
||||
(True, True, True),
|
||||
],
|
||||
)
|
||||
def test_run_indexing_view_post_authentication(
|
||||
client,
|
||||
is_authenticated,
|
||||
is_staff,
|
||||
should_call_command,
|
||||
):
|
||||
"""Test that POST to run_indexing_view requires staff authentication."""
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory(is_staff=is_staff)
|
||||
client.force_login(user)
|
||||
|
||||
batch_size = 100
|
||||
with patch("core.admin.call_command") as mock_call_command:
|
||||
mock_call_command.return_value = HttpResponse("Mocked render")
|
||||
response = client.post("/admin/run-indexing/", {"batch_size": batch_size})
|
||||
|
||||
# redirects in all cases
|
||||
assert response.status_code == 302
|
||||
|
||||
if should_call_command:
|
||||
assert "/admin/run-indexing/" == response.url
|
||||
mock_call_command.assert_called_once()
|
||||
assert mock_call_command.call_args.kwargs == {
|
||||
"batch_size": batch_size,
|
||||
"lower_time_bound": None,
|
||||
"upper_time_bound": None,
|
||||
"async_mode": True,
|
||||
}
|
||||
|
||||
else:
|
||||
assert "/admin/login/" in response.url
|
||||
mock_call_command.assert_not_called()
|
||||
@@ -5,8 +5,6 @@ Unit tests for the Document model
|
||||
|
||||
import random
|
||||
import smtplib
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timezone as base_timezone
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
@@ -21,7 +19,6 @@ from django.utils import timezone
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.factories import DocumentFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -90,8 +87,7 @@ def test_models_documents_tree_alphabet():
|
||||
|
||||
@pytest.mark.parametrize("depth", range(5))
|
||||
def test_models_documents_soft_delete(depth):
|
||||
"""
|
||||
Trying to delete a document that is already deleted or is a descendant of
|
||||
"""Trying to delete a document that is already deleted or is a descendant of
|
||||
a deleted document should raise an error.
|
||||
"""
|
||||
documents = []
|
||||
@@ -103,8 +99,6 @@ def test_models_documents_soft_delete(depth):
|
||||
)
|
||||
assert models.Document.objects.count() == depth + 1
|
||||
|
||||
document_pk_to_updated_at = {d.pk: d.updated_at for d in documents}
|
||||
|
||||
# Delete any one of the documents...
|
||||
deleted_document = random.choice(documents)
|
||||
deleted_document.soft_delete()
|
||||
@@ -112,26 +106,19 @@ def test_models_documents_soft_delete(depth):
|
||||
with pytest.raises(RuntimeError):
|
||||
documents[-1].soft_delete()
|
||||
|
||||
deleted_document.refresh_from_db()
|
||||
assert deleted_document.deleted_at is not None
|
||||
assert deleted_document.ancestors_deleted_at == deleted_document.deleted_at
|
||||
# updated_at is updated on the deleted document
|
||||
assert deleted_document.updated_at > document_pk_to_updated_at[deleted_document.pk]
|
||||
|
||||
descendants = deleted_document.get_descendants()
|
||||
for child in descendants:
|
||||
assert child.deleted_at is None
|
||||
assert child.ancestors_deleted_at is not None
|
||||
assert child.ancestors_deleted_at == deleted_document.deleted_at
|
||||
# updated_at is updated on descendants
|
||||
assert child.updated_at > document_pk_to_updated_at[child.pk]
|
||||
|
||||
ancestors = deleted_document.get_ancestors()
|
||||
for parent in ancestors:
|
||||
assert parent.deleted_at is None
|
||||
assert parent.ancestors_deleted_at is None
|
||||
# updated_at is not affected on parents
|
||||
assert parent.updated_at == document_pk_to_updated_at[parent.pk]
|
||||
|
||||
assert len(ancestors) + len(descendants) == depth
|
||||
|
||||
@@ -1432,20 +1419,16 @@ def test_models_documents_restore_tempering_with_instance():
|
||||
def test_models_documents_restore(django_assert_num_queries):
|
||||
"""The restore method should restore a soft-deleted document."""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
original_updated_after_delete = document.updated_at
|
||||
|
||||
with django_assert_num_queries(10):
|
||||
document.restore()
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is None
|
||||
assert document.ancestors_deleted_at is None
|
||||
# updated_at is updated by restore
|
||||
assert original_updated_after_delete < document.updated_at
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
|
||||
def test_models_documents_restore_complex(django_assert_num_queries):
|
||||
@@ -1462,7 +1445,6 @@ def test_models_documents_restore_complex(django_assert_num_queries):
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
@@ -1472,18 +1454,13 @@ def test_models_documents_restore_complex(django_assert_num_queries):
|
||||
grand_parent.soft_delete()
|
||||
grand_parent.refresh_from_db()
|
||||
parent.refresh_from_db()
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
grand_parent_updated_at = grand_parent.updated_at
|
||||
document_updated_at = document.updated_at
|
||||
child1_updated_at = child2.updated_at
|
||||
child2_updated_at = child2.updated_at
|
||||
|
||||
assert grand_parent.deleted_at is not None
|
||||
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
|
||||
assert parent.ancestors_deleted_at == grand_parent.deleted_at
|
||||
# item, child1 and child2 should not be affected
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
@@ -1492,23 +1469,15 @@ def test_models_documents_restore_complex(django_assert_num_queries):
|
||||
# Restore the item
|
||||
with django_assert_num_queries(14):
|
||||
document.restore()
|
||||
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
grand_parent.refresh_from_db()
|
||||
|
||||
assert document.deleted_at is None
|
||||
assert document.ancestors_deleted_at == grand_parent.deleted_at
|
||||
# child 1 and child 2 should now have the same ancestors_deleted_at as the grand parent
|
||||
assert child1.ancestors_deleted_at == grand_parent.deleted_at
|
||||
assert child2.ancestors_deleted_at == grand_parent.deleted_at
|
||||
# updated_at is updated for document and children after restore
|
||||
assert document.updated_at > document_updated_at
|
||||
assert child1.updated_at > child1_updated_at
|
||||
assert child2.updated_at > child2_updated_at
|
||||
# grand_parent updated_at is not affected
|
||||
assert grand_parent.updated_at == grand_parent_updated_at
|
||||
|
||||
|
||||
def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
@@ -1516,37 +1485,31 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
grand_parent = factories.DocumentFactory()
|
||||
parent = factories.DocumentFactory(parent=grand_parent)
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
|
||||
child1 = factories.DocumentFactory(parent=document)
|
||||
child2 = factories.DocumentFactory(parent=document)
|
||||
|
||||
# Soft delete first the document
|
||||
document.soft_delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
# Soft delete the grand_parent
|
||||
# Soft delete the grand parent
|
||||
grand_parent.soft_delete()
|
||||
|
||||
grand_parent.refresh_from_db()
|
||||
parent.refresh_from_db()
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
original_parent_updated_at = parent.updated_at
|
||||
original_child1_updated_at = child1.updated_at
|
||||
original_child2_updated_at = child2.updated_at
|
||||
|
||||
assert grand_parent.deleted_at is not None
|
||||
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
|
||||
assert parent.ancestors_deleted_at == grand_parent.deleted_at
|
||||
# item, child1 and child2 should not be affected
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
@@ -1562,20 +1525,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
|
||||
assert grand_parent.deleted_at is None
|
||||
assert grand_parent.ancestors_deleted_at is None
|
||||
assert parent.deleted_at is None
|
||||
assert parent.ancestors_deleted_at is None
|
||||
# parent should have updated_at updated (descendant of restored grand_parent)
|
||||
assert parent.updated_at > original_parent_updated_at
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
# children are not restored and then there updated_at should not be affected
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.updated_at == original_child1_updated_at
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
assert child2.updated_at == original_child2_updated_at
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1734,82 +1691,3 @@ def test_models_documents_compute_ancestors_links_paths_mapping_structure(
|
||||
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_models_documents_manager_time_filter_no_filters():
|
||||
"""Test time_filter with no filters returns all documents."""
|
||||
factories.DocumentFactory.create_batch(3)
|
||||
|
||||
queryset = models.Document.objects.filter_updated_at()
|
||||
|
||||
assert queryset.count() == 3
|
||||
|
||||
|
||||
def test_models_documents_manager_time_filter_oldest_updated_at():
|
||||
"""
|
||||
Test filtering by oldest_updated_at includes documents updated after or at
|
||||
lower_time_bound.
|
||||
"""
|
||||
lower_time_bound = datetime(2024, 2, 1, tzinfo=base_timezone.utc)
|
||||
|
||||
DocumentFactory(updated_at=lower_time_bound - timedelta(days=30))
|
||||
document_at_boundary = DocumentFactory(updated_at=lower_time_bound)
|
||||
document_recent = DocumentFactory(updated_at=lower_time_bound + timedelta(days=15))
|
||||
|
||||
queryset = models.Document.objects.filter_updated_at(
|
||||
lower_time_bound=lower_time_bound
|
||||
)
|
||||
|
||||
assert queryset.count() == 2
|
||||
assert sorted(queryset.values_list("pk", flat=True)) == sorted(
|
||||
[document_at_boundary.pk, document_recent.pk]
|
||||
)
|
||||
|
||||
|
||||
def test_models_documents_manager_time_filter_newest_updated_at():
|
||||
"""Test filtering by newest_updated_at includes documents updated before timestamp."""
|
||||
upper_time_bound = datetime(2024, 2, 1, tzinfo=base_timezone.utc)
|
||||
|
||||
document_old = DocumentFactory(updated_at=upper_time_bound - timedelta(days=30))
|
||||
document_at_boundary = DocumentFactory(updated_at=upper_time_bound)
|
||||
document_too_recent = DocumentFactory(
|
||||
updated_at=upper_time_bound + timedelta(days=15)
|
||||
)
|
||||
|
||||
queryset = models.Document.objects.filter_updated_at(
|
||||
upper_time_bound=upper_time_bound
|
||||
)
|
||||
|
||||
assert queryset.count() == 2
|
||||
assert document_old in queryset
|
||||
assert document_at_boundary in queryset
|
||||
assert document_too_recent not in queryset
|
||||
|
||||
|
||||
def test_models_documents_manager_time_filter_both_bounds():
|
||||
"""Test filtering with both oldest and newest bounds."""
|
||||
lower_time_bound = datetime(2024, 2, 1, tzinfo=base_timezone.utc)
|
||||
upper_time_bound = lower_time_bound + timedelta(days=30)
|
||||
|
||||
document_too_early = DocumentFactory(
|
||||
updated_at=lower_time_bound - timedelta(days=10)
|
||||
)
|
||||
document_in_window = DocumentFactory(
|
||||
updated_at=lower_time_bound + timedelta(days=5)
|
||||
)
|
||||
other_document_in_window = DocumentFactory(
|
||||
updated_at=lower_time_bound + timedelta(days=15)
|
||||
)
|
||||
document_too_late = DocumentFactory(
|
||||
updated_at=upper_time_bound + timedelta(days=10)
|
||||
)
|
||||
|
||||
queryset = models.Document.objects.filter_updated_at(
|
||||
lower_time_bound=lower_time_bound, upper_time_bound=upper_time_bound
|
||||
)
|
||||
|
||||
assert queryset.count() == 2
|
||||
assert document_too_early not in queryset
|
||||
assert document_in_window in queryset
|
||||
assert other_document_in_window in queryset
|
||||
assert document_too_late not in queryset
|
||||
|
||||
@@ -252,7 +252,7 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
FindDocumentIndexer().index(models.Document.objects.all())
|
||||
FindDocumentIndexer().index()
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@@ -272,7 +272,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert FindDocumentIndexer().index(models.Document.objects.all()) == 5
|
||||
assert FindDocumentIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -310,7 +310,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert FindDocumentIndexer().index(models.Document.objects.all(), batch_size=2) == 5
|
||||
assert FindDocumentIndexer().index(batch_size=2) == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -345,7 +345,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert FindDocumentIndexer().index(models.Document.objects.all()) == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
@@ -373,7 +373,7 @@ def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings
|
||||
# Only empty docs
|
||||
factories.DocumentFactory.create_batch(5, content="", title="")
|
||||
|
||||
assert FindDocumentIndexer().index(models.Document.objects.all()) == 1
|
||||
assert FindDocumentIndexer().index() == 1
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
results = [doc["id"] for doc in mock_push.call_args[0][0]]
|
||||
@@ -391,7 +391,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert FindDocumentIndexer().index(models.Document.objects.all()) == 4
|
||||
assert FindDocumentIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
@@ -411,7 +411,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert FindDocumentIndexer().index(models.Document.objects.all()) == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -432,7 +432,7 @@ def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert FindDocumentIndexer().index(models.Document.objects.all()) == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
|
||||
@@ -12,10 +12,7 @@ from drf_spectacular.views import (
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
|
||||
from core.admin import run_indexing_view
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/run-indexing/", run_indexing_view, name="run_indexing"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("core.urls")),
|
||||
]
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
/// <reference types="vitest" />
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths({
|
||||
root: '.',
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
],
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
@@ -22,4 +15,7 @@ export default defineConfig({
|
||||
define: {
|
||||
'process.env.NODE_ENV': 'test',
|
||||
},
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,17 +32,17 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@tiptap/extensions": "3.19.0",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/node": "24.12.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"eslint": "10.0.1",
|
||||
"eslint": "10.0.3",
|
||||
"glob": "13.0.6",
|
||||
"prosemirror-view": "1.41.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"typescript": "5.9.3",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"yjs": "13.6.29"
|
||||
"wrap-ansi": "10.0.0",
|
||||
"yjs": "13.6.30"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const js = require('@eslint/js');
|
||||
const nextPlugin = require('@next/eslint-plugin-next');
|
||||
const tanstackQuery = require('@tanstack/eslint-plugin-query');
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const importPlugin = require('eslint-plugin-import');
|
||||
const importPlugin = require('eslint-plugin-import-x');
|
||||
const jsxA11y = require('eslint-plugin-jsx-a11y');
|
||||
const prettier = require('eslint-plugin-prettier');
|
||||
const react = require('eslint-plugin-react');
|
||||
|
||||
@@ -18,22 +18,22 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"@next/eslint-plugin-next": "16.1.7",
|
||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
||||
"@typescript-eslint/parser": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"@vitest/eslint-plugin": "1.6.9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
"@typescript-eslint/parser": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1",
|
||||
"@vitest/eslint-plugin": "1.6.12",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-jest": "29.15.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-playwright": "2.5.1",
|
||||
"eslint-plugin-playwright": "2.10.0",
|
||||
"eslint-plugin-prettier": "5.5.5",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-testing-library": "7.15.4",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-testing-library": "7.16.0",
|
||||
"prettier": "3.8.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"@types/node": "*",
|
||||
"eslint-plugin-docs": "*",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"i18next-parser": "9.3.0",
|
||||
"jest": "30.2.0",
|
||||
"i18next-parser": "9.4.0",
|
||||
"jest": "30.3.0",
|
||||
"ts-jest": "29.4.6",
|
||||
"typescript": "*",
|
||||
"yargs": "18.0.0"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,9 @@
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
"mjml": "4.18.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"minimatch": "^9.0.7"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-mjml-to-html": "bash ./bin/mjml-to-html",
|
||||
|
||||
@@ -110,7 +110,7 @@ boolbase@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
|
||||
|
||||
brace-expansion@^2.0.1:
|
||||
brace-expansion@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
|
||||
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
|
||||
@@ -562,19 +562,12 @@ mime@^2.4.6:
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
|
||||
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
|
||||
|
||||
minimatch@9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
|
||||
integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
|
||||
minimatch@9.0.1, minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.7:
|
||||
version "9.0.9"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
|
||||
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimatch@^9.0.3, minimatch@^9.0.4:
|
||||
version "9.0.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
|
||||
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
brace-expansion "^2.0.2"
|
||||
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
|
||||
version "7.1.2"
|
||||
|
||||
Reference in New Issue
Block a user