Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 464c4ee542 | |||
| 4dd7976803 | |||
| 923abe9110 | |||
| 99f41edfee | |||
| 73ea54d02d | |||
| c5ae5ac6e9 | |||
| aab5b78c3f | |||
| 4643b627fe | |||
| 9b0af6db73 | |||
| 287b0ee441 | |||
| edc19c70a4 | |||
| ba967253f5 | |||
| 6e13187ae6 | |||
| 3185662a09 | |||
| 18a2ed81b9 | |||
| 94bb3e4be1 | |||
| 78f0025249 | |||
| 90dffa3730 | |||
| 2d4beac82f | |||
| 3898c57c6a | |||
| a5fd83dfda | |||
| ab271bc90d | |||
| 82e1783317 | |||
| aa2b9ed5f2 | |||
| 1c96d645ba | |||
| 2f010cf36d | |||
| 9d3c1eb9d5 | |||
| 08f3ceaf3f | |||
| b1d033edc9 | |||
| 192fa76b54 | |||
| b667200ebd | |||
| 294922f966 | |||
| 8b73aa3644 | |||
| dd56a8abeb | |||
| 145c688830 | |||
| 950d215632 | |||
| 7d5cc4e84b | |||
| 3e5bcf96ea | |||
| fe24c00178 | |||
| aca334f81f | |||
| 2003e41c22 | |||
| 5ebdf4b4d4 | |||
| 35e771a1ce |
@@ -0,0 +1,5 @@
|
||||
secret:
|
||||
ignored_matches:
|
||||
- name:
|
||||
match: "na1hhus-OLhq9mb9SO3R-8E4dONuMnqpZSY_SX8xcFk="
|
||||
version: 2
|
||||
@@ -43,6 +43,10 @@ venv.bak/
|
||||
env.d/development/*.local
|
||||
env.d/terraform
|
||||
|
||||
# Docker
|
||||
compose.override.yml
|
||||
docker/auth/*.local
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
|
||||
+22
-4
@@ -6,14 +6,24 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) create skeleton component for DocEditor #1491
|
||||
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) adapt custom blocks to new implementation #1375
|
||||
- ♻️(backend) increase user short_name field length
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix duplicate document entries in grid #1479
|
||||
- 🐛(frontend) show full nested doc names with ajustable bar #1456
|
||||
- 🐛(backend) fix trashbin list
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
|
||||
- 🐛(backend) fix s3 version_id validation
|
||||
|
||||
## [3.8.2] - 2025-10-17
|
||||
|
||||
@@ -42,10 +52,6 @@ and this project adheres to
|
||||
- ✨(frontend) add pdf block to the editor #1293
|
||||
- ✨List and restore deleted docs #1450
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) show full nested doc names with ajustable bar #1456
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
|
||||
@@ -68,6 +74,9 @@ and this project adheres to
|
||||
- ♿ update labels and shared document icon accessibility #1442
|
||||
- 🍱(frontend) Fonts GDPR compliants #1453
|
||||
- ♻️(service-worker) improve SW registration and update handling #1473
|
||||
- ✨(backend) add async indexation of documents on save (or access save) #1276
|
||||
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
|
||||
- ✨(api) add API route to search for indexed documents in Find #1276
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -88,11 +97,18 @@ and this project adheres to
|
||||
- ✨(frontend) load docs logo from public folder via url #1462
|
||||
- 🔧(keycloak) Fix https required issue in dev mode #1286
|
||||
|
||||
## Removed
|
||||
|
||||
- 🔥(frontend) remove custom DividerBlock ##1375
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add API route to fetch document content #1206
|
||||
- ✨(frontend) doc emojis improvements #1381
|
||||
- add an EmojiPicker in the document tree and document title
|
||||
- remove emoji buttons in menus
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -106,6 +122,8 @@ and this project adheres to
|
||||
- ✨unify tab focus style for better visual consistency #1341
|
||||
- ♿hide decorative icons, label menus, avoid accessible name… #1362
|
||||
- ♻️(tilt) use helm dev-backend chart
|
||||
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
|
||||
- 🩹(frontend) handle properly emojis in interlinking #1381
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -247,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
index: ## index all documents to remote search
|
||||
@$(MANAGE) index
|
||||
.PHONY: index
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
|
||||
+18
@@ -72,6 +72,11 @@ services:
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "8071:8000"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
aliases:
|
||||
- impress
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -92,6 +97,9 @@ services:
|
||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
@@ -107,6 +115,11 @@ services:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
aliases:
|
||||
- nginx
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -217,3 +230,8 @@ services:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
|
||||
@@ -12,6 +12,7 @@ flowchart TD
|
||||
Back --> DB("Database (PostgreSQL)")
|
||||
Back <--> Celery --> DB
|
||||
Back ----> S3("Minio (S3)")
|
||||
Back -- REST API --> Find
|
||||
```
|
||||
|
||||
### Architecture decision records
|
||||
|
||||
@@ -93,6 +93,12 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||
| POSTHOG_KEY | Posthog key for analytics | |
|
||||
| REDIS_URL | Cache url | redis://redis:6379/1 |
|
||||
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
|
||||
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
|
||||
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
|
||||
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
|
||||
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
|
||||
| SEARCH_INDEXER_QUERY_URL | Find application endpoint for search | |
|
||||
| SENTRY_DSN | Sentry host | |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# Setup full-text search with Find
|
||||
|
||||
This configuration will enable the fulltext search feature for Docs :
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
|
||||
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
|
||||
|
||||
## Create an index service for Docs
|
||||
|
||||
In Find application Django admin configure a **Service** with these settings
|
||||
|
||||
- **Name**: `docs`<br>_request.auth.name of the Docs application._
|
||||
|
||||
- **Client id**: `impress`<br>_Name of the token audience or client_id of the Docs application._
|
||||
|
||||
## Configure settings
|
||||
|
||||
Add those Django settings to enable the feature.
|
||||
|
||||
```shell
|
||||
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
|
||||
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the index calls.
|
||||
|
||||
# Indexation endpoint.
|
||||
SEARCH_INDEXER_SECRET=my-token-from-the-find-impress-service
|
||||
# The token from service "docs" of Find application.
|
||||
SEARCH_INDEXER_URL="http://app-find:8000/api/v1.0/documents/index/"
|
||||
|
||||
# Search endpoint. Uses the OIDC token for authentication
|
||||
SEARCH_INDEXER_QUERY_URL="http://app-find:8000/api/v1.0/documents/search/"
|
||||
```
|
||||
|
||||
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
|
||||
|
||||
```shell
|
||||
# Store OIDC tokens in the session
|
||||
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
```
|
||||
|
||||
# Installation with docker compose
|
||||
|
||||
First see [compose installation](installation/compose.md) documentation.
|
||||
|
||||
## 1. Postgresql
|
||||
|
||||
Find uses PostgreSQL as its database.
|
||||
|
||||
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/find_postgresql`.
|
||||
|
||||
```shell
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=find
|
||||
POSTGRES_USER=dinum
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=postgresql-find
|
||||
DB_NAME=find
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=<password>
|
||||
DB_PORT=5432
|
||||
```
|
||||
|
||||
And the service can be configured in `compose.yml`.
|
||||
|
||||
```yaml
|
||||
postgresql-find:
|
||||
image: postgres:15
|
||||
env_file:
|
||||
- env.d/development/find_postgresql
|
||||
- env.d/development/find_postgresql.local
|
||||
ports:
|
||||
- "15433:5432"
|
||||
```
|
||||
|
||||
## 2. Opensearch
|
||||
|
||||
Find uses Opensearch as its indexation database.
|
||||
|
||||
If you are using the example provided, you need to generate a secure key for `OPENSEARCH_INITIAL_ADMIN_PASSWORD` and `OPENSEARCH_PASSWORD`
|
||||
to set them in `env.d/opensearch`.
|
||||
|
||||
```shell
|
||||
OPENSEARCH_INITIAL_ADMIN_PASSWORD=find
|
||||
OPENSEARCH_PASSWORD=find
|
||||
OPENSEARCH_USE_SSL=true # false in Development mode
|
||||
|
||||
# Dashboard
|
||||
OPENSEARCH_HOSTS=["http://opensearch:9200"]
|
||||
DISABLE_SECURITY_DASHBOARDS_PLUGIN=false # true in Development mode
|
||||
```
|
||||
|
||||
And the service & dashboard can be configured in `compose.yml`.
|
||||
|
||||
```yaml
|
||||
opensearch:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: opensearchproject/opensearch:latest
|
||||
env_file:
|
||||
- env.d/development/opensearch
|
||||
- env.d/development/opensearch.local
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- plugins.security.disabled=true
|
||||
- plugins.security.ssl.http.enabled=false
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
volumes:
|
||||
- ./data/opensearch:/usr/share/opensearch/data
|
||||
ports:
|
||||
- "9200:9200"
|
||||
- "9600:9600"
|
||||
|
||||
opensearch-dashboards:
|
||||
image: opensearchproject/opensearch-dashboards:latest
|
||||
ports:
|
||||
- "5601:5601"
|
||||
env_file:
|
||||
- env.d/development/opensearch
|
||||
- env.d/development/opensearch.local
|
||||
depends_on:
|
||||
- opensearch
|
||||
```
|
||||
|
||||
## 3. Find (dev mode)
|
||||
|
||||
If you are using the example provided, you need to generate a secure key for `OIDC_RS_CLIENT_SECRET` and set it in `env.d/find`.
|
||||
|
||||
```shell
|
||||
# Django
|
||||
DJANGO_ALLOWED_HOSTS=*
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=find.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
|
||||
# find settings
|
||||
|
||||
# Backend url
|
||||
FIND_BASE_URL="http://localhost:9072"
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_URL=http://localhost:8083/realms/impress
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
|
||||
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
|
||||
|
||||
OIDC_RP_CLIENT_ID=impress
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# OIDC Resource server
|
||||
OIDC_RS_SCOPES="openid"
|
||||
OIDC_RS_CLIENT_ID=impress
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_SIGN_ALGO=RS256
|
||||
|
||||
OIDC_RS_BACKEND_CLASS="core.authentication.FinderResourceServerBackend"
|
||||
```
|
||||
|
||||
And Find can be configured in `compose.yml`.
|
||||
|
||||
```yaml
|
||||
find-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: find:backend-development
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/find
|
||||
- env.d/development/find.local
|
||||
- env.d/development/find_postgresql
|
||||
- env.d/development/find_postgresql.local
|
||||
- env.d/development/opensearch
|
||||
- env.d/development/opensearch.local
|
||||
ports:
|
||||
- "9071:8000"
|
||||
volumes:
|
||||
- ../find/src/backend:/app
|
||||
- ../find/data/static:/data/static
|
||||
depends_on:
|
||||
- find_postgresql
|
||||
- opensearch
|
||||
- redis
|
||||
```
|
||||
|
||||
## Ports (dev defaults)
|
||||
|
||||
| Port | Service |
|
||||
| --------- | --------------------- |
|
||||
| 3000 | Next.js |
|
||||
| 8071 | Django (main) |
|
||||
| 9071 | Django (Find) |
|
||||
| 4444 | Y-Provider |
|
||||
| 8080 | Keycloak |
|
||||
| 8083 | Nginx proxy |
|
||||
| 9000/9001 | MinIO |
|
||||
| 15432 | PostgreSQL (main) |
|
||||
| 15433 | PostgreSQL (Find) |
|
||||
| 5433 | PostgreSQL (Keycloak) |
|
||||
| 1081 | MailCatcher |
|
||||
| 9200 | Opensearch |
|
||||
| 9600 | Opensearch admin |
|
||||
| 5601 | Opensearch dashboard |
|
||||
@@ -36,6 +36,7 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/c
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
|
||||
|
||||
OIDC_RP_CLIENT_ID=impress
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
@@ -49,6 +50,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session
|
||||
OIDC_STORE_ACCESS_TOKEN = True
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# To create one, use the bin/fernetkey command.
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = "na1hhus-OLhq9mb9SO3R-8E4dONuMnqpZSY_SX8xcFk="
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
@@ -68,4 +77,10 @@ Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
|
||||
# Indexer (disabled)
|
||||
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
|
||||
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
|
||||
+6
-2
@@ -19,13 +19,17 @@
|
||||
"matchPackageNames": ["redis"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed pylint versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"@hocuspocus/provider",
|
||||
"@hocuspocus/server",
|
||||
"docx",
|
||||
"fetch-mock",
|
||||
"node",
|
||||
|
||||
@@ -889,3 +889,13 @@ class MoveDocumentSerializer(serializers.Serializer):
|
||||
choices=enums.MoveNodePositionChoices.choices,
|
||||
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
||||
)
|
||||
|
||||
|
||||
class SearchDocumentSerializer(serializers.Serializer):
|
||||
"""Serializer for fulltext search requests through Find application"""
|
||||
|
||||
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.paginator import InvalidPage, Paginator
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import connection, transaction
|
||||
from django.db import models as db
|
||||
@@ -21,6 +22,7 @@ from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -31,9 +33,11 @@ from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.utils.urls import replace_query_param as drf_replace_query_param
|
||||
|
||||
from core import authentication, choices, enums, models
|
||||
from core.services.ai_services import AIService
|
||||
@@ -47,6 +51,10 @@ from core.services.converter_services import (
|
||||
from core.services.converter_services import (
|
||||
YdocConverter,
|
||||
)
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
@@ -373,6 +381,7 @@ class DocumentViewSet(
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
trashbin_serializer_class = serializers.ListDocumentSerializer
|
||||
tree_serializer_class = serializers.ListDocumentSerializer
|
||||
search_serializer_class = serializers.ListDocumentSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset performing all annotation and filtering on the document tree structure."""
|
||||
@@ -636,6 +645,9 @@ class DocumentViewSet(
|
||||
.values_list("document__path", flat=True)
|
||||
)
|
||||
|
||||
if not access_documents_paths:
|
||||
return self.get_response_for_queryset(self.queryset.none())
|
||||
|
||||
children_clause = db.Q()
|
||||
for path in access_documents_paths:
|
||||
children_clause |= db.Q(path__startswith=path)
|
||||
@@ -1061,6 +1073,114 @@ class DocumentViewSet(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def _search_simple(self, request, text):
|
||||
"""
|
||||
Returns a queryset filtered by the content of the document title
|
||||
"""
|
||||
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
|
||||
queryset = self.get_queryset()
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset)
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset.order_by("-updated_at"),
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
def _search_fulltext(self, indexer, request, params):
|
||||
"""
|
||||
Returns a queryset from the results the fulltext search of Find
|
||||
"""
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
user = request.user
|
||||
text = params.validated_data["q"]
|
||||
page_size = params.validated_data.get("page_size", 20)
|
||||
page_number = params.validated_data.get("page", 1)
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
# Retrieve the documents ids from Find.
|
||||
results = indexer.search(
|
||||
text=text,
|
||||
token=access_token,
|
||||
visited=get_visited_document_ids_of(queryset, user),
|
||||
page=1,
|
||||
page_size=100,
|
||||
)
|
||||
|
||||
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
|
||||
ordered_docs = [docs_by_uuid[id] for id in results]
|
||||
|
||||
paginator = Paginator(
|
||||
ordered_docs, per_page=page_size, allow_empty_first_page=True
|
||||
)
|
||||
|
||||
try:
|
||||
page = paginator.page(page_number)
|
||||
except InvalidPage as e:
|
||||
raise drf.exceptions.NotFound(_("Invalid page.")) from e
|
||||
|
||||
serializer = self.get_serializer(
|
||||
page.object_list,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
next_url, prev_url = None, None
|
||||
|
||||
if page.has_next():
|
||||
next_url = request.build_absolute_uri()
|
||||
next_url = drf_replace_query_param(
|
||||
next_url, "page", page.next_page_number()
|
||||
)
|
||||
|
||||
if page.has_previous():
|
||||
prev_url = request.build_absolute_uri()
|
||||
prev_url = drf_replace_query_param(
|
||||
prev_url, "page", page.previous_page_number()
|
||||
)
|
||||
|
||||
output = {
|
||||
"count": paginator.count,
|
||||
"next": next_url,
|
||||
"previous": prev_url,
|
||||
"results": serializer.data,
|
||||
}
|
||||
|
||||
return drf.response.Response(output)
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def search(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
|
||||
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
|
||||
Depending of the configuration it can be:
|
||||
- A fulltext search through the opensearch indexation app "find" if the backend is
|
||||
enabled (see SEARCH_INDEXER_CLASS)
|
||||
- A filtering by the model field 'title'.
|
||||
|
||||
The ordering is always by the most recent first.
|
||||
"""
|
||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||
params.is_valid(raise_exception=True)
|
||||
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
return self._search_fulltext(indexer, request, params=params)
|
||||
|
||||
# The indexer is not configured, we fallback on a simple icontains filter by the
|
||||
# model field 'title'.
|
||||
return self._search_simple(request, text=params.validated_data["q"])
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -1102,7 +1222,7 @@ class DocumentViewSet(
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get", "delete"],
|
||||
url_path="versions/(?P<version_id>[0-9a-z-]+)",
|
||||
url_path=r"versions/(?P<version_id>[A-Za-z0-9._+\-=~]{1,1024})",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def versions_detail(self, request, pk, version_id, *args, **kwargs):
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Impress Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the impress core app."""
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the impress core app."""
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("impress core application")
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("Impress core application")
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
from . import signals # noqa: PLC0415
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Handle search setup that needs to be done at bootstrap time.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
logger = logging.getLogger("docs.search.bootstrap_search")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Index all documents to remote search service"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Launch and log search index generation."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if not indexer:
|
||||
raise CommandError("The indexer is not enabled or properly configured.")
|
||||
|
||||
logger.info("Starting to regenerate Find index...")
|
||||
start = time.perf_counter()
|
||||
|
||||
try:
|
||||
count = indexer.index()
|
||||
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,
|
||||
)
|
||||
+25
-22
@@ -432,32 +432,35 @@ class Document(MP_Node, BaseModel):
|
||||
def save(self, *args, **kwargs):
|
||||
"""Write content to object storage only if _content has changed."""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self._content:
|
||||
file_key = self.file_key
|
||||
bytes_content = self._content.encode("utf-8")
|
||||
self.save_content(self._content)
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
raise
|
||||
def save_content(self, content):
|
||||
"""Save content to object storage."""
|
||||
|
||||
file_key = self.file_key
|
||||
bytes_content = content.encode("utf-8")
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"')
|
||||
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
raise
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
|
||||
def is_leaf(self):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Document search index management utilities and indexers"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from functools import cache
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Subquery
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cache
|
||||
def get_document_indexer():
|
||||
"""Returns an instance of indexer service if enabled and properly configured."""
|
||||
classpath = settings.SEARCH_INDEXER_CLASS
|
||||
|
||||
# For this usecase an empty indexer class is not an issue but a feature.
|
||||
if not classpath:
|
||||
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
|
||||
return None
|
||||
|
||||
try:
|
||||
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
|
||||
return indexer_class()
|
||||
except ImportError as err:
|
||||
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
|
||||
except ImproperlyConfigured as err:
|
||||
logger.error("Document indexer is not properly configured : %s", err)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_batch_accesses_by_users_and_teams(paths):
|
||||
"""
|
||||
Get accesses related to a list of document paths,
|
||||
grouped by users and teams, including all ancestor paths.
|
||||
"""
|
||||
ancestor_map = utils.get_ancestor_to_descendants_map(
|
||||
paths, steplen=models.Document.steplen
|
||||
)
|
||||
ancestor_paths = list(ancestor_map.keys())
|
||||
|
||||
access_qs = models.DocumentAccess.objects.filter(
|
||||
document__path__in=ancestor_paths
|
||||
).values("document__path", "user__sub", "team")
|
||||
|
||||
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
|
||||
|
||||
for access in access_qs:
|
||||
ancestor_path = access["document__path"]
|
||||
user_sub = access["user__sub"]
|
||||
team = access["team"]
|
||||
|
||||
for descendant_path in ancestor_map.get(ancestor_path, []):
|
||||
if user_sub:
|
||||
access_by_document_path[descendant_path]["users"].add(str(user_sub))
|
||||
if team:
|
||||
access_by_document_path[descendant_path]["teams"].add(team)
|
||||
|
||||
return dict(access_by_document_path)
|
||||
|
||||
|
||||
def get_visited_document_ids_of(queryset, user):
|
||||
"""
|
||||
Returns the ids of the documents that have a linktrace to the user and NOT owned.
|
||||
It will be use to limit the opensearch responses to the public documents already
|
||||
"visited" by the user.
|
||||
"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
return []
|
||||
|
||||
qs = models.LinkTrace.objects.filter(user=user)
|
||||
|
||||
docs = (
|
||||
queryset.exclude(accesses__user=user)
|
||||
.filter(
|
||||
deleted_at__isnull=True,
|
||||
ancestors_deleted_at__isnull=True,
|
||||
)
|
||||
.filter(pk__in=Subquery(qs.values("document_id")))
|
||||
.order_by("pk")
|
||||
.distinct("pk")
|
||||
)
|
||||
|
||||
return [str(id) for id in docs.values_list("pk", flat=True)]
|
||||
|
||||
|
||||
class BaseDocumentIndexer(ABC):
|
||||
"""
|
||||
Base class for document indexers.
|
||||
|
||||
Handles batching and access resolution. Subclasses must implement both
|
||||
`serialize_document()` and `push()` to define backend-specific behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, batch_size=None):
|
||||
"""
|
||||
Initialize the indexer.
|
||||
|
||||
Args:
|
||||
batch_size (int, optional): Number of documents per batch.
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
"""
|
||||
self.batch_size = batch_size or settings.SEARCH_INDEXER_BATCH_SIZE
|
||||
self.indexer_url = settings.SEARCH_INDEXER_URL
|
||||
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
|
||||
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
|
||||
|
||||
if not self.indexer_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.indexer_secret:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_SECRET must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
def index(self):
|
||||
"""
|
||||
Fetch documents in batches, serialize them, and push to the search backend.
|
||||
"""
|
||||
last_id = 0
|
||||
count = 0
|
||||
|
||||
while True:
|
||||
documents_batch = list(
|
||||
models.Document.objects.filter(
|
||||
id__gt=last_id,
|
||||
).order_by("id")[: self.batch_size]
|
||||
)
|
||||
|
||||
if not documents_batch:
|
||||
break
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
self.push(serialized_batch)
|
||||
count += len(serialized_batch)
|
||||
|
||||
return count
|
||||
|
||||
@abstractmethod
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document instance to a JSON-serializable format for indexing.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of serialized documents to the backend.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
||||
def search(self, text, token, visited=(), page=1, page_size=50):
|
||||
"""
|
||||
Search for documents in Find app.
|
||||
Ensure the same default ordering as "Docs" list : -updated_at
|
||||
|
||||
Returns ids of the documents
|
||||
|
||||
Args:
|
||||
text (str): Text search content.
|
||||
token (str): OIDC Authentication token.
|
||||
visited (list, optional):
|
||||
List of ids of active public documents with LinkTrace
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
page (int, optional):
|
||||
The page number to retrieve.
|
||||
Defaults to 1 if not specified.
|
||||
page_size (int, optional):
|
||||
The number of results to return per page.
|
||||
Defaults to 50 if not specified.
|
||||
"""
|
||||
response = self.search_query(
|
||||
data={
|
||||
"q": text,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"page_number": page,
|
||||
"page_size": page_size,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
return [d["_id"] for d in response]
|
||||
|
||||
@abstractmethod
|
||||
def search_query(self, data, token) -> dict:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
|
||||
class SearchIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
Document indexer that pushes documents to La Suite Find app.
|
||||
"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document to the JSON format expected by La Suite Find.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
accesses (dict): Mapping of document ID to user/team access.
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
"title": document.title or "",
|
||||
"content": text_content,
|
||||
"depth": document.depth,
|
||||
"path": document.path,
|
||||
"numchild": document.numchild,
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"users": list(accesses.get(doc_path, {}).get("users", set())),
|
||||
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
|
||||
"reach": document.computed_link_reach,
|
||||
"size": len(text_content.encode("utf-8")),
|
||||
"is_active": not bool(document.ancestors_deleted_at),
|
||||
}
|
||||
|
||||
def search_query(self, data, token) -> requests.Response:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Args:
|
||||
data (dict): search data
|
||||
token (str): OICD token
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.search_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of documents to the Find backend.
|
||||
|
||||
Args:
|
||||
data (list): List of document dictionaries.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.indexer_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {self.indexer_secret}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Declare and configure the signals for the impress core application
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
from . import models
|
||||
from .tasks.search import trigger_document_indexer
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.Document)
|
||||
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
Note : Within the transaction we can have an empty content and a serialization
|
||||
error.
|
||||
"""
|
||||
transaction.on_commit(partial(trigger_document_indexer, instance))
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.DocumentAccess)
|
||||
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
"""
|
||||
if not created:
|
||||
transaction.on_commit(partial(trigger_document_indexer, instance.document))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Trigger document indexation using celery task."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from django_redis.cache import RedisCache
|
||||
|
||||
from core import models
|
||||
from core.services.search_indexers import (
|
||||
get_batch_accesses_by_users_and_teams,
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
from impress.celery_app import app
|
||||
|
||||
logger = getLogger(__file__)
|
||||
|
||||
|
||||
def indexer_throttle_acquire(document_id, timeout=0, atomic=True):
|
||||
"""
|
||||
Enable the task throttle flag for a delay.
|
||||
Uses redis locks if available to ensure atomic changes
|
||||
"""
|
||||
key = f"doc-indexer-throttle-{document_id}"
|
||||
|
||||
if isinstance(cache, RedisCache) and atomic:
|
||||
with cache.locks(key):
|
||||
return indexer_throttle_acquire(document_id, timeout, atomic=False)
|
||||
|
||||
# Use add() here :
|
||||
# - set the flag and returns true if not exist
|
||||
# - do nothing and return false if exist
|
||||
return cache.add(key, 1, timeout=timeout)
|
||||
|
||||
|
||||
@app.task
|
||||
def document_indexer_task(document_id):
|
||||
"""Celery Task : Sends indexation query for a document."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer is None:
|
||||
return
|
||||
|
||||
try:
|
||||
doc = models.Document.objects.get(pk=document_id)
|
||||
except models.Document.DoesNotExist:
|
||||
# Skip the task if the document does not exist.
|
||||
return
|
||||
|
||||
accesses = get_batch_accesses_by_users_and_teams((doc.path,))
|
||||
|
||||
data = indexer.serialize_document(document=doc, accesses=accesses)
|
||||
|
||||
logger.info("Start document %s indexation", document_id)
|
||||
indexer.push(data)
|
||||
|
||||
|
||||
def trigger_document_indexer(document):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
"""
|
||||
countdown = settings.SEARCH_INDEXER_COUNTDOWN
|
||||
|
||||
# DO NOT create a task if indexation if disabled
|
||||
if not settings.SEARCH_INDEXER_CLASS:
|
||||
return
|
||||
|
||||
# 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 indexer_throttle_acquire(document.pk, timeout=countdown):
|
||||
logger.info(
|
||||
"Add task for document %s indexation in %.2f seconds",
|
||||
document.pk,
|
||||
countdown,
|
||||
)
|
||||
|
||||
document_indexer_task.apply_async(args=[document.pk])
|
||||
else:
|
||||
logger.info("Skip task for document %s indexation", document.pk)
|
||||
@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(3): # user by sub, user by mail, update sub
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
with django_assert_num_queries(3):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(3):
|
||||
with django_assert_num_queries(4):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Unit test for `index` command.
|
||||
"""
|
||||
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
empty_doc = factories.DocumentFactory(title=None, content="")
|
||||
no_title_doc = factories.DocumentFactory(title=None)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(empty_doc.path): {"users": [user.sub]},
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
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()
|
||||
|
||||
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_improperly_configured(indexer_settings):
|
||||
"""The command should raise an exception if the indexer is not configured"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
with pytest.raises(CommandError) as err:
|
||||
call_command("index")
|
||||
|
||||
assert str(err.value) == "The indexer is not enabled or properly configured."
|
||||
@@ -24,3 +24,29 @@ def mock_user_teams():
|
||||
"core.models.User.teams", new_callable=mock.PropertyMock
|
||||
) as mock_teams:
|
||||
yield mock_teams
|
||||
|
||||
|
||||
@pytest.fixture(name="indexer_settings")
|
||||
def indexer_settings_fixture(settings):
|
||||
"""
|
||||
Setup valid settings for the document indexer. Clear the indexer cache.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from core.services.search_indexers import ( # noqa: PLC0415
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
|
||||
yield settings
|
||||
|
||||
# clear cache to prevent issues with other tests
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import random
|
||||
from json import loads as json_loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_search_url(**kwargs):
|
||||
"""Build absolute uri for search endpoint with ORDERED query arguments"""
|
||||
return (
|
||||
RequestFactory()
|
||||
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
|
||||
.build_absolute_uri()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
"""
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[],
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
"api_page_size": 21, # default page_size is 20
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page": 1, "page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "score" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
docs_by_uuid = {str(doc.pk): doc for doc in docs}
|
||||
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
|
||||
|
||||
# reorder randomly to simulate score ordering
|
||||
random.shuffle(api_results)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=api_results,
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
start, end = expected["range"]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
# The find api results ordering by score is kept
|
||||
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"page_number": 1,
|
||||
"page_size": 100,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination_endpoint_is_none(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "-updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
queryset = models.Document.objects.order_by("-updated_at")
|
||||
start, end = expected["range"]
|
||||
expected_results = [str(d.pk) for d in queryset[start:end]]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
assert [r["id"] for r in results] == expected_results
|
||||
@@ -293,3 +293,29 @@ def test_api_documents_trashbin_distinct():
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(document.id)
|
||||
|
||||
|
||||
def test_api_documents_trashbin_empty_queryset_bug():
|
||||
"""
|
||||
Test that users with no owner role don't see documents.
|
||||
"""
|
||||
# Create a new user with no owner access to any document
|
||||
new_user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(new_user)
|
||||
|
||||
# Create some deleted documents owned by other users
|
||||
other_user = factories.UserFactory()
|
||||
item1 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item1.soft_delete()
|
||||
item2 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item2.soft_delete()
|
||||
item3 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item3.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 0
|
||||
assert len(content["results"]) == 0
|
||||
|
||||
@@ -6,6 +6,7 @@ Unit tests for the Document model
|
||||
import random
|
||||
import smtplib
|
||||
from logging import Logger
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
@@ -13,12 +14,15 @@ from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import transaction
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
from core.tasks.search import document_indexer_task
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -1445,3 +1449,330 @@ def test_models_documents_compute_ancestors_links_paths_mapping_structure(
|
||||
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push, indexer_settings):
|
||||
"""Test indexation task on document creation"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert sorted(data, key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The throttle counters should be reset
|
||||
assert cache.get(f"doc-indexer-throttle-{doc1.pk}") is None
|
||||
assert cache.get(f"doc-indexer-throttle-{doc2.pk}") is None
|
||||
assert cache.get(f"doc-indexer-throttle-{doc3.pk}") is None
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push, indexer_settings):
|
||||
"""Test indexation task on document creation"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc2, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc3, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc1.path): {"users": [user.sub]},
|
||||
str(doc2.path): {"users": [user.sub]},
|
||||
str(doc3.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert sorted(data, key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The throttle counters should be reset
|
||||
assert cache.get(f"doc-indexer-throttle-{doc1.pk}") is None
|
||||
assert cache.get(f"doc-indexer-throttle-{doc2.pk}") is None
|
||||
assert cache.get(f"doc-indexer-throttle-{doc3.pk}") is None
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push, indexer_settings):
|
||||
"""Indexation task on deleted or ancestor_deleted documents"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_deleted = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_ancestor_deleted = factories.DocumentFactory(
|
||||
parent=doc_deleted,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
doc_deleted.soft_delete()
|
||||
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
|
||||
|
||||
doc_deleted.refresh_from_db()
|
||||
doc_ancestor_deleted.refresh_from_db()
|
||||
|
||||
assert doc_deleted.deleted_at is not None
|
||||
assert doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert doc_ancestor_deleted.deleted_at is None
|
||||
assert doc_ancestor_deleted.ancestors_deleted_at is not None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(doc_deleted.path): {"users": [user.sub]},
|
||||
str(doc_ancestor_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
# Even deleted document are re-indexed : only update their status in the future ?
|
||||
assert sorted(data, key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses),
|
||||
indexer.serialize_document(doc_ancestor_deleted, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The throttle counters should be reset
|
||||
assert cache.get(f"doc-indexer-throttle-{doc.pk}") is None
|
||||
assert cache.get(f"doc-indexer-throttle-{doc_deleted.pk}") is None
|
||||
assert cache.get(f"doc-indexer-throttle-{doc_ancestor_deleted.pk}") is None
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_indexer_hard_deleted(indexer_settings):
|
||||
"""Indexation task on hard deleted document"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
doc_id = doc.pk
|
||||
doc.delete()
|
||||
|
||||
# Call task on deleted document.
|
||||
document_indexer_task.apply(args=[doc_id])
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# Hard delete document are not re-indexed.
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push, indexer_settings):
|
||||
"""Restart indexation task on restored documents"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_deleted = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_ancestor_deleted = factories.DocumentFactory(
|
||||
parent=doc_deleted,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
doc_deleted.soft_delete()
|
||||
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
|
||||
|
||||
doc_deleted.refresh_from_db()
|
||||
doc_ancestor_deleted.refresh_from_db()
|
||||
|
||||
assert doc_deleted.deleted_at is not None
|
||||
assert doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert doc_ancestor_deleted.deleted_at is None
|
||||
assert doc_ancestor_deleted.ancestors_deleted_at is not None
|
||||
|
||||
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
|
||||
doc_restored.restore()
|
||||
|
||||
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
|
||||
|
||||
assert doc_restored.deleted_at is None
|
||||
assert doc_restored.ancestors_deleted_at is None
|
||||
|
||||
assert doc_ancestor_restored.deleted_at is None
|
||||
assert doc_ancestor_restored.ancestors_deleted_at is None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(doc_deleted.path): {"users": [user.sub]},
|
||||
str(doc_ancestor_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
# All docs are re-indexed
|
||||
assert sorted(data, key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
|
||||
indexer.serialize_document(doc_restored, accesses), # restore()
|
||||
indexer.serialize_document(doc_ancestor_deleted, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_throttle(indexer_settings):
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
indexer = SearchIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# Simulate 1 running task
|
||||
cache.set(f"doc-indexer-throttle-{doc.pk}", 1)
|
||||
|
||||
# save doc to trigger the indexer, but nothing should be done since
|
||||
# the flag is up
|
||||
with transaction.atomic():
|
||||
doc.save()
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == []
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# No waiting task
|
||||
cache.delete(f"doc-indexer-throttle-{doc.pk}")
|
||||
|
||||
with transaction.atomic():
|
||||
doc = models.Document.objects.get(pk=doc.pk)
|
||||
doc.save()
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == [
|
||||
indexer.serialize_document(doc, accesses),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_access_post_save_indexer(indexer_settings):
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
indexer = SearchIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
doc_access = factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
doc_access.save()
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == [
|
||||
indexer.serialize_document(doc, accesses),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
"""Tests for Documents search indexers"""
|
||||
|
||||
from functools import partial
|
||||
from json import dumps as json_dumps
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests import HTTPError
|
||||
|
||||
from core import factories, models, utils
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
SearchIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class FakeDocumentIndexer(BaseDocumentIndexer):
|
||||
"""Fake indexer for test purpose"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
return {}
|
||||
|
||||
def push(self, data):
|
||||
pass
|
||||
|
||||
def search_query(self, data, token):
|
||||
return {}
|
||||
|
||||
|
||||
def test_services_search_indexer_class_invalid(indexer_settings):
|
||||
"""
|
||||
Should raise RuntimeError if SEARCH_INDEXER_CLASS cannot be imported.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = "unknown.Unknown"
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
|
||||
def test_services_search_indexer_class(indexer_settings):
|
||||
"""
|
||||
Import indexer class defined in setting SEARCH_INDEXER_CLASS.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.tests.test_services_search_indexers.FakeDocumentIndexer"
|
||||
)
|
||||
|
||||
assert isinstance(
|
||||
get_document_indexer(),
|
||||
import_string("core.tests.test_services_search_indexers.FakeDocumentIndexer"),
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_indexer_is_configured(indexer_settings):
|
||||
"""
|
||||
Should return true only when the indexer class and other configuration settings
|
||||
are valid.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
# None
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
# Empty
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = ""
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
# Valid class
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.services.search_indexers.SearchIndexer"
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
# Invalid url
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
"""
|
||||
It should serialize documents with correct metadata and access control.
|
||||
"""
|
||||
user_a, user_b = factories.UserFactory.create_batch(2)
|
||||
document = factories.DocumentFactory()
|
||||
factories.DocumentFactory(parent=document)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=document, user=user_a)
|
||||
factories.UserDocumentAccessFactory(document=document, user=user_b)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="team1")
|
||||
factories.TeamDocumentAccessFactory(document=document, team="team2")
|
||||
|
||||
accesses = {
|
||||
document.path: {
|
||||
"users": {str(user_a.sub), str(user_b.sub)},
|
||||
"teams": {"team1", "team2"},
|
||||
}
|
||||
}
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, accesses)
|
||||
|
||||
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
|
||||
assert set(result.pop("groups")) == {"team1", "team2"}
|
||||
assert result == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"depth": 1,
|
||||
"path": document.path,
|
||||
"numchild": 1,
|
||||
"content": utils.base64_yjs_to_text(document.content),
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"reach": document.link_reach,
|
||||
"size": 13,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_deleted():
|
||||
"""Deleted documents are marked as just in the serialized json."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
|
||||
parent.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["is_active"] is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_empty():
|
||||
"""Empty documents returns empty content in the serialized json."""
|
||||
document = factories.DocumentFactory(content="", title=None)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
assert result["title"] == ""
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
Documents indexing response handling on Find API HTTP errors.
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://app-find/api/v1.0/documents/index/",
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().index()
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
and only the access data relevant to each batch should be used.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
|
||||
documents = factories.DocumentFactory.create_batch(5)
|
||||
|
||||
# Attach a single user access to each document
|
||||
expected_user_subs = {}
|
||||
for document in documents:
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
|
||||
seen_doc_ids = set()
|
||||
|
||||
for call in mock_push.call_args_list:
|
||||
batch = call.args[0]
|
||||
assert isinstance(batch, list)
|
||||
|
||||
for doc_json in batch:
|
||||
doc_id = doc_json["id"]
|
||||
seen_doc_ids.add(doc_id)
|
||||
|
||||
# Only one user expected per document
|
||||
assert doc_json["users"] == [expected_user_subs[doc_id]]
|
||||
assert doc_json["groups"] == []
|
||||
|
||||
# Make sure all 5 documents were indexed
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
and only the access data relevant to each batch should be used.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
factories.DocumentFactory(content="", title="")
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
# Make sure only not eempty documents are indexed
|
||||
results = {doc["id"] for doc in mock_push.call_args[0][0]}
|
||||
assert results == {
|
||||
str(d.id)
|
||||
for d in (
|
||||
document,
|
||||
empty_content,
|
||||
empty_title,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
"""Document accesses and reach should take into account ancestors link reaches."""
|
||||
great_grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent = factories.DocumentFactory(
|
||||
parent=great_grand_parent, link_reach="authenticated"
|
||||
)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert SearchIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
assert results[str(great_grand_parent.id)]["reach"] == "restricted"
|
||||
assert results[str(grand_parent.id)]["reach"] == "authenticated"
|
||||
assert results[str(parent.id)]["reach"] == "public"
|
||||
assert results[str(document.id)]["reach"] == "public"
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_users(mock_push):
|
||||
"""Document accesses and reach should include users from ancestors."""
|
||||
user_gp, user_p, user_d = factories.UserFactory.create_batch(3)
|
||||
|
||||
grand_parent = factories.DocumentFactory(users=[user_gp])
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
assert results[str(grand_parent.id)]["users"] == [str(user_gp.sub)]
|
||||
assert set(results[str(parent.id)]["users"]) == {str(user_gp.sub), str(user_p.sub)}
|
||||
assert set(results[str(document.id)]["users"]) == {
|
||||
str(user_gp.sub),
|
||||
str(user_p.sub),
|
||||
str(user_d.sub),
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
"""Document accesses and reach should include teams from ancestors."""
|
||||
grand_parent = factories.DocumentFactory(teams=["team_gp"])
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
assert results[str(grand_parent.id)]["groups"] == ["team_gp"]
|
||||
assert set(results[str(parent.id)]["groups"]) == {"team_gp", "team_p"}
|
||||
assert set(results[str(document.id)]["groups"]) == {"team_gp", "team_p", "team_d"}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
"""
|
||||
push() should call requests.post with the correct URL from settings
|
||||
the timeout set to 10 seconds and the data as JSON.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
|
||||
|
||||
indexer = SearchIndexer()
|
||||
sample_data = [{"id": "123", "title": "Test"}]
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
indexer.push(sample_data)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
|
||||
assert kwargs.get("json") == sample_data
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
|
||||
def test_get_visited_document_ids_of():
|
||||
"""
|
||||
get_visited_document_ids_of() returns the ids of the documents viewed
|
||||
by the user BUT without specific access configuration (like public ones)
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other = factories.UserFactory()
|
||||
anonymous = AnonymousUser()
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
assert not get_visited_document_ids_of(queryset, anonymous)
|
||||
assert not get_visited_document_ids_of(queryset, user)
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
# The third document is not visited
|
||||
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
|
||||
[str(doc1.pk), str(doc2.pk)]
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(user=other, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user, document=doc2)
|
||||
|
||||
# The second document have an access for the user
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_get_visited_document_ids_of_deleted():
|
||||
"""
|
||||
get_visited_document_ids_of() returns the ids of the documents viewed
|
||||
by the user if they are not deleted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
anonymous = AnonymousUser()
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
assert not get_visited_document_ids_of(queryset, anonymous)
|
||||
assert not get_visited_document_ids_of(queryset, user)
|
||||
|
||||
doc = factories.DocumentFactory()
|
||||
doc_deleted = factories.DocumentFactory()
|
||||
doc_ancestor_deleted = factories.DocumentFactory(parent=doc_deleted)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc)
|
||||
create_link(document=doc_deleted)
|
||||
create_link(document=doc_ancestor_deleted)
|
||||
|
||||
# The all documents are visited
|
||||
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
|
||||
[str(doc.pk), str(doc_deleted.pk), str(doc_ancestor_deleted.pk)]
|
||||
)
|
||||
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
# Only the first document is not deleted
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_search_errors(indexer_settings):
|
||||
"""
|
||||
Documents indexing response handling on Find API HTTP errors.
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://app-find/api/v1.0/documents/search/"
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://app-find/api/v1.0/documents/search/",
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().search("alpha", token="mytoken")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
"""
|
||||
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
|
||||
document ids from linktraces.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
|
||||
query_data = kwargs.get("json")
|
||||
assert query_data["q"] == "alpha"
|
||||
assert sorted(query_data["visited"]) == sorted([str(doc1.pk), str(doc2.pk)])
|
||||
assert query_data["services"] == ["docs"]
|
||||
assert query_data["page_number"] == 1
|
||||
assert query_data["page_size"] == 50
|
||||
assert query_data["order_by"] == "updated_at"
|
||||
assert query_data["order_direction"] == "desc"
|
||||
|
||||
assert kwargs.get("headers") == {"Authorization": "Bearer mytoken"}
|
||||
assert kwargs.get("timeout") == 10
|
||||
@@ -75,3 +75,28 @@ def test_utils_extract_attachments():
|
||||
base64_string = base64.b64encode(update).decode("utf-8")
|
||||
# image_key2 is missing the "/media/" part and shouldn't get extracted
|
||||
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_single_path():
|
||||
"""Test ancestor mapping of a single path."""
|
||||
paths = ["000100020005"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005"},
|
||||
"00010002": {"000100020005"},
|
||||
"000100020005": {"000100020005"},
|
||||
}
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
|
||||
"""Test ancestor mapping of multiple paths with shared prefixes."""
|
||||
paths = ["000100020005", "00010003"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005", "00010003"},
|
||||
"00010002": {"000100020005"},
|
||||
"000100020005": {"000100020005"},
|
||||
"00010003": {"00010003"},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -9,6 +10,27 @@ from bs4 import BeautifulSoup
|
||||
from core import enums
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
"""
|
||||
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
|
||||
|
||||
Each path is assumed to use materialized path format with fixed-length segments.
|
||||
|
||||
Args:
|
||||
paths (list of str): List of full document paths.
|
||||
steplen (int): Length of each path segment.
|
||||
|
||||
Returns:
|
||||
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
|
||||
"""
|
||||
ancestor_map = defaultdict(set)
|
||||
for path in paths:
|
||||
for i in range(steplen, len(path) + 1, steplen):
|
||||
ancestor = path[:i]
|
||||
ancestor_map[ancestor].add(path)
|
||||
return ancestor_map
|
||||
|
||||
|
||||
def filter_descendants(paths, root_paths, skip_sorting=False):
|
||||
"""
|
||||
Filters paths to keep only those that are descendants of any path in root_paths.
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# ruff: noqa: S311, S106
|
||||
"""create_demo management command"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from uuid import uuid4
|
||||
|
||||
from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
import pycrdt
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
@@ -27,6 +30,16 @@ def random_true_with_probability(probability):
|
||||
return random.random() < probability
|
||||
|
||||
|
||||
def get_ydoc_for_text(text):
|
||||
"""Return a ydoc from plain text for demo purposes."""
|
||||
ydoc = pycrdt.Doc()
|
||||
paragraph = pycrdt.XmlElement("p", {}, [pycrdt.XmlText(text)])
|
||||
fragment = pycrdt.XmlFragment([paragraph])
|
||||
ydoc["document-store"] = fragment
|
||||
update = ydoc.get_update()
|
||||
return base64.b64encode(update).decode("utf-8")
|
||||
|
||||
|
||||
class BulkQueue:
|
||||
"""A utility class to create Django model instances in bulk by just pushing to a queue."""
|
||||
|
||||
@@ -48,7 +61,7 @@ class BulkQueue:
|
||||
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
|
||||
|
||||
def push(self, obj):
|
||||
"""Add a model instance to queue to that it gets created in bulk."""
|
||||
"""Add a model instance to queue so that it gets created in bulk."""
|
||||
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
|
||||
objects.append(obj)
|
||||
if len(objects) > self.BATCH_SIZE:
|
||||
@@ -139,17 +152,19 @@ def create_demo(stdout):
|
||||
# pylint: disable=protected-access
|
||||
key = models.Document._int2str(i) # noqa: SLF001
|
||||
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
|
||||
queue.push(
|
||||
models.Document(
|
||||
depth=1,
|
||||
path=f"{padding}{key}",
|
||||
creator_id=random.choice(users_ids),
|
||||
title=fake.sentence(nb_words=4),
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
else random.choice(models.LinkReachChoices.values),
|
||||
)
|
||||
title = fake.sentence(nb_words=4)
|
||||
document = models.Document(
|
||||
id=uuid4(),
|
||||
depth=1,
|
||||
path=f"{padding}{key}",
|
||||
creator_id=random.choice(users_ids),
|
||||
title=title,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
else random.choice(models.LinkReachChoices.values),
|
||||
)
|
||||
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
|
||||
queue.push(document)
|
||||
|
||||
queue.flush()
|
||||
|
||||
|
||||
@@ -99,6 +99,28 @@ class Base(Configuration):
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Search
|
||||
SEARCH_INDEXER_CLASS = values.Value(
|
||||
default=None,
|
||||
environ_name="SEARCH_INDEXER_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
|
||||
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
|
||||
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_SECRET = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
|
||||
+27
-27
@@ -25,42 +25,42 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"beautifulsoup4==4.13.4",
|
||||
"boto3==1.39.4",
|
||||
"beautifulsoup4==4.14.2",
|
||||
"boto3==1.40.59",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-csp==4.0",
|
||||
"django-filter==25.1",
|
||||
"django-lasuite[all]==0.0.14",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.16",
|
||||
"django-parler==2.3",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.7",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.24.0",
|
||||
"lxml==6.0.0",
|
||||
"markdown==3.8.2",
|
||||
"jsonschema==4.25.1",
|
||||
"lxml==6.0.2",
|
||||
"markdown==3.9",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.95.0",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"pycrdt==0.12.25",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"openai==2.6.1",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"pycrdt==0.12.42",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.4",
|
||||
"sentry-sdk==2.32.0",
|
||||
"whitenoise==6.9.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.42.1",
|
||||
"whitenoise==6.11.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -73,21 +73,21 @@ dependencies = [
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"django-test-migrations==1.5.0",
|
||||
"drf-spectacular-sidecar==2025.7.1",
|
||||
"freezegun==1.5.2",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.4.0",
|
||||
"pyfakefs==5.9.1",
|
||||
"ipython==9.6.0",
|
||||
"pyfakefs==5.10.0",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.7",
|
||||
"pytest-cov==6.2.1",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==8.4.1",
|
||||
"pytest==8.4.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.12.2",
|
||||
"types-requests==2.32.4.20250611",
|
||||
"responses==0.25.8",
|
||||
"ruff==0.14.2",
|
||||
"types-requests==2.32.4.20250913",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
} from './utils-common';
|
||||
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
|
||||
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
|
||||
import {
|
||||
createRootSubPage,
|
||||
getTreeRow,
|
||||
navigateToPageFromTree,
|
||||
} from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -676,10 +680,9 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await calloutBlock.locator('.inline-content').fill('example text');
|
||||
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'yellow',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.bn-block-content[data-content-type="callout"]').first(),
|
||||
).toHaveAttribute('data-background-color', 'yellow');
|
||||
|
||||
const emojiButton = calloutBlock.getByRole('button');
|
||||
await expect(emojiButton).toHaveText('💡');
|
||||
@@ -703,10 +706,9 @@ test.describe('Doc Editor', () => {
|
||||
await page.locator('.mantine-Menu-dropdown > button').last().click();
|
||||
await page.locator('.bn-color-picker-dropdown > button').last().click();
|
||||
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'pink',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.bn-block-content[data-content-type="callout"]').first(),
|
||||
).toHaveAttribute('data-background-color', 'pink');
|
||||
});
|
||||
|
||||
test('it checks interlink feature', async ({ page, browserName }) => {
|
||||
@@ -730,7 +732,13 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
const treeRow = await getTreeRow(page, docChild2);
|
||||
await treeRow.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
|
||||
await navigateToPageFromTree({ page, title: docChild1 });
|
||||
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
@@ -744,6 +752,16 @@ test.describe('Doc Editor', () => {
|
||||
await expect(searchContainer.getByText(docChild1)).toBeVisible();
|
||||
await expect(searchContainer.getByText(docChild2)).toBeVisible();
|
||||
|
||||
const searchContainerRow = searchContainer
|
||||
.getByRole('option')
|
||||
.filter({
|
||||
hasText: docChild2,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(searchContainerRow).toContainText('😀');
|
||||
await expect(searchContainerRow.locator('svg').first()).toBeHidden();
|
||||
|
||||
await input.pressSequentially('-child');
|
||||
|
||||
await expect(searchContainer.getByText(docChild1)).toBeVisible();
|
||||
@@ -758,32 +776,30 @@ test.describe('Doc Editor', () => {
|
||||
await expect(searchContainer).toBeHidden();
|
||||
|
||||
// Wait for the interlink to be created and rendered
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
const editor = await getEditor({ page });
|
||||
|
||||
const interlink = editor.getByRole('button', {
|
||||
const interlinkChild2 = editor.getByRole('button', {
|
||||
name: docChild2,
|
||||
});
|
||||
|
||||
await expect(interlink).toBeVisible({ timeout: 10000 });
|
||||
await interlink.click();
|
||||
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild2).toContainText('😀');
|
||||
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
|
||||
await interlinkChild2.click();
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
});
|
||||
|
||||
test('it checks interlink shortcut @', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = page.locator('.bn-block-outer').last();
|
||||
await editor.click();
|
||||
await page.keyboard.press('@');
|
||||
|
||||
await expect(
|
||||
page.locator(
|
||||
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||
),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('@');
|
||||
await input.fill(docChild1);
|
||||
await searchContainer.getByText(docChild1).click();
|
||||
|
||||
const interlinkChild1 = editor.getByRole('button', {
|
||||
name: docChild1,
|
||||
});
|
||||
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks multiple big doc scroll to the top', async ({
|
||||
@@ -844,10 +860,10 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(pdfBlock).toBeVisible();
|
||||
|
||||
await page.getByText('Add PDF').click();
|
||||
await page.getByText(/Add (PDF|file)/).click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByText('Upload file').click();
|
||||
await page.getByText(/Upload (PDF|file)/).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import pdf from 'pdf-parse';
|
||||
import { pdf } from 'pdf-parse';
|
||||
|
||||
import {
|
||||
TestLanguage,
|
||||
@@ -59,20 +59,16 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
|
||||
await editor.click();
|
||||
await editor.locator('.bn-block-outer').last().fill('Hello');
|
||||
|
||||
const editor = await writeInEditor({ page, text: 'Hello' });
|
||||
await page.keyboard.press('Enter');
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Page Break').click();
|
||||
|
||||
await expect(editor.locator('.bn-page-break')).toBeVisible();
|
||||
await expect(
|
||||
editor.locator('div[data-content-type="pageBreak"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('World');
|
||||
await writeInEditor({ page, text: 'World' });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -92,9 +88,9 @@ test.describe('Doc Export', () => {
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
expect(pdfData.numpages).toBe(2);
|
||||
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
|
||||
expect(pdfData.info.Title).toBe(randomDoc);
|
||||
expect(pdfData.total).toBe(2);
|
||||
expect(pdfData.text).toContain('Hello\n\nWorld\n\n'); // This is the doc text
|
||||
expect(pdfData.info?.Title).toBe(randomDoc);
|
||||
});
|
||||
|
||||
test('it exports the doc to docx', async ({ page, browserName }) => {
|
||||
@@ -274,49 +270,6 @@ test.describe('Doc Export', () => {
|
||||
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
|
||||
});
|
||||
|
||||
/**
|
||||
* We cannot assert the line break is visible in the pdf, but we can assert the
|
||||
* line break is visible in the editor and that the pdf is generated.
|
||||
*/
|
||||
test('it exports the doc with divider', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'export-divider', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello World');
|
||||
|
||||
// Trigger slash menu to show menu
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Add a horizontal line').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="divider"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('doc-open-modal-download-button'),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
expect(pdfData.text).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with multi columns', async ({
|
||||
page,
|
||||
browserName,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { mockedAccesses, mockedInvitations } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -65,25 +65,36 @@ test.describe('Doc Header', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-update', browserName, 1);
|
||||
await createDoc(page, 'doc-update-emoji', browserName, 1);
|
||||
|
||||
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
|
||||
|
||||
// Top parent should not have emoji picker
|
||||
await expect(emojiPicker).toBeHidden();
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-update-emoji-child',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
await expect(emojiPicker).toBeVisible();
|
||||
await emojiPicker.click({
|
||||
delay: 100,
|
||||
});
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
await expect(emojiPicker).toHaveText('😀');
|
||||
|
||||
const docTitle = page.getByRole('textbox', { name: 'Document title' });
|
||||
await expect(docTitle).toBeVisible();
|
||||
await docTitle.fill('👍 Hello Emoji World');
|
||||
await docTitle.fill('Hello Emoji World');
|
||||
await docTitle.blur();
|
||||
await verifyDocName(page, '👍 Hello Emoji World');
|
||||
await verifyDocName(page, 'Hello Emoji World');
|
||||
|
||||
// Check the tree
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
|
||||
await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible();
|
||||
await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden();
|
||||
|
||||
await page.getByTestId('home-button').click();
|
||||
|
||||
// Check the documents grid
|
||||
const gridRow = await getGridRow(page, 'Hello Emoji World');
|
||||
await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible();
|
||||
await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden();
|
||||
const row = await getTreeRow(page, 'Hello Emoji World');
|
||||
await expect(row.getByText('😀')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it deletes the doc', async ({ page, browserName }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
@@ -240,11 +241,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror')
|
||||
.locator('.bn-block-outer')
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Routing', () => {
|
||||
@@ -58,16 +59,23 @@ test.describe('Doc Routing', () => {
|
||||
|
||||
await createRootSubPage(page, browserName, '401-doc-child');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
const responsePromise = page.route(
|
||||
/.*\/documents\/.*\/$|users\/me\/$/,
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
// When we quit a document, a PATCH request is sent to save the document.
|
||||
// We intercept this request to simulate a 401 error from the backend.
|
||||
// The GET request to users/me is also intercepted to simulate the user
|
||||
// being logged out when trying to fetch user info.
|
||||
// This way we can test the 401 error handling when saving the document
|
||||
if (
|
||||
request.method().includes('PATCH') ||
|
||||
request.method().includes('GET')
|
||||
(request.url().includes('/documents/') &&
|
||||
request.method().includes('PATCH')) ||
|
||||
(request.url().includes('/users/me/') &&
|
||||
request.method().includes('GET'))
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { addNewMember } from './utils-share';
|
||||
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
|
||||
import {
|
||||
clickOnAddRootSubPage,
|
||||
createRootSubPage,
|
||||
getTreeRow,
|
||||
} from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Tree', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -298,6 +302,58 @@ test.describe('Doc Tree', () => {
|
||||
// Now test keyboard navigation on sub-document
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the child icon from the tree', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [docParent] = await createDoc(
|
||||
page,
|
||||
'doc-child-emoji',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-child-emoji-child',
|
||||
);
|
||||
|
||||
const row = await getTreeRow(page, docChild);
|
||||
|
||||
// Check Remove emoji is not present initially
|
||||
await row.hover();
|
||||
const menu = row.getByText(`more_horiz`);
|
||||
await menu.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Remove emoji' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Close the menu
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Update the emoji from the tree
|
||||
await row.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
|
||||
// Verify the emoji is updated in the tree and in the document title
|
||||
await expect(row.getByText('😀')).toBeVisible();
|
||||
|
||||
const titleEmojiPicker = page
|
||||
.locator('.--docs--doc-title')
|
||||
.getByRole('button');
|
||||
await expect(titleEmojiPicker).toHaveText('😀');
|
||||
|
||||
// Now remove the emoji using the new action
|
||||
await row.hover();
|
||||
await menu.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
|
||||
|
||||
await expect(row.getByText('😀')).toBeHidden();
|
||||
await expect(titleEmojiPicker).not.toHaveText('😀');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Tree: Inheritance', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
@@ -151,18 +152,15 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror')
|
||||
.locator('.bn-block-outer')
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
const { otherBrowserName, otherPage } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
});
|
||||
const { otherBrowserName, otherPage, cleanup } =
|
||||
await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
});
|
||||
|
||||
await expect(
|
||||
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||
@@ -175,7 +173,11 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await addNewMember(page, 0, 'Reader', otherBrowserName);
|
||||
|
||||
await otherPage.reload();
|
||||
await expect(otherPage.getByText('Hello World')).toBeVisible();
|
||||
await expect(otherPage.getByText('Hello World')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ test.describe('Header: Override configuration', () => {
|
||||
logo: {
|
||||
src: '/assets/logo-gouv.svg',
|
||||
width: '220px',
|
||||
alt: 'Gouvernement Logo',
|
||||
alt: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -168,8 +168,11 @@ test.describe('Header: Override configuration', () => {
|
||||
await page.goto('/');
|
||||
const header = page.locator('header').first();
|
||||
|
||||
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
|
||||
const logoImage = header.getByTestId('header-icon-docs');
|
||||
await expect(logoImage).toBeVisible();
|
||||
|
||||
await expect(header.getByAltText('Docs')).toBeHidden();
|
||||
await expect(logoImage).not.toHaveAttribute('src', '/assets/icon-docs.svg');
|
||||
await expect(logoImage).toHaveAttribute('src', '/assets/logo-gouv.svg');
|
||||
await expect(logoImage).toHaveAttribute('alt', '');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
|
||||
import { openSuggestionMenu } from './utils-editor';
|
||||
|
||||
test.describe('Language', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -51,6 +52,7 @@ test.describe('Language', () => {
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
||||
await expect(languagePicker).toContainText('English');
|
||||
});
|
||||
|
||||
test('can switch language using only keyboard', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLanguageSwitch(page, TestLanguage.English);
|
||||
@@ -106,18 +108,18 @@ test.describe('Language', () => {
|
||||
}) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
// Trigger slash menu to show english menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
|
||||
|
||||
await editor.click(); // close the menu
|
||||
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
|
||||
|
||||
// Change language to French
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Trigger slash menu to show french menu
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await openSuggestionMenu({ page });
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Type definitions for pdf-parse library
|
||||
* The library doesn't export complete type definitions for the parsed PDF data
|
||||
*/
|
||||
|
||||
declare module 'pdf-parse' {
|
||||
export interface PdfInfo {
|
||||
Title?: string;
|
||||
Author?: string;
|
||||
Subject?: string;
|
||||
Keywords?: string;
|
||||
Creator?: string;
|
||||
Producer?: string;
|
||||
CreationDate?: string;
|
||||
ModDate?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PdfData {
|
||||
/** Total number of pages */
|
||||
numpages: number;
|
||||
/** Alias for numpages */
|
||||
total?: number;
|
||||
/** Extracted text content from the PDF */
|
||||
text: string;
|
||||
/** PDF metadata information */
|
||||
info?: PdfInfo;
|
||||
/** PDF metadata (alternative structure) */
|
||||
metadata?: unknown;
|
||||
/** PDF version */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function pdf(buffer: Buffer): Promise<PdfData>;
|
||||
|
||||
export default pdf;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const getEditor = async ({ page }: { page: Page }) => {
|
||||
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
|
||||
const editor = await getEditor({ page });
|
||||
await editor.click();
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await writeInEditor({ page, text: '/' });
|
||||
|
||||
return editor;
|
||||
};
|
||||
@@ -22,6 +22,6 @@ export const writeInEditor = async ({
|
||||
text: string;
|
||||
}) => {
|
||||
const editor = await getEditor({ page });
|
||||
await editor.locator('.bn-block-outer').last().fill(text);
|
||||
await editor.locator('.bn-block-outer .bn-inline-content').last().fill(text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -107,6 +107,20 @@ export const addChild = async ({
|
||||
return name;
|
||||
};
|
||||
|
||||
export const getTreeRow = async (page: Page, title: string) => {
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
const row = docTree
|
||||
.getByRole('treeitem')
|
||||
.filter({
|
||||
hasText: title,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
export const navigateToTopParentFromTree = async ({ page }: { page: Page }) => {
|
||||
await page.getByRole('link', { name: /Open root document/ }).click();
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.55.0",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.5",
|
||||
"eslint-plugin-docs": "*",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"convert-stream": "1.0.2",
|
||||
"pdf-parse": "1.1.1"
|
||||
"pdf-parse": "2.1.7"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -19,46 +19,49 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@blocknote/code-block": "0.37.0",
|
||||
"@blocknote/core": "0.37.0",
|
||||
"@blocknote/mantine": "0.37.0",
|
||||
"@blocknote/react": "0.37.0",
|
||||
"@blocknote/xl-docx-exporter": "0.37.0",
|
||||
"@blocknote/xl-multi-column": "0.37.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.37.0",
|
||||
"@blocknote/code-block": "0.41.1",
|
||||
"@blocknote/core": "0.41.1",
|
||||
"@blocknote/mantine": "0.41.1",
|
||||
"@blocknote/react": "0.41.1",
|
||||
"@blocknote/xl-docx-exporter": "0.41.1",
|
||||
"@blocknote/xl-multi-column": "0.41.1",
|
||||
"@blocknote/xl-pdf-exporter": "0.41.1",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@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.25",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.1",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.2",
|
||||
"@hocuspocus/provider": "3.3.0",
|
||||
"@mantine/core": "8.3.4",
|
||||
"@mantine/hooks": "8.3.4",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@sentry/nextjs": "10.11.0",
|
||||
"@tanstack/react-query": "5.87.4",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.17.0",
|
||||
"@tanstack/react-query": "5.90.2",
|
||||
"@tiptap/extensions": "3.4.4",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.5.0",
|
||||
"docx": "*",
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.5.0",
|
||||
"i18next": "25.5.2",
|
||||
"i18next": "25.5.3",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.7.2",
|
||||
"next": "15.5.3",
|
||||
"posthog-js": "1.264.2",
|
||||
"next": "15.5.4",
|
||||
"posthog-js": "1.271.0",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.12.1",
|
||||
"react-aria-components": "1.13.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.7.3",
|
||||
"react-i18next": "16.0.0",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
@@ -70,9 +73,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.87.4",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.8.0",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "4.17.20",
|
||||
@@ -80,22 +83,22 @@
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "5.0.2",
|
||||
"@vitejs/plugin-react": "5.0.4",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.0.0",
|
||||
"dotenv": "17.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint-plugin-docs": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jsdom": "26.1.0",
|
||||
"jsdom": "27.0.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.6.2",
|
||||
"stylelint": "16.24.0",
|
||||
"stylelint-config-standard": "39.0.0",
|
||||
"stylelint": "16.25.0",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.101.3",
|
||||
"webpack": "5.102.0",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<svg width="18" height="23" viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z" fill="#3A3A3A"/>
|
||||
<svg viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z"
|
||||
fill="#3A3A3A"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,6 +1,6 @@
|
||||
<svg viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3.40918 4.70117C3.28613 4.70117 3.18359 4.66016 3.10156 4.57812C3.02409 4.49609 2.98535 4.39583 2.98535 4.27734C2.98535 4.15885 3.02409 4.06087 3.10156 3.9834C3.18359 3.90137 3.28613 3.86035 3.40918 3.86035H8.59766C8.71615 3.86035 8.81413 3.90137 8.8916 3.9834C8.97363 4.06087 9.01465 4.15885 9.01465 4.27734C9.01465 4.39583 8.97363 4.49609 8.8916 4.57812C8.81413 4.66016 8.71615 4.70117 8.59766 4.70117H3.40918ZM3.40918 7.08691C3.28613 7.08691 3.18359 7.0459 3.10156 6.96387C3.02409 6.88184 2.98535 6.78158 2.98535 6.66309C2.98535 6.5446 3.02409 6.44661 3.10156 6.36914C3.18359 6.28711 3.28613 6.24609 3.40918 6.24609H8.59766C8.71615 6.24609 8.81413 6.28711 8.8916 6.36914C8.97363 6.44661 9.01465 6.5446 9.01465 6.66309C9.01465 6.78158 8.97363 6.88184 8.8916 6.96387C8.81413 7.0459 8.71615 7.08691 8.59766 7.08691H3.40918ZM3.40918 9.47266C3.28613 9.47266 3.18359 9.43392 3.10156 9.35645C3.02409 9.27441 2.98535 9.17643 2.98535 9.0625C2.98535 8.93945 3.02409 8.83691 3.10156 8.75488C3.18359 8.67285 3.28613 8.63184 3.40918 8.63184H5.86328C5.98633 8.63184 6.08659 8.67285 6.16406 8.75488C6.24609 8.83691 6.28711 8.93945 6.28711 9.0625C6.28711 9.17643 6.24609 9.27441 6.16406 9.35645C6.08659 9.43392 5.98633 9.47266 5.86328 9.47266H3.40918ZM0.250977 13.2598V2.88965C0.250977 2.17871 0.426432 1.64323 0.777344 1.2832C1.13281 0.923177 1.66374 0.743164 2.37012 0.743164H9.62988C10.3363 0.743164 10.8649 0.923177 11.2158 1.2832C11.5713 1.64323 11.749 2.17871 11.749 2.88965V13.2598C11.749 13.9753 11.5713 14.5107 11.2158 14.8662C10.8649 15.2217 10.3363 15.3994 9.62988 15.3994H2.37012C1.66374 15.3994 1.13281 15.2217 0.777344 14.8662C0.426432 14.5107 0.250977 13.9753 0.250977 13.2598ZM1.35156 13.2393C1.35156 13.5811 1.44043 13.8431 1.61816 14.0254C1.80046 14.2077 2.06934 14.2988 2.4248 14.2988H9.5752C9.93066 14.2988 10.1973 14.2077 10.375 14.0254C10.5573 13.8431 10.6484 13.5811 10.6484 13.2393V2.91016C10.6484 2.56836 10.5573 2.30632 10.375 2.12402C10.1973 1.93717 9.93066 1.84375 9.5752 1.84375H2.4248C2.06934 1.84375 1.80046 1.93717 1.61816 2.12402C1.44043 2.30632 1.35156 2.56836 1.35156 2.91016V13.2393Z"
|
||||
fill="#8585F6"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
+13
-10
@@ -1,6 +1,7 @@
|
||||
import { codeBlock } from '@blocknote/code-block';
|
||||
import { codeBlockOptions } from '@blocknote/code-block';
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
createCodeBlockSpec,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
withPageBreak,
|
||||
@@ -16,7 +17,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
|
||||
import {
|
||||
Doc,
|
||||
useIsCollaborativeEditable,
|
||||
useProviderStore,
|
||||
} from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
import {
|
||||
@@ -36,7 +41,6 @@ import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import {
|
||||
AccessibleImageBlock,
|
||||
CalloutBlock,
|
||||
DividerBlock,
|
||||
PdfBlock,
|
||||
UploadLoaderBlock,
|
||||
} from './custom-blocks';
|
||||
@@ -53,11 +57,11 @@ const baseBlockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
callout: CalloutBlock,
|
||||
divider: DividerBlock,
|
||||
image: AccessibleImageBlock,
|
||||
pdf: PdfBlock,
|
||||
uploadLoader: UploadLoaderBlock,
|
||||
callout: CalloutBlock(),
|
||||
codeBlock: createCodeBlockSpec(codeBlockOptions),
|
||||
image: AccessibleImageBlock(),
|
||||
pdf: PdfBlock(),
|
||||
uploadLoader: UploadLoaderBlock(),
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
@@ -79,9 +83,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const { t } = useTranslation();
|
||||
const { isSynced: isConnectedToCollabServer } = useProviderStore();
|
||||
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const isConnectedToCollabServer = provider.isSynced;
|
||||
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
|
||||
const isDeletedDoc = !!doc.deleted_at;
|
||||
|
||||
@@ -98,7 +102,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
|
||||
const editor: DocsBlockNoteEditor = useCreateBlockNote(
|
||||
{
|
||||
codeBlock,
|
||||
collaboration: {
|
||||
provider,
|
||||
fragment: provider.document.getXmlFragment('document-store'),
|
||||
|
||||
+18
-18
@@ -1,5 +1,6 @@
|
||||
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
|
||||
import {
|
||||
DefaultReactSuggestionItem,
|
||||
SuggestionMenuController,
|
||||
getDefaultReactSlashMenuItems,
|
||||
getPageBreakReactSlashMenuItems,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
|
||||
import {
|
||||
getCalloutReactSlashMenuItems,
|
||||
getDividerReactSlashMenuItems,
|
||||
getPdfReactSlashMenuItems,
|
||||
} from './custom-blocks';
|
||||
import { useGetInterlinkingMenuItems } from './custom-inline-content';
|
||||
@@ -42,29 +42,29 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
const getSlashMenuItems = useMemo(() => {
|
||||
// We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks
|
||||
const defaultMenu = getDefaultReactSlashMenuItems(editor);
|
||||
const index = defaultMenu.findIndex(
|
||||
(item) => item.aliases?.includes('code') && item.aliases?.includes('pre'),
|
||||
|
||||
const combinedMenu = combineByGroup(
|
||||
defaultMenu,
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getMultiColumnSlashMenuItems?.(editor) || [],
|
||||
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
);
|
||||
|
||||
const index = combinedMenu.findIndex(
|
||||
(item) =>
|
||||
(item as DefaultReactSuggestionItem & { key: string })?.key ===
|
||||
'callout',
|
||||
);
|
||||
|
||||
const newSlashMenuItems = [
|
||||
...defaultMenu.slice(0, index + 1),
|
||||
...combinedMenu.slice(0, index + 1),
|
||||
...getInterlinkingMenuItems(editor, t),
|
||||
...defaultMenu.slice(index + 1),
|
||||
...combinedMenu.slice(index + 1),
|
||||
];
|
||||
|
||||
return async (query: string) =>
|
||||
Promise.resolve(
|
||||
filterSuggestionItems(
|
||||
combineByGroup(
|
||||
newSlashMenuItems,
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getMultiColumnSlashMenuItems?.(editor) || [],
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
|
||||
),
|
||||
query,
|
||||
),
|
||||
);
|
||||
Promise.resolve(filterSuggestionItems(newSlashMenuItems, query));
|
||||
}, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]);
|
||||
|
||||
return (
|
||||
|
||||
+16
-14
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* We added some custom logic to the original Blocknote FileDownloadButton
|
||||
* component to handle our file download use case.
|
||||
*
|
||||
* Original source:
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
checkBlockIsFileBlock,
|
||||
checkBlockIsFileBlockWithPlaceholder,
|
||||
blockHasType,
|
||||
} from '@blocknote/core';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
@@ -41,7 +48,9 @@ export const FileDownloadButton = ({
|
||||
|
||||
const block = selectedBlocks[0];
|
||||
|
||||
if (checkBlockIsFileBlock(block, editor)) {
|
||||
if (
|
||||
blockHasType(block, editor, block.type, { url: 'string', name: 'string' })
|
||||
) {
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -53,6 +62,7 @@ export const FileDownloadButton = ({
|
||||
editor.focus();
|
||||
|
||||
const url = fileBlock.props.url as string;
|
||||
const name = fileBlock.props.name as string | undefined;
|
||||
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
@@ -76,16 +86,12 @@ export const FileDownloadButton = ({
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(
|
||||
blob,
|
||||
fileBlock.props.name || url.split('/').pop() || 'file',
|
||||
);
|
||||
downloadFile(blob, name || url.split('/').pop() || 'file');
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
|
||||
const baseName =
|
||||
fileBlock.props.name || url.split('/').pop() || 'file';
|
||||
const baseName = name || url.split('/').pop() || 'file';
|
||||
|
||||
const regFindLastDot = /(\.[^/.]+)$/;
|
||||
const unsafeName = baseName.includes('.')
|
||||
@@ -100,11 +106,7 @@ export const FileDownloadButton = ({
|
||||
}
|
||||
}, [editor, fileBlock, open]);
|
||||
|
||||
if (
|
||||
!fileBlock ||
|
||||
checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
|
||||
!Components
|
||||
) {
|
||||
if (!fileBlock || fileBlock.props.url === '' || !Components) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { Box, Loading, Text, TextErrors } from '@/components';
|
||||
import { DocHeader, DocVersionHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/docs/doc-management';
|
||||
import { TableContent } from '@/docs/doc-table-content/';
|
||||
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
|
||||
@@ -25,10 +26,18 @@ interface DocEditorProps {
|
||||
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const isVersion = !!versionId && typeof versionId === 'string';
|
||||
const { provider } = useProviderStore();
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
useEffect(() => {
|
||||
if (isProviderReady) {
|
||||
setIsSkeletonVisible(false);
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
|
||||
if (!isProviderReady) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,16 +9,18 @@ interface EmojiPickerProps {
|
||||
emojiData: EmojiMartData;
|
||||
onClickOutside: () => void;
|
||||
onEmojiSelect: ({ native }: { native: string }) => void;
|
||||
withOverlay?: boolean;
|
||||
}
|
||||
|
||||
export const EmojiPicker = ({
|
||||
emojiData,
|
||||
onClickOutside,
|
||||
onEmojiSelect,
|
||||
withOverlay = false,
|
||||
}: EmojiPickerProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
const pickerContent = (
|
||||
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
|
||||
<Picker
|
||||
data={emojiData}
|
||||
@@ -30,4 +32,27 @@ export const EmojiPicker = ({
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (withOverlay) {
|
||||
return (
|
||||
<>
|
||||
{/* Overlay transparent pour fermer en cliquant à l'extérieur */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 999,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
onClick={onClickOutside}
|
||||
/>
|
||||
{pickerContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return pickerContent;
|
||||
};
|
||||
|
||||
+74
-51
@@ -1,43 +1,62 @@
|
||||
/**
|
||||
* AccessibleImageBlock.tsx
|
||||
*
|
||||
* This file defines a custom BlockNote block specification for an accessible image block.
|
||||
* It extends the default image block to ensure compliance with accessibility standards,
|
||||
* specifically RGAA 1.9.1, by using <figure> and <figcaption> elements when a caption is provided.
|
||||
*
|
||||
* The accessible image block ensures that:
|
||||
* - Images with captions are wrapped in <figure> and <figcaption> elements.
|
||||
* - The <img> element has an appropriate alt attribute based on the caption.
|
||||
* - Accessibility attributes such as role and aria-label are added for better screen reader support.
|
||||
* - Images without captions have alt="" and are marked as decorative with aria-hidden="true".
|
||||
*
|
||||
* This implementation leverages BlockNote's existing image block functionality while enhancing it for accessibility.
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/blocks/Image/block.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockFromConfig,
|
||||
BlockNoteEditor,
|
||||
BlockSchemaWithBlock,
|
||||
ImageOptions,
|
||||
InlineContentSchema,
|
||||
InlineContentSchemaFromSpecs,
|
||||
StyleSchema,
|
||||
createBlockSpec,
|
||||
imageBlockConfig,
|
||||
createImageBlockConfig,
|
||||
defaultInlineContentSpecs,
|
||||
imageParse,
|
||||
imageRender,
|
||||
imageToExternalHTML,
|
||||
} from '@blocknote/core';
|
||||
import { t } from 'i18next';
|
||||
|
||||
type ImageBlockConfig = typeof imageBlockConfig;
|
||||
type CreateImageBlockConfig = ReturnType<typeof createImageBlockConfig>;
|
||||
|
||||
export const accessibleImageRender = (
|
||||
block: BlockFromConfig<ImageBlockConfig, InlineContentSchema, StyleSchema>,
|
||||
editor: BlockNoteEditor<
|
||||
BlockSchemaWithBlock<ImageBlockConfig['type'], ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>,
|
||||
) => {
|
||||
const imageRenderComputed = imageRender(block, editor);
|
||||
const dom = imageRenderComputed.dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
export const accessibleImageRender =
|
||||
(config: ImageOptions) =>
|
||||
(
|
||||
block: BlockFromConfig<
|
||||
CreateImageBlockConfig,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>,
|
||||
editor: BlockNoteEditor<
|
||||
Record<'image', CreateImageBlockConfig>,
|
||||
InlineContentSchemaFromSpecs<typeof defaultInlineContentSpecs>,
|
||||
StyleSchema
|
||||
>,
|
||||
) => {
|
||||
const imageRenderComputed = imageRender(config);
|
||||
const dom = imageRenderComputed(block, editor).dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
|
||||
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
|
||||
const captionElement = dom.querySelector('.bn-file-caption');
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
|
||||
if (captionElement) {
|
||||
const figureElement = document.createElement('figure');
|
||||
|
||||
// Copy all attributes from the original div
|
||||
@@ -76,32 +95,36 @@ export const accessibleImageRender = (
|
||||
...imageRenderComputed,
|
||||
dom: figureElement,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
return withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
};
|
||||
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
};
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
const result = withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
|
||||
// Return the result if accessibleImageWithCaption created a figure, otherwise return original
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
|
||||
export const AccessibleImageBlock = createBlockSpec(imageBlockConfig, {
|
||||
render: accessibleImageRender,
|
||||
parse: imageParse,
|
||||
toExternalHTML: imageToExternalHTML,
|
||||
});
|
||||
export const AccessibleImageBlock = createBlockSpec(
|
||||
createImageBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['image/*'],
|
||||
},
|
||||
render: accessibleImageRender(config),
|
||||
parse: imageParse(config),
|
||||
toExternalHTML: imageToExternalHTML(config),
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
|
||||
+126
-77
@@ -1,9 +1,16 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import {
|
||||
BlockConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
defaultProps,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, Icon } from '@/components';
|
||||
|
||||
@@ -12,90 +19,131 @@ import { EmojiPicker } from '../EmojiPicker';
|
||||
|
||||
import emojidata from './initEmojiCallout';
|
||||
|
||||
const CalloutBlockStyle = createGlobalStyle`
|
||||
.bn-block-content[data-content-type="callout"][data-background-color] {
|
||||
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
`;
|
||||
|
||||
type CreateCalloutBlockConfig = BlockConfig<
|
||||
'callout',
|
||||
{
|
||||
textAlignment: typeof defaultProps.textAlignment;
|
||||
backgroundColor: typeof defaultProps.backgroundColor;
|
||||
emoji: { default: '💡' };
|
||||
},
|
||||
'inline'
|
||||
>;
|
||||
|
||||
interface CalloutComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'callout', CreateCalloutBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'callout', CreateCalloutBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const CalloutComponent = ({
|
||||
block,
|
||||
editor,
|
||||
contentRef,
|
||||
}: CalloutComponentProps) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
};
|
||||
|
||||
const onClickOutside = () => setOpenEmojiPicker(false);
|
||||
|
||||
const onEmojiSelect = ({ native }: { native: string }) => {
|
||||
editor.updateBlock(block, { props: { emoji: native } });
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
// Temporary: sets a yellow background color to a callout block when added by
|
||||
// the user, while keeping the colors menu on the drag handler usable for
|
||||
// this custom block.
|
||||
useEffect(() => {
|
||||
if (!block.content.length && block.props.backgroundColor === 'default') {
|
||||
// Delay the update to avoid interfering with the block insertion process
|
||||
setTimeout(() => {
|
||||
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
|
||||
}, 0);
|
||||
}
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="1rem"
|
||||
$gap="0.625rem"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
flex-grow: 1;
|
||||
`}
|
||||
>
|
||||
<CalloutBlockStyle />
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
withOverlay={true}
|
||||
/>
|
||||
)}
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const CalloutBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'callout',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
backgroundColor: defaultProps.backgroundColor,
|
||||
backgroundColor: { default: 'default' as const },
|
||||
emoji: { default: '💡' },
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: ({ block, editor, contentRef }) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
};
|
||||
|
||||
const onClickOutside = () => setOpenEmojiPicker(false);
|
||||
|
||||
const onEmojiSelect = ({ native }: { native: string }) => {
|
||||
editor.updateBlock(block, { props: { emoji: native } });
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
// Temporary: sets a yellow background color to a callout block when added by
|
||||
// the user, while keeping the colors menu on the drag handler usable for
|
||||
// this custom block.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!block.content.length &&
|
||||
block.props.backgroundColor === 'default'
|
||||
) {
|
||||
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
|
||||
}
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="1rem"
|
||||
$gap="0.625rem"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$height="28px"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
)}
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
render: ({ block, editor, contentRef }) => (
|
||||
<CalloutComponent block={block} editor={editor} contentRef={contentRef} />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -105,6 +153,7 @@ export const getCalloutReactSlashMenuItems = (
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
key: 'callout',
|
||||
title: t('Callout'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
export const DividerBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'divider',
|
||||
propSchema: {},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="hr"
|
||||
$width="100%"
|
||||
$background={colorsTokens['greyscale-300']}
|
||||
$margin="1rem 0"
|
||||
$css={`border: 1px solid ${colorsTokens['greyscale-300']};`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getDividerReactSlashMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
title: t('Divider'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'divider',
|
||||
});
|
||||
},
|
||||
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
|
||||
group,
|
||||
icon: <Icon iconName="remove" $size="18px" />,
|
||||
subtext: t('Add a horizontal line'),
|
||||
},
|
||||
];
|
||||
+98
-41
@@ -1,11 +1,19 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import {
|
||||
BlockConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
import * as locales from '@blocknote/core/locales';
|
||||
import {
|
||||
AddFileButton,
|
||||
ResizableFileBlockWrapper,
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
@@ -20,57 +28,106 @@ const PDFBlockStyle = createGlobalStyle`
|
||||
`;
|
||||
|
||||
type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor'];
|
||||
type FileBlockBlock = Parameters<typeof AddFileButton>[0]['block'];
|
||||
|
||||
type CreatePDFBlockConfig = BlockConfig<
|
||||
'pdf',
|
||||
{
|
||||
backgroundColor: { default: 'default' };
|
||||
caption: { default: '' };
|
||||
name: { default: '' };
|
||||
previewWidth: { default: undefined; type: 'number' };
|
||||
showPreview: { default: true };
|
||||
textAlignment: { default: 'left' };
|
||||
url: { default: '' };
|
||||
},
|
||||
'none'
|
||||
>;
|
||||
|
||||
interface PdfBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'callout', CreatePDFBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'pdf', CreatePDFBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const PdfBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
contentRef,
|
||||
}: PdfBlockComponentProps) => {
|
||||
const pdfUrl = block.props.url;
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
|
||||
useEffect(() => {
|
||||
if (lang && locales[lang as keyof typeof locales]) {
|
||||
locales[lang as keyof typeof locales].file_blocks.add_button_text['pdf'] =
|
||||
t('Add PDF');
|
||||
(
|
||||
locales[lang as keyof typeof locales].file_panel.embed
|
||||
.embed_button as Record<string, string>
|
||||
)['pdf'] = t('Add PDF');
|
||||
(
|
||||
locales[lang as keyof typeof locales].file_panel.upload
|
||||
.file_placeholder as Record<string, string>
|
||||
)['pdf'] = t('Upload PDF');
|
||||
}
|
||||
}, [lang, t]);
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={
|
||||
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
block={block as unknown as FileBlockBlock}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
>
|
||||
<Box
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
as="embed"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
</ResizableFileBlockWrapper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const PdfBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'pdf',
|
||||
content: 'none',
|
||||
propSchema: {
|
||||
name: { default: '' as const },
|
||||
url: { default: '' as const },
|
||||
backgroundColor: { default: 'default' as const },
|
||||
caption: { default: '' as const },
|
||||
showPreview: { default: true },
|
||||
name: { default: '' as const },
|
||||
previewWidth: { default: undefined, type: 'number' },
|
||||
showPreview: { default: true },
|
||||
textAlignment: { default: 'left' as const },
|
||||
url: { default: '' as const },
|
||||
},
|
||||
isFileBlock: true,
|
||||
fileBlockAccept: ['application/pdf'],
|
||||
},
|
||||
{
|
||||
render: ({ editor, block, contentRef }) => {
|
||||
const { t } = useTranslation();
|
||||
const pdfUrl = block.props.url;
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={
|
||||
<Icon
|
||||
iconName="upload"
|
||||
$size="24px"
|
||||
$css="line-height: normal;"
|
||||
/>
|
||||
}
|
||||
block={block}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
buttonText={t('Add PDF')}
|
||||
>
|
||||
<Box
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
as="embed"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
</ResizableFileBlockWrapper>
|
||||
</Box>
|
||||
);
|
||||
meta: {
|
||||
fileBlockAccept: ['application/pdf'],
|
||||
},
|
||||
render: (props) => <PdfBlockComponent {...props} />,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
export * from './AccessibleImageBlock';
|
||||
export * from './CalloutBlock';
|
||||
export * from './DividerBlock';
|
||||
export { default as emojidata } from './initEmojiCallout';
|
||||
export * from './PdfBlock';
|
||||
export * from './UploadLoaderBlock';
|
||||
|
||||
+18
-4
@@ -7,7 +7,7 @@ import { css } from 'styled-components';
|
||||
import { BoxButton, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
|
||||
import { useDoc } from '@/docs/doc-management';
|
||||
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';
|
||||
|
||||
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
{
|
||||
@@ -52,6 +52,8 @@ interface LinkSelectedProps {
|
||||
}
|
||||
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -78,9 +80,21 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
`}
|
||||
>
|
||||
<SelectedPageIcon width={11.5} />
|
||||
<Text $weight="500" spellCheck="false" $size="16px" $display="inline">
|
||||
{title}
|
||||
{emoji ? (
|
||||
<Text $size="16px">{emoji}</Text>
|
||||
) : (
|
||||
<SelectedPageIcon width={11.5} color={colorsTokens['primary-400']} />
|
||||
)}
|
||||
<Text
|
||||
$weight="500"
|
||||
spellCheck="false"
|
||||
$size="16px"
|
||||
$display="inline"
|
||||
$css={css`
|
||||
margin-left: 2px;
|
||||
`}
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
);
|
||||
|
||||
+2
@@ -47,6 +47,7 @@ export const getInterlinkinghMenuItems = (
|
||||
createPage: () => void,
|
||||
) => [
|
||||
{
|
||||
key: 'link-doc',
|
||||
title: t('Link a doc'),
|
||||
onItemClick: () => {
|
||||
editor.insertInlineContent([
|
||||
@@ -65,6 +66,7 @@ export const getInterlinkinghMenuItems = (
|
||||
subtext: t('Link this doc to another doc'),
|
||||
},
|
||||
{
|
||||
key: 'new-sub-doc',
|
||||
title: t('New sub-doc'),
|
||||
onItemClick: createPage,
|
||||
aliases: ['new sub-doc'],
|
||||
|
||||
+56
-32
@@ -25,6 +25,7 @@ import {
|
||||
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
|
||||
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
|
||||
import {
|
||||
getEmojiAndTitle,
|
||||
useCreateChildDocTree,
|
||||
useDocStore,
|
||||
useTrans,
|
||||
@@ -43,17 +44,19 @@ const inputStyle = css`
|
||||
`;
|
||||
|
||||
type SearchPageProps = {
|
||||
trigger: string;
|
||||
trigger: '/' | '@';
|
||||
updateInlineContent: (
|
||||
update: PartialCustomInlineContentFromConfig<
|
||||
{
|
||||
type: string;
|
||||
type: 'interlinkingSearchInline';
|
||||
propSchema: {
|
||||
disabled: {
|
||||
default: boolean;
|
||||
default: false;
|
||||
values: [true, false];
|
||||
};
|
||||
trigger: {
|
||||
default: string;
|
||||
default: '/';
|
||||
values: ['/', '@'];
|
||||
};
|
||||
};
|
||||
content: 'styled';
|
||||
@@ -234,35 +237,56 @@ export const SearchPage = ({
|
||||
|
||||
editor.focus();
|
||||
}}
|
||||
renderElement={(doc) => (
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.6rem"
|
||||
$align="center"
|
||||
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
|
||||
$width="100%"
|
||||
>
|
||||
<FoundPageIcon />
|
||||
<Text
|
||||
$size="14px"
|
||||
$color="var(--c--theme--colors--greyscale-1000)"
|
||||
spellCheck="false"
|
||||
renderElement={(doc) => {
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
|
||||
doc.title || untitledDocument,
|
||||
);
|
||||
|
||||
return (
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.2rem"
|
||||
$align="center"
|
||||
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
|
||||
$width="100%"
|
||||
>
|
||||
{doc.title}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<Icon
|
||||
iconName="keyboard_return"
|
||||
$variation="600"
|
||||
spellCheck="false"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
$css={css`
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
{emoji ? (
|
||||
<Text $size="18px">{emoji}</Text>
|
||||
) : (
|
||||
<FoundPageIcon
|
||||
width="100%"
|
||||
style={{ maxHeight: '24px' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
$size="14px"
|
||||
$color="var(--c--theme--colors--greyscale-1000)"
|
||||
spellCheck="false"
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<Icon
|
||||
iconName="keyboard_return"
|
||||
$variation="600"
|
||||
spellCheck="false"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<QuickSearchGroup
|
||||
group={{
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './DocEditor';
|
||||
export * from './EmojiPicker';
|
||||
export * from './custom-blocks/';
|
||||
|
||||
@@ -16,6 +16,9 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure long placeholder text is truncated with ellipsis
|
||||
*/
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
text-overflow: ellipsis;
|
||||
@@ -29,14 +32,16 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: #767676 !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure images with unsafe URLs are not interactive
|
||||
*/
|
||||
img.bn-visual-media[src*='-unsafe'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaboration cursor styles
|
||||
*/
|
||||
.collaboration-cursor-custom__base {
|
||||
position: relative;
|
||||
}
|
||||
@@ -87,6 +92,9 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
.bn-side-menu[data-block-type='divider'] {
|
||||
height: 38px;
|
||||
}
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: #767676 !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callout, Paragraph and Heading blocks
|
||||
@@ -94,21 +102,17 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
.bn-block {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block[data-background-color] > .bn-block-content {
|
||||
.bn-block > .bn-block-content[data-background-color] {
|
||||
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
|
||||
.bn-inline-content {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
@@ -146,6 +150,16 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
border-left: 4px solid var(--c--theme--colors--greyscale-300);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider
|
||||
*/
|
||||
[data-content-type='divider'] hr {
|
||||
background: #d3d2cf;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid #d3d2cf;
|
||||
}
|
||||
}
|
||||
|
||||
& .bn-block-outer:not(:first-child) {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Paragraph } from 'docx';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsExporterDocx } from '../types';
|
||||
|
||||
export const blockMappingDividerDocx: DocsExporterDocx['mappings']['blockMapping']['divider'] =
|
||||
() => {
|
||||
const { colorsTokens } = useCunninghamTheme.getState();
|
||||
|
||||
return new Paragraph({
|
||||
spacing: {
|
||||
before: 200,
|
||||
},
|
||||
border: {
|
||||
top: {
|
||||
color: colorsTokens['greyscale-300'],
|
||||
size: 1,
|
||||
style: 'single',
|
||||
space: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Text } from '@react-pdf/renderer';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
export const blockMappingDividerPDF: DocsExporterPDF['mappings']['blockMapping']['divider'] =
|
||||
() => {
|
||||
const { colorsTokens } = useCunninghamTheme.getState();
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
marginVertical: 10,
|
||||
backgroundColor: colorsTokens['greyscale-300'],
|
||||
height: '2px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -100,16 +100,13 @@ function blockPropsToStyles(
|
||||
? undefined
|
||||
: {
|
||||
type: ShadingType.SOLID,
|
||||
color:
|
||||
colors[
|
||||
props.backgroundColor as keyof typeof colors
|
||||
].background.slice(1),
|
||||
color: colors[props.backgroundColor].background.slice(1),
|
||||
},
|
||||
run:
|
||||
props.textColor === 'default' || !props.textColor
|
||||
? undefined
|
||||
: {
|
||||
color: colors[props.textColor as keyof typeof colors].text.slice(1),
|
||||
color: colors[props.textColor].text.slice(1),
|
||||
},
|
||||
alignment:
|
||||
!props.textAlignment || props.textAlignment === 'left'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export * from './calloutDocx';
|
||||
export * from './calloutPDF';
|
||||
export * from './dividerDocx';
|
||||
export * from './dividerPDF';
|
||||
export * from './headingPDF';
|
||||
export * from './imageDocx';
|
||||
export * from './imagePDF';
|
||||
|
||||
@@ -92,15 +92,11 @@ export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['
|
||||
color:
|
||||
cellProps.textColor === 'default'
|
||||
? undefined
|
||||
: options.colors[
|
||||
cellProps.textColor as keyof typeof options.colors
|
||||
].text,
|
||||
: options.colors[cellProps.textColor].text,
|
||||
backgroundColor:
|
||||
cellProps.backgroundColor === 'default'
|
||||
? undefined
|
||||
: options.colors[
|
||||
cellProps.backgroundColor as keyof typeof options.colors
|
||||
].background,
|
||||
: options.colors[cellProps.backgroundColor].background,
|
||||
textAlign: cellProps.textAlignment,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
|
||||
import { Paragraph } from 'docx';
|
||||
import { TextRun } from 'docx';
|
||||
|
||||
import {
|
||||
blockMappingCalloutDocx,
|
||||
blockMappingDividerDocx,
|
||||
blockMappingImageDocx,
|
||||
blockMappingQuoteDocx,
|
||||
blockMappingUploadLoaderDocx,
|
||||
@@ -16,9 +15,8 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
|
||||
blockMapping: {
|
||||
...docxDefaultSchemaMappings.blockMapping,
|
||||
callout: blockMappingCalloutDocx,
|
||||
divider: blockMappingDividerDocx,
|
||||
// We're using the file block mapping for PDF blocks
|
||||
// The types don't match exactly but the implementation is compatible
|
||||
// We're reusing the file block mapping for PDF blocks; both share the same
|
||||
// implementation signature, so we can reuse the handler directly.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
|
||||
quote: blockMappingQuoteDocx,
|
||||
@@ -27,7 +25,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
|
||||
},
|
||||
inlineContentMapping: {
|
||||
...docxDefaultSchemaMappings.inlineContentMapping,
|
||||
interlinkingSearchInline: () => new Paragraph(''),
|
||||
interlinkingSearchInline: () => new TextRun(''),
|
||||
interlinkingLinkInline: inlineContentMappingInterlinkingLinkDocx,
|
||||
},
|
||||
styleMapping: {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
|
||||
|
||||
import {
|
||||
blockMappingCalloutPDF,
|
||||
blockMappingDividerPDF,
|
||||
blockMappingHeadingPDF,
|
||||
blockMappingImagePDF,
|
||||
blockMappingParagraphPDF,
|
||||
@@ -21,7 +20,6 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
|
||||
heading: blockMappingHeadingPDF,
|
||||
image: blockMappingImagePDF,
|
||||
paragraph: blockMappingParagraphPDF,
|
||||
divider: blockMappingDividerPDF,
|
||||
quote: blockMappingQuotePDF,
|
||||
table: blockMappingTablePDF,
|
||||
// We're using the file block mapping for PDF blocks
|
||||
|
||||
@@ -76,16 +76,13 @@ export function docxBlockPropsToStyles(
|
||||
? undefined
|
||||
: {
|
||||
type: ShadingType.SOLID,
|
||||
color:
|
||||
colors[
|
||||
props.backgroundColor as keyof typeof colors
|
||||
].background.slice(1),
|
||||
color: colors[props.backgroundColor].background.slice(1),
|
||||
},
|
||||
run:
|
||||
props.textColor === 'default' || !props.textColor
|
||||
? undefined
|
||||
: {
|
||||
color: colors[props.textColor as keyof typeof colors].text.slice(1),
|
||||
color: colors[props.textColor].text.slice(1),
|
||||
},
|
||||
alignment:
|
||||
!props.textAlignment || props.textAlignment === 'left'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { Tooltip } from '@openfun/cunningham-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -8,14 +7,16 @@ import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
DocIcon,
|
||||
getEmojiAndTitle,
|
||||
useDocStore,
|
||||
useDocTitleUpdate,
|
||||
useDocUtils,
|
||||
useIsCollaborativeEditable,
|
||||
useTrans,
|
||||
useUpdateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { useBroadcastStore, useResponsiveStore } from '@/stores';
|
||||
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
interface DocTitleProps {
|
||||
doc: Doc;
|
||||
@@ -49,52 +50,77 @@ export const DocTitleText = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
|
||||
return (
|
||||
<Tooltip content={t('Document emoji')} aria-hidden={true} placement="top">
|
||||
<Box
|
||||
$css={css`
|
||||
padding: 4px;
|
||||
padding-top: 3px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
border-radius: 4px;
|
||||
}
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
`}
|
||||
>
|
||||
<DocIcon
|
||||
withEmojiPicker={doc.abilities.partial_update}
|
||||
docId={doc.id}
|
||||
title={doc.title}
|
||||
emoji={emoji}
|
||||
$size="25px"
|
||||
defaultIcon={
|
||||
<SimpleFileIcon
|
||||
width="25px"
|
||||
height="25px"
|
||||
aria-hidden="true"
|
||||
aria-label={t('Simple document icon')}
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { isTopRoot } = useDocUtils(doc);
|
||||
const { untitledDocument } = useTrans();
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
const [titleDisplay, setTitleDisplay] = useState(
|
||||
isTopRoot ? doc.title : titleWithoutEmoji,
|
||||
);
|
||||
|
||||
const { broadcast } = useBroadcastStore();
|
||||
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess(updatedDoc) {
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
|
||||
|
||||
if (!treeContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (treeContext.root?.id === updatedDoc.id) {
|
||||
treeContext?.setRoot(updatedDoc);
|
||||
} else {
|
||||
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
|
||||
}
|
||||
},
|
||||
});
|
||||
const { updateDocTitle } = useDocTitleUpdate();
|
||||
|
||||
const handleTitleSubmit = useCallback(
|
||||
(inputText: string) => {
|
||||
let sanitizedTitle = inputText.trim();
|
||||
sanitizedTitle = sanitizedTitle.replace(/(\r\n|\n|\r)/gm, '');
|
||||
|
||||
// When blank we set to untitled
|
||||
if (!sanitizedTitle) {
|
||||
setTitleDisplay('');
|
||||
}
|
||||
|
||||
// If mutation we update
|
||||
if (sanitizedTitle !== doc.title) {
|
||||
if (isTopRoot) {
|
||||
const sanitizedTitle = updateDocTitle(doc, inputText);
|
||||
setTitleDisplay(sanitizedTitle);
|
||||
updateDoc({ id: doc.id, title: sanitizedTitle });
|
||||
} else {
|
||||
const sanitizedTitle = updateDocTitle(
|
||||
doc,
|
||||
emoji ? `${emoji} ${inputText}` : inputText,
|
||||
);
|
||||
const { titleWithoutEmoji: sanitizedTitleWithoutEmoji } =
|
||||
getEmojiAndTitle(sanitizedTitle);
|
||||
|
||||
setTitleDisplay(sanitizedTitleWithoutEmoji);
|
||||
}
|
||||
},
|
||||
[doc.id, doc.title, updateDoc],
|
||||
[updateDocTitle, doc, emoji, isTopRoot],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -105,43 +131,62 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTitleDisplay(doc.title);
|
||||
}, [doc]);
|
||||
setTitleDisplay(isTopRoot ? doc.title : titleWithoutEmoji);
|
||||
}, [doc.title, isTopRoot, titleWithoutEmoji]);
|
||||
|
||||
return (
|
||||
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
|
||||
<Box
|
||||
as="span"
|
||||
role="textbox"
|
||||
className="--docs--doc-title-input"
|
||||
contentEditable
|
||||
defaultValue={titleDisplay || undefined}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
aria-label={`${t('Document title')}`}
|
||||
aria-multiline={false}
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
$color={colorsTokens['greyscale-1000']}
|
||||
$minHeight="40px"
|
||||
$padding={{ right: 'big' }}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
content: '${untitledDocument}';
|
||||
color: grey;
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
<Box
|
||||
className="--docs--doc-title"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacingsTokens['xs']}
|
||||
$minHeight="40px"
|
||||
>
|
||||
{isTopRoot && (
|
||||
<SimpleFileIcon
|
||||
width="25px"
|
||||
height="25px"
|
||||
aria-hidden="true"
|
||||
aria-label={t('Simple document icon')}
|
||||
color={colorsTokens['primary-500']}
|
||||
style={{ flexShrink: '0' }}
|
||||
/>
|
||||
)}
|
||||
{!isTopRoot && <DocTitleEmojiPicker doc={doc} />}
|
||||
|
||||
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
|
||||
<Box
|
||||
as="span"
|
||||
role="textbox"
|
||||
className="--docs--doc-title-input"
|
||||
contentEditable
|
||||
defaultValue={titleDisplay || undefined}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
aria-label={`${t('Document title')}`}
|
||||
aria-multiline={false}
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
font-size: ${isDesktop
|
||||
? css`var(--c--theme--font--sizes--h2)`
|
||||
: css`var(--c--theme--font--sizes--sm)`};
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
`}
|
||||
>
|
||||
{titleDisplay}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
$color={colorsTokens['greyscale-1000']}
|
||||
$padding={{ right: 'big' }}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
content: '${untitledDocument}';
|
||||
color: grey;
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
}
|
||||
font-size: ${isDesktop
|
||||
? css`var(--c--theme--font--sizes--h2)`
|
||||
: css`var(--c--theme--font--sizes--sm)`};
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
`}
|
||||
>
|
||||
{titleDisplay}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
ModalRemoveDoc,
|
||||
getEmojiAndTitle,
|
||||
useCopyDocLink,
|
||||
useCreateFavoriteDoc,
|
||||
useDeleteFavoriteDoc,
|
||||
useDocTitleUpdate,
|
||||
useDocUtils,
|
||||
useDuplicateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
@@ -49,7 +51,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { isChild } = useDocUtils(doc);
|
||||
const { isChild, isTopRoot } = useDocUtils(doc);
|
||||
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
|
||||
@@ -83,6 +85,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
});
|
||||
}, [selectHistoryModal.isOpen, queryClient]);
|
||||
|
||||
// Emoji Management
|
||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
const { updateDocEmoji } = useDocTitleUpdate();
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
...(isSmallMobile
|
||||
? [
|
||||
@@ -118,6 +124,17 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
},
|
||||
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
|
||||
},
|
||||
...(emoji && doc.abilities.partial_update && !isTopRoot
|
||||
? [
|
||||
{
|
||||
label: t('Remove emoji'),
|
||||
icon: 'emoji_emotions',
|
||||
callback: () => {
|
||||
updateDocEmoji(doc.id, doc.title ?? '', '');
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('Version history'),
|
||||
icon: 'history',
|
||||
|
||||
@@ -9,4 +9,3 @@ export * from './useDuplicateDoc';
|
||||
export * from './useRestoreDoc';
|
||||
export * from './useSubDocs';
|
||||
export * from './useUpdateDoc';
|
||||
export * from './useUpdateDocLink';
|
||||
|
||||
@@ -20,9 +20,10 @@ export const createDoc = async (): Promise<Doc> => {
|
||||
|
||||
interface CreateDocProps {
|
||||
onSuccess: (data: Doc) => void;
|
||||
onError?: (error: APIError) => void;
|
||||
}
|
||||
|
||||
export function useCreateDoc({ onSuccess }: CreateDocProps) {
|
||||
export function useCreateDoc({ onSuccess, onError }: CreateDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Doc, APIError>({
|
||||
mutationFn: createDoc,
|
||||
@@ -32,5 +33,8 @@ export function useCreateDoc({ onSuccess }: CreateDocProps) {
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +44,16 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
export type DocsResponse = APIList<Doc>;
|
||||
export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
|
||||
const searchParams = constructParams(params);
|
||||
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
let response
|
||||
|
||||
// HACK for fulltext search feature
|
||||
if (searchParams.has('title')) {
|
||||
searchParams.set('q', searchParams.get('title') || '');
|
||||
searchParams.delete('title');
|
||||
response = await fetchAPI(`documents/search?${searchParams.toString()}`);
|
||||
} else {
|
||||
response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
|
||||
|
||||
return await duplicateDoc(variables);
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
@@ -89,14 +89,14 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
void options?.onSuccess?.(data, variables, context);
|
||||
void options?.onSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
toast(t('Failed to duplicate the document...'), VariantType.ERROR, {
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
void options?.onError?.(error, variables, context);
|
||||
void options?.onError?.(error, variables, onMutateResult, context);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,19 +33,19 @@ export const useRemoveDoc = ({
|
||||
return useMutation<void, APIError, RemoveDocProps>({
|
||||
mutationFn: removeDoc,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,19 +36,19 @@ export const useRestoreDoc = ({
|
||||
return useMutation<void, APIError, RestoreDocProps>({
|
||||
mutationFn: restoreDoc,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
|
||||
return useMutation<Doc, APIError, UpdateDocParams>({
|
||||
mutationFn: updateDoc,
|
||||
...queryConfig,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
queryConfig?.listInvalideQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
@@ -50,10 +50,10 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
|
||||
});
|
||||
|
||||
if (queryConfig?.onSuccess) {
|
||||
void queryConfig.onSuccess(data, variables, context);
|
||||
void queryConfig.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
// If error it means the user is probably not allowed to edit the doc
|
||||
// so we invalidate the canEdit query to update the UI accordingly
|
||||
void queryClient.invalidateQueries({
|
||||
@@ -61,7 +61,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
|
||||
});
|
||||
|
||||
if (queryConfig?.onError) {
|
||||
queryConfig.onError(error, variables, context);
|
||||
queryConfig.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<svg
|
||||
width="33"
|
||||
height="33"
|
||||
viewBox="0 0 33 33"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
+103
-13
@@ -1,8 +1,18 @@
|
||||
import { Text, TextType } from '@/components';
|
||||
import { MouseEvent, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { BoxButton, Icon, TextType } from '@/components';
|
||||
import { EmojiPicker, emojidata } from '@/docs/doc-editor/';
|
||||
|
||||
import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate';
|
||||
|
||||
type DocIconProps = TextType & {
|
||||
emoji?: string | null;
|
||||
defaultIcon: React.ReactNode;
|
||||
docId?: string;
|
||||
title?: string;
|
||||
onEmojiUpdate?: (emoji: string) => void;
|
||||
withEmojiPicker?: boolean;
|
||||
};
|
||||
|
||||
export const DocIcon = ({
|
||||
@@ -11,22 +21,102 @@ export const DocIcon = ({
|
||||
$size = 'sm',
|
||||
$variation = '1000',
|
||||
$weight = '400',
|
||||
docId,
|
||||
title,
|
||||
onEmojiUpdate,
|
||||
withEmojiPicker = false,
|
||||
...textProps
|
||||
}: DocIconProps) => {
|
||||
if (!emoji) {
|
||||
return <>{defaultIcon}</>;
|
||||
const { updateDocEmoji } = useDocTitleUpdate();
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
|
||||
const [pickerPosition, setPickerPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
}>({ top: 0, left: 0 });
|
||||
|
||||
if (!withEmojiPicker && !emoji) {
|
||||
return defaultIcon;
|
||||
}
|
||||
|
||||
const toggleEmojiPicker = (e: MouseEvent) => {
|
||||
if (withEmojiPicker) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (!openEmojiPicker && iconRef.current) {
|
||||
const rect = iconRef.current.getBoundingClientRect();
|
||||
setPickerPosition({
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
}
|
||||
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiSelect = ({ native }: { native: string }) => {
|
||||
setOpenEmojiPicker(false);
|
||||
|
||||
// Update document emoji if docId is provided
|
||||
if (docId && title !== undefined) {
|
||||
updateDocEmoji(docId, title ?? '', native);
|
||||
}
|
||||
|
||||
// Call the optional callback
|
||||
onEmojiUpdate?.(native);
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Text
|
||||
{...textProps}
|
||||
$size={$size}
|
||||
$variation={$variation}
|
||||
$weight={$weight}
|
||||
aria-hidden="true"
|
||||
data-testid="doc-emoji-icon"
|
||||
>
|
||||
{emoji}
|
||||
</Text>
|
||||
<>
|
||||
<BoxButton
|
||||
className="--docs--doc-icon"
|
||||
ref={iconRef}
|
||||
onClick={toggleEmojiPicker}
|
||||
color="tertiary-text"
|
||||
>
|
||||
{!emoji ? (
|
||||
defaultIcon
|
||||
) : (
|
||||
<Icon
|
||||
{...textProps}
|
||||
iconName={emoji}
|
||||
$size={$size}
|
||||
$variation={$variation}
|
||||
$weight={$weight}
|
||||
aria-hidden="true"
|
||||
data-testid="doc-emoji-icon"
|
||||
>
|
||||
{emoji}
|
||||
</Icon>
|
||||
)}
|
||||
</BoxButton>
|
||||
{openEmojiPicker &&
|
||||
createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: pickerPosition.top,
|
||||
left: pickerPosition.left,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onClickOutside={handleClickOutside}
|
||||
withOverlay={true}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -8,6 +9,7 @@ import img403 from '@/assets/icons/icon-403.png';
|
||||
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
|
||||
import { ButtonAccessRequest } from '@/docs/doc-share';
|
||||
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: fit-content;
|
||||
@@ -19,6 +21,13 @@ interface DocProps {
|
||||
|
||||
export const DocPage403 = ({ id }: DocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure the skeleton overlay is hidden on 403 page
|
||||
setIsSkeletonVisible(false);
|
||||
}, [setIsSkeletonVisible]);
|
||||
|
||||
const {
|
||||
data: requests,
|
||||
isLoading: isLoadingRequest,
|
||||
|
||||
+14
-31
@@ -4,20 +4,13 @@ import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
getEmojiAndTitle,
|
||||
useDocUtils,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { Doc, useDocUtils, useTrans } from '@/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import ChildDocument from '../assets/child-document.svg';
|
||||
import PinnedDocumentIcon from '../assets/pinned-document.svg';
|
||||
import SimpleFileIcon from '../assets/simple-document.svg';
|
||||
|
||||
import { DocIcon } from './DocIcon';
|
||||
|
||||
const ItemTextCss = css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -45,10 +38,6 @@ export const SimpleDocItem = ({
|
||||
const { untitledDocument } = useTrans();
|
||||
const { isChild } = useDocUtils(doc);
|
||||
|
||||
const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle(
|
||||
doc.title || untitledDocument,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
@@ -76,25 +65,19 @@ export const SimpleDocItem = ({
|
||||
data-testid="doc-pinned-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
) : isChild ? (
|
||||
<ChildDocument
|
||||
aria-hidden="true"
|
||||
data-testid="doc-child-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
) : (
|
||||
<DocIcon
|
||||
emoji={emoji}
|
||||
defaultIcon={
|
||||
isChild ? (
|
||||
<ChildDocument
|
||||
aria-hidden="true"
|
||||
data-testid="doc-child-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
) : (
|
||||
<SimpleFileIcon
|
||||
aria-hidden="true"
|
||||
data-testid="doc-simple-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
$size="25px"
|
||||
<SimpleFileIcon
|
||||
width="32px"
|
||||
height="32px"
|
||||
aria-hidden="true"
|
||||
data-testid="doc-simple-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -106,7 +89,7 @@ export const SimpleDocItem = ({
|
||||
$css={ItemTextCss}
|
||||
data-testid="doc-title"
|
||||
>
|
||||
{displayTitle}
|
||||
{doc.title || untitledDocument}
|
||||
</Text>
|
||||
{(!isDesktop || showAccesses) && (
|
||||
<Box
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './DocIcon';
|
||||
export * from './DocPage403';
|
||||
export * from './ModalRemoveDoc';
|
||||
export * from './SimpleDocItem';
|
||||
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { Doc } from '../../types';
|
||||
import { useDocTitleUpdate } from '../useDocTitleUpdate';
|
||||
|
||||
// Mock useBroadcastStore
|
||||
vi.mock('@/stores', () => ({
|
||||
useBroadcastStore: () => ({
|
||||
broadcast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useDocTitleUpdate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('should return the correct functions and state', () => {
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(result.current.updateDocTitle).toBeDefined();
|
||||
expect(result.current.updateDocEmoji).toBeDefined();
|
||||
expect(typeof result.current.updateDocTitle).toBe('function');
|
||||
expect(typeof result.current.updateDocEmoji).toBe('function');
|
||||
});
|
||||
|
||||
describe('updateDocTitle', () => {
|
||||
it('should call updateDoc with sanitized title', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const sanitizedTitle = result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: '' } as Doc,
|
||||
' My Document \n\r',
|
||||
);
|
||||
|
||||
expect(sanitizedTitle).toBe('My Document');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][0]).toBe(
|
||||
'http://test.jest/api/v1.0/documents/test-doc-id/',
|
||||
);
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: 'My Document' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty title and not call updateDoc', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const sanitizedTitle = result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: '' } as Doc,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(sanitizedTitle).toBe('');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove newlines and carriage returns', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const sanitizedTitle = result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: '' } as Doc,
|
||||
'Title\nwith\r\nnewlines',
|
||||
);
|
||||
|
||||
expect(sanitizedTitle).toBe('Titlewithnewlines');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDocEmoji', () => {
|
||||
it('should call updateDoc with emoji and title without existing emoji', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocEmoji('test-doc-id', 'My Document', '🚀');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][0]).toBe(
|
||||
'http://test.jest/api/v1.0/documents/test-doc-id/',
|
||||
);
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: '🚀 My Document' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace existing emoji with new one', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocEmoji('test-doc-id', '📝 My Document', '🚀');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: '🚀 My Document' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle title with only emoji', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocEmoji('test-doc-id', '📝', '🚀');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: '🚀 ' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty title', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocEmoji('test-doc-id', '', '🚀');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: '🚀 ' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSuccess callback', () => {
|
||||
it('should call onSuccess when provided', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'Updated Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
const { result } = renderHook(() => useDocTitleUpdate({ onSuccess }), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: 'Old Document' } as Doc,
|
||||
'Updated Document',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith({
|
||||
id: 'test-doc-id',
|
||||
title: 'Updated Document',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onError callback', () => {
|
||||
it('should call onError when provided', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
throws: new Error('Update failed'),
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(() => useDocTitleUpdate({ onError }), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
try {
|
||||
result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: 'Old Document' } as Doc,
|
||||
'Updated Document',
|
||||
);
|
||||
} catch {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
expect(onError).toHaveBeenCalledWith(new Error('Update failed'));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './useCollaboration';
|
||||
export * from './useCopyDocLink';
|
||||
export * from './useCreateChildDocTree';
|
||||
export * from './useDocTitleUpdate';
|
||||
export * from './useDocUtils';
|
||||
export * from './useIsCollaborativeEditable';
|
||||
export * from './useTrans';
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
getEmojiAndTitle,
|
||||
useUpdateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
|
||||
interface UseDocUpdateOptions {
|
||||
onSuccess?: (updatedDoc: Doc) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const useDocTitleUpdate = (options?: UseDocUpdateOptions) => {
|
||||
const { broadcast } = useBroadcastStore();
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
const { mutate: updateDoc, ...mutationResult } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess: (updatedDoc) => {
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
|
||||
|
||||
if (treeContext) {
|
||||
if (treeContext.root?.id === updatedDoc.id) {
|
||||
treeContext?.setRoot(updatedDoc);
|
||||
} else {
|
||||
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
|
||||
}
|
||||
}
|
||||
|
||||
options?.onSuccess?.(updatedDoc);
|
||||
},
|
||||
onError: (error) => {
|
||||
options?.onError?.(error);
|
||||
},
|
||||
});
|
||||
|
||||
const updateDocTitle = useCallback(
|
||||
(doc: Doc, title: string) => {
|
||||
const sanitizedTitle = title.trim().replace(/(\r\n|\n|\r)/gm, '');
|
||||
|
||||
// When blank we set to untitled
|
||||
if (!sanitizedTitle) {
|
||||
updateDoc({ id: doc.id, title: '' });
|
||||
return '';
|
||||
}
|
||||
|
||||
// If mutation we update
|
||||
if (sanitizedTitle !== doc.title) {
|
||||
updateDoc({ id: doc.id, title: sanitizedTitle });
|
||||
}
|
||||
|
||||
return sanitizedTitle;
|
||||
},
|
||||
[updateDoc],
|
||||
);
|
||||
|
||||
const updateDocEmoji = useCallback(
|
||||
(docId: string, title: string, emoji: string) => {
|
||||
const { titleWithoutEmoji } = getEmojiAndTitle(title);
|
||||
updateDoc({ id: docId, title: `${emoji} ${titleWithoutEmoji}` });
|
||||
},
|
||||
[updateDoc],
|
||||
);
|
||||
|
||||
return {
|
||||
...mutationResult,
|
||||
updateDocTitle,
|
||||
updateDocEmoji,
|
||||
};
|
||||
};
|
||||
+32
@@ -1,3 +1,4 @@
|
||||
import { CloseEvent } from '@hocuspocus/common';
|
||||
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
|
||||
import * as Y from 'yjs';
|
||||
import { create } from 'zustand';
|
||||
@@ -13,6 +14,8 @@ export interface UseCollaborationStore {
|
||||
destroyProvider: () => void;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
isConnected: boolean;
|
||||
isReady: boolean;
|
||||
isSynced: boolean;
|
||||
hasLostConnection: boolean;
|
||||
resetLostConnection: () => void;
|
||||
}
|
||||
@@ -20,9 +23,13 @@ export interface UseCollaborationStore {
|
||||
const defaultValues = {
|
||||
provider: undefined,
|
||||
isConnected: false,
|
||||
isReady: false,
|
||||
isSynced: false,
|
||||
hasLostConnection: false,
|
||||
};
|
||||
|
||||
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
...defaultValues,
|
||||
createProvider: (wsUrl, storeId, initialDoc) => {
|
||||
@@ -38,11 +45,21 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
url: wsUrl,
|
||||
name: storeId,
|
||||
document: doc,
|
||||
onDisconnect(data) {
|
||||
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
|
||||
if ((data.event as ExtendedCloseEvent).wasClean) {
|
||||
void provider.connect();
|
||||
}
|
||||
},
|
||||
onAuthenticationFailed() {
|
||||
set({ isReady: true });
|
||||
},
|
||||
onStatus: ({ status }) => {
|
||||
set((state) => {
|
||||
const nextConnected = status === WebSocketStatus.Connected;
|
||||
return {
|
||||
isConnected: nextConnected,
|
||||
isReady: state.isReady || status === WebSocketStatus.Disconnected,
|
||||
hasLostConnection:
|
||||
state.isConnected && !nextConnected
|
||||
? true
|
||||
@@ -50,6 +67,21 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
};
|
||||
});
|
||||
},
|
||||
onSynced: ({ state }) => {
|
||||
set({ isSynced: state, isReady: true });
|
||||
},
|
||||
onClose(data) {
|
||||
/**
|
||||
* Handle the "Reset Connection" event from the server
|
||||
* This is triggered when the server wants to reset the connection
|
||||
* for clients in the room.
|
||||
* A disconnect is made automatically but it takes time to be triggered,
|
||||
* so we force the disconnection here.
|
||||
*/
|
||||
if (data.event.code === 1000) {
|
||||
provider.disconnect();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
set({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export * from './useDeleteDocAccess';
|
||||
export * from './useDocAccesses';
|
||||
export * from './useUpdateDocAccess';
|
||||
export * from './useCreateDocAccess';
|
||||
export * from './useUsers';
|
||||
export * from './useCreateDocInvitation';
|
||||
export * from './useDeleteDocAccess';
|
||||
export * from './useDeleteDocInvitation';
|
||||
export * from './useDocAccesses';
|
||||
export * from './useDocAccessRequest';
|
||||
export * from './useDocInvitations';
|
||||
export * from './useUpdateDocAccess';
|
||||
export * from './useUpdateDocInvitation';
|
||||
export * from './useUsers';
|
||||
|
||||
@@ -45,7 +45,7 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
|
||||
return useMutation<void, APIError, DeleteDocAccessProps>({
|
||||
mutationFn: deleteDocAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES],
|
||||
});
|
||||
@@ -63,7 +63,7 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
|
||||
queryKey: [KEY_LIST_USER],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,17 +53,17 @@ export const useDeleteDocInvitation = (
|
||||
>({
|
||||
mutationFn: deleteDocInvitation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_INVITATIONS],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,12 +59,12 @@ export function useCreateDocAccessRequest(
|
||||
return useMutation<void, APIError, CreateDocAccessRequestParams>({
|
||||
mutationFn: createDocAccessRequest,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
|
||||
});
|
||||
|
||||
void options?.onSuccess?.(data, variables, context);
|
||||
void options?.onSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export const useAcceptDocAccessRequest = (
|
||||
return useMutation<void, APIError, acceptDocAccessRequestsParams>({
|
||||
mutationFn: acceptDocAccessRequests,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES],
|
||||
});
|
||||
@@ -179,7 +179,7 @@ export const useAcceptDocAccessRequest = (
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -223,13 +223,13 @@ export const useDeleteDocAccessRequest = (
|
||||
return useMutation<void, APIError, DeleteDocAccessRequestParams>({
|
||||
mutationFn: deleteDocAccessRequest,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Access, KEY_DOC, KEY_LIST_DOC, Role } from '@/docs/doc-management';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
|
||||
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
|
||||
|
||||
@@ -45,12 +44,11 @@ type UseUpdateDocAccessOptions = UseMutationOptions<
|
||||
|
||||
export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { broadcast } = useBroadcastStore();
|
||||
|
||||
return useMutation<Access, APIError, UpdateDocAccessProps>({
|
||||
mutationFn: updateDocAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES],
|
||||
});
|
||||
@@ -58,14 +56,12 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
|
||||
queryKey: [KEY_DOC],
|
||||
});
|
||||
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${variables.docId}`);
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,17 +62,17 @@ export const useUpdateDocInvitation = (
|
||||
>({
|
||||
mutationFn: updateDocInvitation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_INVITATIONS],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+2
-10
@@ -3,11 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
|
||||
import { Doc } from '../types';
|
||||
|
||||
import { KEY_DOC } from './useDoc';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
|
||||
export type UpdateDocLinkParams = Pick<Doc, 'id' | 'link_reach'> &
|
||||
Partial<Pick<Doc, 'link_role'>>;
|
||||
@@ -43,22 +39,18 @@ export function useUpdateDocLink({
|
||||
listInvalideQueries,
|
||||
}: UpdateDocLinkProps = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { broadcast } = useBroadcastStore();
|
||||
const { toast } = useToastProvider();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<Doc, APIError, UpdateDocLinkParams>({
|
||||
mutationFn: updateDocLink,
|
||||
onSuccess: (data, variable) => {
|
||||
onSuccess: (data) => {
|
||||
listInvalideQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${variable.id}`);
|
||||
|
||||
toast(
|
||||
t('The document visibility has been updated.'),
|
||||
VariantType.SUCCESS,
|
||||
+3
-6
@@ -4,12 +4,9 @@ import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
useUpdateDocLink,
|
||||
} from '@/docs/doc-management';
|
||||
import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
|
||||
|
||||
import { useUpdateDocLink } from '../api/useUpdateDocLink';
|
||||
|
||||
import Desync from './../assets/desynchro.svg';
|
||||
import Undo from './../assets/undo.svg';
|
||||
|
||||
@@ -112,7 +112,7 @@ export const QuickSearchGroupMember = ({
|
||||
elements: members,
|
||||
endActions: undefined,
|
||||
};
|
||||
}, [membersQuery, t]);
|
||||
}, [membersQuery.data, t]);
|
||||
|
||||
return (
|
||||
<Box aria-label={t('List members card')} $padding={{ bottom: '3xs' }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
@@ -15,7 +16,14 @@ import { User } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
import { isValidEmail } from '@/utils';
|
||||
|
||||
import { KEY_LIST_USER, useDocAccesses, useUsers } from '../api';
|
||||
import {
|
||||
KEY_LIST_DOC_ACCESSES,
|
||||
KEY_LIST_DOC_ACCESS_REQUESTS,
|
||||
KEY_LIST_DOC_INVITATIONS,
|
||||
KEY_LIST_USER,
|
||||
useDocAccesses,
|
||||
useUsers,
|
||||
} from '../api';
|
||||
|
||||
import { DocInheritedShareContent } from './DocInheritedShareContent';
|
||||
import {
|
||||
@@ -48,6 +56,7 @@ type Props = {
|
||||
export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedUsersRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
@@ -128,6 +137,19 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const showInheritedShareContent =
|
||||
inheritedAccesses.length > 0 && showMemberSection && !isRootDoc;
|
||||
|
||||
// Invalidate relevant queries to ensure fresh data on modal open
|
||||
useEffect(() => {
|
||||
[
|
||||
KEY_LIST_DOC_INVITATIONS,
|
||||
KEY_LIST_DOC_ACCESS_REQUESTS,
|
||||
KEY_LIST_DOC_ACCESSES,
|
||||
].forEach((key) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [key],
|
||||
});
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
getDocLinkReach,
|
||||
getDocLinkRole,
|
||||
useDocUtils,
|
||||
useUpdateDocLink,
|
||||
} from '@/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useUpdateDocLink } from '../api/useUpdateDocLink';
|
||||
import { useTranslatedShareSettings } from '../hooks/';
|
||||
|
||||
import { DocDesynchronized } from './DocDesynchronized';
|
||||
|
||||
@@ -12,10 +12,10 @@ import { Box, BoxButton, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
DocIcon,
|
||||
getEmojiAndTitle,
|
||||
useTrans,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
|
||||
import { useLeftPanelStore } from '@/features/left-panel';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
@@ -166,11 +166,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
min-width: 0;
|
||||
`}
|
||||
>
|
||||
<Box $width="16px" $height="16px">
|
||||
<Box>
|
||||
<DocIcon
|
||||
emoji={emoji}
|
||||
withEmojiPicker={doc.abilities.partial_update}
|
||||
defaultIcon={<SubPageIcon color={colorsTokens['primary-400']} />}
|
||||
$size="sm"
|
||||
docId={doc.id}
|
||||
title={doc.title}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user