Compare commits
14 Commits
ui/fulltex
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b856912e5 | ||
|
|
b740ffa52c | ||
|
|
f555e36e98 | ||
|
|
de11ab508f | ||
|
|
dc2fe4905b | ||
|
|
2864669dde | ||
|
|
7dae3a3c02 | ||
|
|
bdf62e2172 | ||
|
|
29104dfe2d | ||
|
|
785c9b21cf | ||
|
|
3fee1f2081 | ||
|
|
5f9968d81e | ||
|
|
f7baf238e3 | ||
|
|
bab42efd08 |
10
.github/workflows/impress.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Install codespell
|
||||
run: pip install --user codespell
|
||||
- name: Check for typos
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
4
.gitignore
vendored
@@ -43,10 +43,6 @@ venv.bak/
|
||||
env.d/development/*.local
|
||||
env.d/terraform
|
||||
|
||||
# Docker
|
||||
compose.override.yml
|
||||
docker/auth/*.local
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
|
||||
21
CHANGELOG.md
@@ -6,10 +6,24 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ Add comments feature to the editor #1330
|
||||
- ✨(backend) Comments on text editor #1330
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(sw) stop to cache external resources likes videos #1655
|
||||
- 💥(frontend) upgrade to ui-kit v2
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) improve share modal button accessibility #1626
|
||||
- ♿(frontend) improve screen reader support in DocShare modal #1628
|
||||
- 🐛(frontend) fix toolbar not activated when reader #1640
|
||||
- 🐛(frontend) preserve left panel width on window resize #1588
|
||||
- 🐛(frontend) prevent duplicate as first character in title #1595
|
||||
|
||||
## [3.10.0] - 2025-11-18
|
||||
|
||||
@@ -40,13 +54,11 @@ and this project adheres to
|
||||
### Security
|
||||
|
||||
- mitigate role escalation in the ask_for_access viewset #1580
|
||||
- 🐛(frontend) preserve left panel width on window resize #1588
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(backend) remove api managing templates
|
||||
|
||||
|
||||
## [3.9.0] - 2025-11-10
|
||||
|
||||
### Added
|
||||
@@ -54,7 +66,6 @@ and this project adheres to
|
||||
- ✨(frontend) create skeleton component for DocEditor #1491
|
||||
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
|
||||
- ✨(frontend) ajustable left panel #1456
|
||||
- ✨ Add comments feature to the editor #1330
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -123,9 +134,6 @@ 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
|
||||
|
||||
@@ -186,7 +194,6 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) Comments on text editor #1309
|
||||
- 👷(CI) add bundle size check job #1268
|
||||
- ✨(frontend) use title first emoji as doc icon in tree #1289
|
||||
|
||||
|
||||
4
Makefile
@@ -247,10 +247,6 @@ 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: \
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/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
compose.yml
@@ -72,11 +72,6 @@ services:
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "8071:8000"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
aliases:
|
||||
- impress
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -97,9 +92,6 @@ 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
|
||||
@@ -115,11 +107,6 @@ 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:
|
||||
@@ -230,8 +217,3 @@ services:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
|
||||
@@ -12,7 +12,6 @@ flowchart TD
|
||||
Back --> DB("Database (PostgreSQL)")
|
||||
Back <--> Celery --> DB
|
||||
Back ----> S3("Minio (S3)")
|
||||
Back -- REST API --> Find
|
||||
```
|
||||
|
||||
### Architecture decision records
|
||||
|
||||
@@ -93,13 +93,6 @@ 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 | |
|
||||
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
|
||||
| SENTRY_DSN | Sentry host | |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# Setup the Find search for Impress
|
||||
|
||||
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
|
||||
|
||||
Configure a **Service** for Docs application 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._
|
||||
|
||||
See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
|
||||
|
||||
## Configure settings of Docs
|
||||
|
||||
Add those Django settings the Docs application to enable the feature.
|
||||
|
||||
```shell
|
||||
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
|
||||
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
|
||||
|
||||
# The token from service "docs" of Find application (development).
|
||||
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
|
||||
# Search endpoint. Uses the OIDC token for authentication
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
# Maximum number of results expected from the search endpoint
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50
|
||||
```
|
||||
|
||||
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)
|
||||
```
|
||||
@@ -97,17 +97,6 @@ Production deployments differ significantly from development environments. The t
|
||||
| 5433 | PostgreSQL (Keycloak) |
|
||||
| 1081 | MailCatcher |
|
||||
|
||||
**With fulltext search service**
|
||||
|
||||
| Port | Service |
|
||||
| --------- | --------------------- |
|
||||
| 8081 | Find (Django) |
|
||||
| 9200 | Opensearch |
|
||||
| 9600 | Opensearch admin |
|
||||
| 5601 | Opensearch dashboard |
|
||||
| 25432 | PostgreSQL (Find) |
|
||||
|
||||
|
||||
## 6. Sizing Guidelines
|
||||
|
||||
**RAM** – start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
|
||||
|
||||
@@ -32,9 +32,9 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
|
||||
|
||||
----
|
||||
|
||||
# **Your logo** 📝
|
||||
# **Your Docs icon** 📝
|
||||
|
||||
You can add your own logo in the header from the theme customization file.
|
||||
You can add your own Docs icon in the header from the theme customization file.
|
||||
|
||||
### Settings 🔧
|
||||
|
||||
@@ -44,9 +44,9 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||
|
||||
### Example of JSON
|
||||
|
||||
You can activate it with the `header.logo` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
|
||||
This configuration is optional. If not set, the default logo will be used.
|
||||
This configuration is optional. If not set, the default icon will be used.
|
||||
|
||||
----
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ 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
|
||||
@@ -50,14 +49,6 @@ 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. Needed by search/ endpoint.
|
||||
# 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="your-32-byte-encryption-key=="
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
@@ -77,10 +68,4 @@ 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
|
||||
|
||||
# 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/"
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
@@ -6,9 +6,4 @@ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
|
||||
# Throttle
|
||||
API_DOCUMENT_THROTTLE_RATE=1000/min
|
||||
API_CONFIG_THROTTLE_RATE=1000/min
|
||||
|
||||
# Store OIDC tokens in the session.
|
||||
OIDC_STORE_ACCESS_TOKEN = True
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = "na1hhus-OLhq9mb9SO3R-8E4dONuMnqpZSY_SX8xcFk="
|
||||
API_CONFIG_THROTTLE_RATE=1000/min
|
||||
@@ -6,6 +6,7 @@
|
||||
"prCreation": "not-pending",
|
||||
"rebaseWhen": "conflicted",
|
||||
"updateNotScheduled": false,
|
||||
"minimumReleaseAge": "15 days",
|
||||
"packageRules": [
|
||||
{
|
||||
"enabled": false,
|
||||
|
||||
@@ -1013,13 +1013,3 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
if request:
|
||||
return thread.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -22,7 +22,6 @@ from django.db.models.functions import Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
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 _
|
||||
@@ -33,7 +32,6 @@ 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
|
||||
@@ -50,10 +48,6 @@ 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
|
||||
|
||||
@@ -380,7 +374,6 @@ 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."""
|
||||
@@ -1072,83 +1065,6 @@ 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"]
|
||||
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),
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
page = self.paginate_queryset(ordered_docs)
|
||||
|
||||
serializer = self.get_serializer(
|
||||
page if page else ordered_docs,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@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):
|
||||
"""
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
"""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")
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
from . import signals # noqa: PLC0415
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("impress core application")
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
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 add_arguments(self, parser):
|
||||
"""Add argument to require forcing execution when not in debug mode."""
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
action="store",
|
||||
dest="batch_size",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Indexation query batch size",
|
||||
)
|
||||
|
||||
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()
|
||||
batch_size = options["batch_size"]
|
||||
|
||||
try:
|
||||
count = indexer.index(batch_size=batch_size)
|
||||
except Exception as err:
|
||||
raise CommandError("Unable to regenerate index") from err
|
||||
|
||||
duration = time.perf_counter() - start
|
||||
logger.info(
|
||||
"Search index regenerated from %d document(s) in %.2f seconds.",
|
||||
count,
|
||||
duration,
|
||||
)
|
||||
@@ -432,35 +432,32 @@ 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:
|
||||
self.save_content(self._content)
|
||||
file_key = self.file_key
|
||||
bytes_content = self._content.encode("utf-8")
|
||||
|
||||
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
|
||||
# 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
|
||||
else:
|
||||
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
|
||||
)
|
||||
# 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):
|
||||
"""
|
||||
@@ -906,8 +903,7 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
# Mark all descendants as soft deleted
|
||||
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
|
||||
ancestors_deleted_at=self.ancestors_deleted_at,
|
||||
updated_at=self.updated_at,
|
||||
ancestors_deleted_at=self.ancestors_deleted_at
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
"""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):
|
||||
"""
|
||||
Initialize the indexer.
|
||||
"""
|
||||
self.batch_size = 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
|
||||
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
|
||||
|
||||
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, queryset=None, batch_size=None):
|
||||
"""
|
||||
Fetch documents in batches, serialize them, and push to the search backend.
|
||||
|
||||
Args:
|
||||
queryset (optional): Document queryset
|
||||
Defaults to all documents without filter.
|
||||
batch_size (int, optional): Number of documents per batch.
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
"""
|
||||
last_id = 0
|
||||
count = 0
|
||||
queryset = queryset or models.Document.objects.all()
|
||||
batch_size = batch_size or self.batch_size
|
||||
|
||||
while True:
|
||||
documents_batch = list(
|
||||
queryset.filter(
|
||||
id__gt=last_id,
|
||||
).order_by("id")[:batch_size]
|
||||
)
|
||||
|
||||
if not documents_batch:
|
||||
break
|
||||
|
||||
doc_paths = [doc.path for doc in documents_batch]
|
||||
last_id = documents_batch[-1].id
|
||||
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
|
||||
|
||||
serialized_batch = [
|
||||
self.serialize_document(document, accesses_by_document_path)
|
||||
for document in documents_batch
|
||||
if document.content or document.title
|
||||
]
|
||||
|
||||
if serialized_batch:
|
||||
self.push(serialized_batch)
|
||||
count += len(serialized_batch)
|
||||
|
||||
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=(), nb_results=None):
|
||||
"""
|
||||
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.
|
||||
nb_results (int, optional):
|
||||
The number of results to return.
|
||||
Defaults to 50 if not specified.
|
||||
"""
|
||||
nb_results = nb_results or self.search_limit
|
||||
response = self.search_query(
|
||||
data={
|
||||
"q": text,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"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()
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
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_batch_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_batch_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_batch_document_indexer, instance.document)
|
||||
)
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Trigger document indexation using celery task."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
|
||||
from django_redis.cache import RedisCache
|
||||
|
||||
from core import models
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
from impress.celery_app import app
|
||||
|
||||
logger = getLogger(__file__)
|
||||
|
||||
|
||||
@app.task
|
||||
def document_indexer_task(document_id):
|
||||
"""Celery Task : Sends indexation query for a document."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
logger.info("Start document %s indexation", document_id)
|
||||
indexer.index(models.Document.objects.filter(pk=document_id))
|
||||
|
||||
|
||||
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
|
||||
"""
|
||||
Enable the task throttle flag for a delay.
|
||||
Uses redis locks if available to ensure atomic changes
|
||||
"""
|
||||
key = "document-batch-indexer-throttle"
|
||||
|
||||
# Redis is used as cache database (not in tests). Use the lock feature here
|
||||
# to ensure atomicity of changes to the throttle flag.
|
||||
if isinstance(cache, RedisCache) and atomic:
|
||||
with cache.locks(key):
|
||||
return batch_indexer_throttle_acquire(timeout, atomic=False)
|
||||
|
||||
# Use add() here :
|
||||
# - set the flag and returns true if not exist
|
||||
# - do nothing and return false if exist
|
||||
return cache.add(key, 1, timeout=timeout)
|
||||
|
||||
|
||||
@app.task
|
||||
def batch_document_indexer_task(timestamp):
|
||||
"""Celery Task : Sends indexation query for a batch of documents."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
queryset = models.Document.objects.filter(
|
||||
Q(updated_at__gte=timestamp)
|
||||
| Q(deleted_at__gte=timestamp)
|
||||
| Q(ancestors_deleted_at__gte=timestamp)
|
||||
)
|
||||
|
||||
count = indexer.index(queryset)
|
||||
logger.info("Indexed %d documents", count)
|
||||
|
||||
|
||||
def trigger_batch_document_indexer(item):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
"""
|
||||
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
|
||||
|
||||
# DO NOT create a task if indexation if disabled
|
||||
if not settings.SEARCH_INDEXER_CLASS:
|
||||
return
|
||||
|
||||
if countdown > 0:
|
||||
# Each time this method is called during a countdown, we increment the
|
||||
# counter and each task decrease it, so the index be run only once.
|
||||
if batch_indexer_throttle_acquire(timeout=countdown):
|
||||
logger.info(
|
||||
"Add task for batch document indexation from updated_at=%s in %d seconds",
|
||||
item.updated_at.isoformat(),
|
||||
countdown,
|
||||
)
|
||||
|
||||
batch_document_indexer_task.apply_async(
|
||||
args=[item.updated_at], countdown=countdown
|
||||
)
|
||||
else:
|
||||
logger.info("Skip task for batch document %s indexation", item.pk)
|
||||
else:
|
||||
document_indexer_task.apply(args=[item.pk])
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
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,30 +24,3 @@ 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/"
|
||||
)
|
||||
settings.SEARCH_INDEXER_COUNTDOWN = 1
|
||||
|
||||
yield settings
|
||||
|
||||
# clear cache to prevent issues with other tests
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
"""
|
||||
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_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"],
|
||||
"nb_results": 50,
|
||||
"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
|
||||
@@ -1,441 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def reset_batch_indexer_throttle():
|
||||
"""Reset throttle flag"""
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_throttle():
|
||||
"""Reset throttle flag before each test"""
|
||||
reset_batch_indexer_throttle()
|
||||
yield
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push):
|
||||
"""Test indexation task on document creation"""
|
||||
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 len(data) == 1
|
||||
|
||||
# One call
|
||||
assert sorted(data[0], 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("document-batch-indexer-throttle") == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
"""Test indexation task on doculment creation, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
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()
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
# one document per call
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# all documents are indexed
|
||||
assert sorted([d[0] for d in 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("file-batch-indexer-throttle") 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_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_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.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
"""Test indexation task on document creation"""
|
||||
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 len(data) == 1
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
"""Indexation task on deleted or ancestor_deleted documents"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
main_doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
child_doc = factories.DocumentFactory(
|
||||
parent=main_doc,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=main_doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=child_doc, user=user)
|
||||
|
||||
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with transaction.atomic():
|
||||
main_doc_deleted = models.Document.objects.get(pk=main_doc.pk)
|
||||
main_doc_deleted.soft_delete()
|
||||
|
||||
child_doc_deleted = models.Document.objects.get(pk=child_doc.pk)
|
||||
|
||||
main_doc_deleted.refresh_from_db()
|
||||
child_doc_deleted.refresh_from_db()
|
||||
|
||||
assert main_doc_deleted.deleted_at is not None
|
||||
assert child_doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert child_doc_deleted.deleted_at is None
|
||||
assert child_doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(main_doc_deleted.path): {"users": [user.sub]},
|
||||
str(child_doc_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 2
|
||||
|
||||
# First indexation on document creation
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(main_doc, accesses),
|
||||
indexer.serialize_document(child_doc, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# Even deleted items are re-indexed : only update their status in the future
|
||||
assert sorted(data[1], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(main_doc_deleted, accesses), # soft_delete()
|
||||
indexer.serialize_document(child_doc_deleted, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_indexer_hard_deleted():
|
||||
"""Indexation task on hard deleted document"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
# Call task on deleted document.
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
doc.delete()
|
||||
|
||||
# Hard delete document are not re-indexed.
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
"""Restart indexation task on restored documents"""
|
||||
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,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
|
||||
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
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
|
||||
|
||||
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with transaction.atomic():
|
||||
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 len(data) == 2
|
||||
|
||||
# First indexation on items creation & soft delete (in the same transaction)
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses),
|
||||
indexer.serialize_document(doc_ancestor_deleted, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# Restored items are re-indexed : only update their status in the future
|
||||
assert sorted(data[1], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc_restored, accesses), # restore()
|
||||
indexer.serialize_document(doc_ancestor_restored, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_post_save_indexer_throttle():
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer = SearchIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
docs = factories.DocumentFactory.create_batch(5, users=(user,))
|
||||
|
||||
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# Simulate 1 running task
|
||||
cache.set("document-batch-indexer-throttle", 1)
|
||||
|
||||
# save doc to trigger the indexer, but nothing should be done since
|
||||
# the flag is up
|
||||
with transaction.atomic():
|
||||
docs[0].save()
|
||||
docs[2].save()
|
||||
docs[3].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("document-batch-indexer-throttle")
|
||||
|
||||
with transaction.atomic():
|
||||
docs[0].save()
|
||||
docs[2].save()
|
||||
docs[3].save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# One call
|
||||
assert len(data) == 1
|
||||
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(docs[0], accesses),
|
||||
indexer.serialize_document(docs[2], accesses),
|
||||
indexer.serialize_document(docs[3], accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_access_post_save_indexer():
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
"user__sub"
|
||||
)
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# One call
|
||||
assert len(data) == 1
|
||||
|
||||
assert [d["id"] for d in data[0]] == [str(doc.pk)]
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
"""Test indexation task on ItemAccess update, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
"user__sub"
|
||||
)
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
# one document per call
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# the same document is indexed 3 times
|
||||
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
|
||||
@@ -1,635 +0,0 @@
|
||||
"""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_batch_size_argument(mock_push):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
batch_size overrides SEARCH_INDEXER_BATCH_SIZE
|
||||
"""
|
||||
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(batch_size=2) == 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")
|
||||
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
|
||||
"""
|
||||
Documents indexing batch can be empty if all the docs are empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
# Only empty docs
|
||||
factories.DocumentFactory.create_batch(5, content="", title="")
|
||||
|
||||
assert SearchIndexer().index() == 1
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
results = [doc["id"] for doc in mock_push.call_args[0][0]]
|
||||
assert results == [str(document.id)]
|
||||
|
||||
|
||||
@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["nb_results"] == 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
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search_nb_results(mock_post, indexer_settings):
|
||||
"""
|
||||
Find API call should have nb_results == SEARCH_INDEXER_QUERY_LIMIT
|
||||
or the given nb_results argument.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
|
||||
|
||||
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
|
||||
assert kwargs.get("json")["nb_results"] == 25
|
||||
|
||||
# The argument overrides the setting value
|
||||
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert kwargs.get("json")["nb_results"] == 109
|
||||
@@ -75,28 +75,3 @@ 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,7 +2,6 @@
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -10,27 +9,6 @@ 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,19 +1,16 @@
|
||||
# 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
|
||||
@@ -30,16 +27,6 @@ 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."""
|
||||
|
||||
@@ -61,7 +48,7 @@ class BulkQueue:
|
||||
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
|
||||
|
||||
def push(self, obj):
|
||||
"""Add a model instance to queue so that it gets created in bulk."""
|
||||
"""Add a model instance to queue to that it gets created in bulk."""
|
||||
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
|
||||
objects.append(obj)
|
||||
if len(objects) > self.BATCH_SIZE:
|
||||
@@ -152,19 +139,17 @@ 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))
|
||||
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),
|
||||
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),
|
||||
)
|
||||
)
|
||||
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
|
||||
queue.push(document)
|
||||
|
||||
queue.flush()
|
||||
|
||||
|
||||
@@ -99,31 +99,6 @@ 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
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
|
||||
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
|
||||
)
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
|
||||
@@ -24,15 +24,13 @@ test.beforeEach(async ({ page }) => {
|
||||
});
|
||||
|
||||
test.describe('Doc Editor', () => {
|
||||
test('it checks default toolbar buttons are displayed', async ({
|
||||
test('it checks toolbar buttons are displayed', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('test content');
|
||||
const editor = await writeInEditor({ page, text: 'test content' });
|
||||
|
||||
await editor
|
||||
.getByText('test content', {
|
||||
@@ -41,6 +39,9 @@ test.describe('Doc Editor', () => {
|
||||
.selectText();
|
||||
|
||||
const toolbar = page.locator('.bn-formatting-toolbar');
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="comment-toolbar-button"]'),
|
||||
).toBeVisible();
|
||||
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
|
||||
await expect(toolbar.locator('button[data-test="italic"]')).toBeVisible();
|
||||
await expect(
|
||||
@@ -63,6 +64,53 @@ test.describe('Doc Editor', () => {
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="createLink"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="ai-actions"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="convertMarkdown"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
await page.getByText('Upload image').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
|
||||
const image = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media')
|
||||
.first();
|
||||
|
||||
await expect(image).toHaveAttribute('role', 'presentation');
|
||||
|
||||
await image.dblclick();
|
||||
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="comment-toolbar-button"]'),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="ai-actions"]'),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="convertMarkdown"]'),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="editcaption"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="downloadfile"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -241,20 +289,66 @@ test.describe('Doc Editor', () => {
|
||||
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it cannot edit if viewer', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
user_role: 'reader',
|
||||
test('it cannot edit if viewer but see and can get resources', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1);
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await updateShareLink(page, 'Public', 'Reading');
|
||||
|
||||
// Close the modal
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
|
||||
const { otherPage, cleanup } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl: page.url(),
|
||||
withoutSignIn: true,
|
||||
docTitle,
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
await expect(
|
||||
otherPage.getByLabel('It is the card information').getByText('Reader'),
|
||||
).toBeVisible();
|
||||
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
// Cannot edit
|
||||
const editor = otherPage.locator('.ProseMirror');
|
||||
await expect(editor).toHaveAttribute('contenteditable', 'false');
|
||||
|
||||
// Owner add a image
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
await page.getByText('Upload image').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
|
||||
// Owner see the image
|
||||
await expect(
|
||||
page.locator('.--docs--editor-container img.bn-visual-media').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Viewser see the image
|
||||
const viewerImg = otherPage
|
||||
.locator('.--docs--editor-container img.bn-visual-media')
|
||||
.first();
|
||||
await expect(viewerImg).toBeVisible();
|
||||
|
||||
// Viewer can download the image
|
||||
await viewerImg.click();
|
||||
const downloadPromise = otherPage.waitForEvent('download');
|
||||
await otherPage.getByRole('button', { name: 'Download image' }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('logo-suite-numerique.png');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test('it adds an image to the doc editor', async ({ page, browserName }) => {
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(
|
||||
invitationCard.getByText('test.test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
|
||||
const invitationRole = invitationCard.getByTestId('doc-role-dropdown');
|
||||
await expect(invitationRole).toBeVisible();
|
||||
|
||||
await invitationRole.click();
|
||||
@@ -217,7 +217,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(invitationCard).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
const roles = memberCard.getByLabel('doc-role-dropdown');
|
||||
const roles = memberCard.getByTestId('doc-role-dropdown');
|
||||
await expect(memberCard).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByText('test.test@accesses.test').first(),
|
||||
|
||||
@@ -74,7 +74,7 @@ test.describe('Document create member', () => {
|
||||
await expect(list.getByText(email)).toBeVisible();
|
||||
|
||||
// Check roles are displayed
|
||||
await list.getByLabel('doc-role-dropdown').click();
|
||||
await list.getByTestId('doc-role-dropdown').click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
|
||||
@@ -84,7 +84,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Validate
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: /^Invite / }).click();
|
||||
await page.getByTestId('doc-share-invite-button').click();
|
||||
|
||||
// Check invitation added
|
||||
await expect(
|
||||
@@ -128,14 +128,14 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
await page.getByRole('button', { name: /^Invite / }).click();
|
||||
await page.getByTestId('doc-share-invite-button').click();
|
||||
|
||||
// Check invitation sent
|
||||
|
||||
@@ -146,7 +146,7 @@ test.describe('Document create member', () => {
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
@@ -154,7 +154,7 @@ test.describe('Document create member', () => {
|
||||
response.url().includes('/invitations/') && response.status() === 400,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Invite / }).click();
|
||||
await page.getByTestId('doc-share-invite-button').click();
|
||||
await expect(
|
||||
page.getByText(`"${email}" is already invited to the document.`),
|
||||
).toBeVisible();
|
||||
@@ -183,7 +183,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
@@ -191,7 +191,7 @@ test.describe('Document create member', () => {
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Invite / }).click();
|
||||
await page.getByTestId('doc-share-invite-button').click();
|
||||
|
||||
// Check invitation sent
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
@@ -210,18 +210,14 @@ test.describe('Document create member', () => {
|
||||
response.request().method() === 'PATCH',
|
||||
);
|
||||
|
||||
await userInvitation.getByLabel('doc-role-dropdown').click();
|
||||
await userInvitation.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Reader' }).click();
|
||||
|
||||
const responsePatchInvitation = await responsePromisePatchInvitation;
|
||||
expect(responsePatchInvitation.ok()).toBeTruthy();
|
||||
|
||||
const moreActions = userInvitation.getByRole('button', {
|
||||
name: 'Open invitation actions menu',
|
||||
});
|
||||
await moreActions.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await userInvitation.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Remove access' }).click();
|
||||
|
||||
await expect(userInvitation).toBeHidden();
|
||||
});
|
||||
@@ -272,7 +268,7 @@ test.describe('Document create member', () => {
|
||||
const container = page.getByTestId(
|
||||
`doc-share-access-request-row-${emailRequest}`,
|
||||
);
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await container.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ test.describe('Document list members', () => {
|
||||
const currentUser = list.getByTestId(
|
||||
`doc-share-member-row-user.test@${browserName}.test`,
|
||||
);
|
||||
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
|
||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||
await expect(currentUser).toBeVisible();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
await currentUserRole.click();
|
||||
@@ -169,7 +169,7 @@ test.describe('Document list members', () => {
|
||||
});
|
||||
const newUserEmail = await addNewMember(page, 0, 'Owner');
|
||||
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
|
||||
const newUserRoles = newUser.getByLabel('doc-role-dropdown');
|
||||
const newUserRoles = newUser.getByTestId('doc-role-dropdown');
|
||||
|
||||
await expect(newUser).toBeVisible();
|
||||
|
||||
@@ -214,9 +214,7 @@ test.describe('Document list members', () => {
|
||||
|
||||
const emailMyself = `user.test@${browserName}.test`;
|
||||
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
|
||||
const mySelfRole = mySelf.getByRole('button', {
|
||||
name: 'doc-role-dropdown',
|
||||
});
|
||||
const mySelfRole = mySelf.getByTestId('doc-role-dropdown');
|
||||
|
||||
const userOwnerEmail = await addNewMember(page, 0, 'Owner');
|
||||
const userOwner = list.getByTestId(
|
||||
@@ -231,9 +229,7 @@ test.describe('Document list members', () => {
|
||||
const userReader = list.getByTestId(
|
||||
`doc-share-member-row-${userReaderEmail}`,
|
||||
);
|
||||
const userReaderRole = userReader.getByRole('button', {
|
||||
name: 'doc-role-dropdown',
|
||||
});
|
||||
const userReaderRole = userReader.getByTestId('doc-role-dropdown');
|
||||
|
||||
await expect(mySelf).toBeVisible();
|
||||
await expect(userOwner).toBeVisible();
|
||||
|
||||
@@ -226,7 +226,7 @@ test.describe('Doc Tree', () => {
|
||||
const currentUser = list.getByTestId(
|
||||
`doc-share-member-row-user.test@${browserName}.test`,
|
||||
);
|
||||
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
|
||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
|
||||
@@ -156,9 +156,10 @@ test.describe('Header: Override configuration', () => {
|
||||
FRONTEND_THEME: 'dsfr',
|
||||
theme_customization: {
|
||||
header: {
|
||||
logo: {
|
||||
icon: {
|
||||
src: '/assets/logo-gouv.svg',
|
||||
width: '220px',
|
||||
height: 'auto',
|
||||
alt: '',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './utils-common';
|
||||
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
|
||||
|
||||
test.describe('Left panel desktop', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -10,8 +10,12 @@ test.describe('Left panel desktop', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
|
||||
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
|
||||
await expect(page.getByTestId('home-button')).toBeVisible();
|
||||
await expect(page.getByTestId('home-button')).toBeHidden();
|
||||
await expect(page.getByTestId('new-doc-button')).toBeVisible();
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.getByTestId('home-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks resize handle is present and functional on document page', async ({
|
||||
@@ -88,6 +92,9 @@ test.describe('Left panel mobile', () => {
|
||||
await expect(languageButton).not.toBeInViewport();
|
||||
await expect(logoutButton).not.toBeInViewport();
|
||||
|
||||
const title = await goToGridDoc(page);
|
||||
await verifyDocName(page, title);
|
||||
|
||||
await header.getByLabel('Open the header menu').click();
|
||||
|
||||
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
|
||||
|
||||
@@ -38,9 +38,9 @@ export const addNewMember = async (
|
||||
await page.getByRole('option', { name: users[index].email }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: role }).click();
|
||||
await page.getByRole('button', { name: /^Invite / }).click();
|
||||
await page.getByTestId('doc-share-invite-button').click();
|
||||
|
||||
return users[index].email;
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export const updateRoleUser = async (
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
|
||||
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
|
||||
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
|
||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: role }).click();
|
||||
await list.click();
|
||||
|
||||
@@ -1,57 +1,25 @@
|
||||
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
|
||||
import { defaultTokens } from '@openfun/cunningham-react';
|
||||
import merge from 'lodash/merge';
|
||||
|
||||
const customColors = {
|
||||
'primary-action': '#1212FF',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-focus': '#0A76F6',
|
||||
'secondary-icon': 'var(--c--theme--colors--primary-text)',
|
||||
'blue-400': '#7AB1E8',
|
||||
'blue-500': '#417DC4',
|
||||
'blue-600': '#3558A2',
|
||||
'brown-400': '#E6BE92',
|
||||
'brown-500': '#BD987A',
|
||||
'brown-600': '#745B47',
|
||||
'cyan-400': '#34BAB5',
|
||||
'cyan-500': '#009099',
|
||||
'cyan-600': '#006A6F',
|
||||
'gold-400': '#FFCA00',
|
||||
'gold-500': '#C3992A',
|
||||
'gold-600': '#695240',
|
||||
'green-400': '#34CB6A',
|
||||
'green-500': '#00A95F',
|
||||
'green-600': '#297254',
|
||||
'olive-400': '#99C221',
|
||||
'olive-500': '#68A532',
|
||||
'olive-600': '#447049',
|
||||
'orange-400': '#FF732C',
|
||||
'orange-500': '#E4794A',
|
||||
'orange-600': '#755348',
|
||||
'pink-400': '#FFB7AE',
|
||||
'pink-500': '#E18B76',
|
||||
'pink-600': '#8D533E',
|
||||
'purple-400': '#CE70CC',
|
||||
'purple-500': '#A558A0',
|
||||
'purple-600': '#6E445A',
|
||||
'yellow-400': '#D8C634',
|
||||
'yellow-500': '#B7A73F',
|
||||
'yellow-600': '#66673D',
|
||||
// Uikit does not provide the full list of tokens.
|
||||
// To be able to override correctly, we need to merge with the default tokens.
|
||||
let mergedColors = merge(
|
||||
defaultTokens.globals.colors,
|
||||
tokens.themes.default.globals.colors,
|
||||
);
|
||||
|
||||
mergedColors = {
|
||||
...mergedColors,
|
||||
'logo-1': '#2845C1',
|
||||
};
|
||||
|
||||
tokens.themes.default.theme = {
|
||||
...tokens.themes.default.theme,
|
||||
tokens.themes.default.globals = {
|
||||
...tokens.themes.default.globals,
|
||||
...{
|
||||
logo: {
|
||||
src: '',
|
||||
alt: '',
|
||||
widthHeader: '',
|
||||
widthFooter: '',
|
||||
},
|
||||
colors: {
|
||||
...tokens.themes.default.theme.colors,
|
||||
...customColors,
|
||||
},
|
||||
colors: mergedColors,
|
||||
font: {
|
||||
...tokens.themes.default.theme.font,
|
||||
...tokens.themes.default.globals.font,
|
||||
families: {
|
||||
base: 'sans-serif',
|
||||
accent: 'sans-serif',
|
||||
@@ -63,39 +31,29 @@ tokens.themes.default.theme = {
|
||||
tokens.themes.default.components = {
|
||||
...tokens.themes.default.components,
|
||||
...{
|
||||
logo: {
|
||||
src: '',
|
||||
alt: '',
|
||||
widthHeader: '',
|
||||
widthFooter: '',
|
||||
},
|
||||
'la-gaufre': false,
|
||||
'home-proconnect': false,
|
||||
'image-system-filter': '',
|
||||
icon: {
|
||||
src: '/assets/icon-docs.svg',
|
||||
width: '32px',
|
||||
height: 'auto',
|
||||
},
|
||||
favicon: {
|
||||
ico: '/assets/favicon-light.ico',
|
||||
'png-light': '/assets/favicon-light.png',
|
||||
'png-dark': '/assets/favicon-dark.png',
|
||||
},
|
||||
button: {
|
||||
...tokens.themes.default.components.button,
|
||||
primary: {
|
||||
...tokens.themes.default.components.button.primary,
|
||||
...{
|
||||
'background--disabled': 'var(--c--theme--colors--greyscale-100)',
|
||||
},
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dsfrTheme = {
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'secondary-icon': '#C9191E',
|
||||
},
|
||||
logo: {
|
||||
src: '/assets/logo-gouv.svg',
|
||||
widthHeader: '110px',
|
||||
widthFooter: '220px',
|
||||
alt: 'Gouvernement Logo',
|
||||
},
|
||||
globals: {
|
||||
font: {
|
||||
families: {
|
||||
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
|
||||
@@ -104,8 +62,19 @@ const dsfrTheme = {
|
||||
},
|
||||
},
|
||||
components: {
|
||||
logo: {
|
||||
src: '/assets/logo-gouv.svg',
|
||||
widthHeader: '110px',
|
||||
widthFooter: '220px',
|
||||
alt: 'Gouvernement Logo',
|
||||
},
|
||||
'la-gaufre': true,
|
||||
'home-proconnect': true,
|
||||
icon: {
|
||||
src: '/assets/icon-docs-dsfr.svg',
|
||||
width: '32px',
|
||||
height: 'auto',
|
||||
},
|
||||
favicon: {
|
||||
ico: '/assets/favicon-dsfr.ico',
|
||||
'png-light': '/assets/favicon-dsfr.png',
|
||||
@@ -117,131 +86,296 @@ const dsfrTheme = {
|
||||
|
||||
const genericTheme = {
|
||||
generic: {
|
||||
theme: {
|
||||
globals: {
|
||||
colors: {
|
||||
'primary-action': '#206EBD',
|
||||
'primary-focus': '#1E64BF',
|
||||
'primary-text': '#2E2C28',
|
||||
'primary-050': '#F8F8F7',
|
||||
'primary-100': '#F0EFEC',
|
||||
'primary-150': '#F4F4FD',
|
||||
'primary-200': '#E8E7E4',
|
||||
'primary-300': '#CFCDC9',
|
||||
'primary-400': '#979592',
|
||||
'primary-500': '#82807D',
|
||||
'primary-600': '#3F3D39',
|
||||
'primary-700': '#2E2C28',
|
||||
'primary-800': '#302E29',
|
||||
'primary-900': '#282622',
|
||||
'primary-950': '#201F1C',
|
||||
'secondary-text': '#fff',
|
||||
'secondary-50': '#F4F7FA',
|
||||
'secondary-100': '#D7E3EE',
|
||||
'secondary-200': '#B8CCE1',
|
||||
'secondary-300': '#99B4D3',
|
||||
'secondary-400': '#7595BE',
|
||||
'secondary-500': '#5874A0',
|
||||
'secondary-600': '#3A5383',
|
||||
'secondary-700': '#1E3462',
|
||||
'secondary-800': '#091B41',
|
||||
'secondary-900': '#08183B',
|
||||
'secondary-950': '#071636',
|
||||
'greyscale-text': '#3C3B38',
|
||||
'greyscale-000': '#fff',
|
||||
'greyscale-050': '#F8F7F7',
|
||||
'greyscale-100': '#F3F3F2',
|
||||
'greyscale-200': '#ECEBEA',
|
||||
'greyscale-250': '#E4E3E2',
|
||||
'greyscale-300': '#D3D2CF',
|
||||
'greyscale-350': '#eee',
|
||||
'greyscale-400': '#96948E',
|
||||
'greyscale-500': '#817E77',
|
||||
'greyscale-600': '#6A6862',
|
||||
'greyscale-700': '#3C3B38',
|
||||
'greyscale-750': '#383632',
|
||||
'greyscale-800': '#2D2B27',
|
||||
'greyscale-900': '#262522',
|
||||
'greyscale-950': '#201F1C',
|
||||
'greyscale-1000': '#181714',
|
||||
'success-text': '#234935',
|
||||
'success-50': '#F3FBF5',
|
||||
'success-100': '#E4F7EA',
|
||||
'success-200': '#CAEED4',
|
||||
'success-300': '#A0E0B5',
|
||||
'success-400': '#6CC88C',
|
||||
'success-500': '#6CC88C',
|
||||
'success-600': '#358D5C',
|
||||
'success-700': '#2D704B',
|
||||
'success-800': '#28583F',
|
||||
'success-900': '#234935',
|
||||
'success-950': '#0F281B',
|
||||
'info-text': '#212445',
|
||||
'info-50': '#F2F6FB',
|
||||
'info-100': '#E2E9F5',
|
||||
'info-200': '#CCD8EE',
|
||||
'info-300': '#A9C0E3',
|
||||
'info-400': '#809DD4',
|
||||
'info-500': '#617BC7',
|
||||
'info-600': '#4A5CBF',
|
||||
'info-700': '#3E49B2',
|
||||
'info-800': '#353C8F',
|
||||
'info-900': '#303771',
|
||||
'info-950': '#212445',
|
||||
'warning-text': '#D97C3A',
|
||||
'warning-50': '#FDF7F1',
|
||||
'warning-100': '#FBEDDC',
|
||||
'warning-200': '#F5D9B9',
|
||||
'warning-300': '#EDBE8C',
|
||||
'warning-400': '#E2985C',
|
||||
'warning-500': '#D97C3A',
|
||||
'warning-600': '#C96330',
|
||||
'warning-700': '#A34B32',
|
||||
'warning-800': '#813B2C',
|
||||
'warning-900': '#693327',
|
||||
'warning-950': '#381713',
|
||||
'danger-action': '#C0182A',
|
||||
'danger-text': '#FFF',
|
||||
'danger-050': '#FDF5F4',
|
||||
'danger-100': '#FBEBE8',
|
||||
'danger-200': '#F9E0DC',
|
||||
'danger-300': '#F3C3BD',
|
||||
'danger-400': '#E26552',
|
||||
'danger-500': '#C91F00',
|
||||
'danger-600': '#A71901',
|
||||
'danger-700': '#562C2B',
|
||||
'danger-800': '#392425',
|
||||
'danger-900': '#311F20',
|
||||
'danger-950': '#2A191A',
|
||||
'blue-400': '#8BAECC',
|
||||
'blue-500': '#567AA2',
|
||||
'blue-600': '#455784',
|
||||
'brown-400': '#E4C090',
|
||||
'brown-500': '#BA9977',
|
||||
'brown-600': '#735C45',
|
||||
'cyan-400': '#5CBEC9',
|
||||
'cyan-500': '#43A1B3',
|
||||
'cyan-600': '#39809B',
|
||||
'gold-400': '#ECBF50',
|
||||
'gold-500': '#DFA038',
|
||||
'gold-600': '#C17B31',
|
||||
'green-400': '#5DBD9A',
|
||||
'green-500': '#3AA183',
|
||||
'green-600': '#2A816D',
|
||||
'olive-400': '#AFD662',
|
||||
'olive-500': '#90BB4B',
|
||||
'olive-600': '#6E9441',
|
||||
'orange-400': '#E2985C',
|
||||
'orange-500': '#D97C3A',
|
||||
'orange-600': '#C96330',
|
||||
'pink-400': '#BE8FC8',
|
||||
'pink-500': '#A563B1',
|
||||
'pink-600': '#8B44A5',
|
||||
'purple-400': '#BE8FC8',
|
||||
'purple-500': '#A563B1',
|
||||
'purple-600': '#8B44A5',
|
||||
'yellow-400': '#EDC947',
|
||||
'yellow-500': '#DBB13A',
|
||||
'yellow-600': '#B88A34',
|
||||
'brand-050': '#EEF1FA',
|
||||
'brand-100': '#DDE2F5',
|
||||
'brand-150': '#CED3F1',
|
||||
'brand-200': '#BEC5F0',
|
||||
'brand-250': '#AFB5F1',
|
||||
'brand-300': '#A0A5F6',
|
||||
'brand-350': '#8F94FD',
|
||||
'brand-400': '#8184FC',
|
||||
'brand-450': '#7576EE',
|
||||
'brand-500': '#6969DF',
|
||||
'brand-550': '#5E5CD0',
|
||||
'brand-600': '#534FC2',
|
||||
'brand-650': '#4844AD',
|
||||
'brand-700': '#3E3B98',
|
||||
'brand-750': '#36347D',
|
||||
'brand-800': '#2D2F5F',
|
||||
'brand-850': '#262848',
|
||||
'brand-900': '#1C1E32',
|
||||
'brand-950': '#11131F',
|
||||
'gray-000': '#FFFFFF',
|
||||
'gray-025': '#F8F8F9',
|
||||
'gray-050': '#F0F0F3',
|
||||
'gray-100': '#E2E2EA',
|
||||
'gray-150': '#D3D4E0',
|
||||
'gray-200': '#C5C6D5',
|
||||
'gray-250': '#B7B7CB',
|
||||
'gray-300': '#A9A9BF',
|
||||
'gray-350': '#9C9CB2',
|
||||
'gray-400': '#8F8FA4',
|
||||
'gray-450': '#828297',
|
||||
'gray-500': '#75758A',
|
||||
'gray-550': '#69697D',
|
||||
'gray-600': '#5D5D70',
|
||||
'gray-650': '#515164',
|
||||
'gray-700': '#454558',
|
||||
'gray-750': '#3A3A4C',
|
||||
'gray-800': '#2F303D',
|
||||
'gray-850': '#25252F',
|
||||
'gray-900': '#1B1B23',
|
||||
'gray-950': '#111114',
|
||||
'gray-1000': '#000000',
|
||||
'info-050': '#EAF2F9',
|
||||
'info-100': '#D5E4F3',
|
||||
'info-150': '#BFD7F0',
|
||||
'info-200': '#A7CAEE',
|
||||
'info-250': '#8DBDEF',
|
||||
'info-300': '#6EB0F2',
|
||||
'info-350': '#50A2F5',
|
||||
'info-400': '#3593F4',
|
||||
'info-450': '#1185ED',
|
||||
'info-500': '#0077DE',
|
||||
'info-550': '#0069CF',
|
||||
'info-600': '#005BC0',
|
||||
'info-650': '#0D4EAA',
|
||||
'info-700': '#124394',
|
||||
'info-750': '#163878',
|
||||
'info-800': '#192F5A',
|
||||
'info-850': '#192541',
|
||||
'info-900': '#141B2D',
|
||||
'info-950': '#0C111C',
|
||||
'success-050': '#E8F1EA',
|
||||
'success-100': '#CFE4D4',
|
||||
'success-150': '#BAD9C1',
|
||||
'success-200': '#A2CFAD',
|
||||
'success-250': '#86C597',
|
||||
'success-300': '#6CBA83',
|
||||
'success-350': '#4FB070',
|
||||
'success-400': '#40A363',
|
||||
'success-450': '#309556',
|
||||
'success-500': '#1E884A',
|
||||
'success-550': '#027B3E',
|
||||
'success-600': '#016D31',
|
||||
'success-650': '#006024',
|
||||
'success-700': '#005317',
|
||||
'success-750': '#0D4511',
|
||||
'success-800': '#11380E',
|
||||
'success-850': '#132A11',
|
||||
'success-900': '#101E0F',
|
||||
'success-950': '#091209',
|
||||
'warning-050': '#F8F0E9',
|
||||
'warning-100': '#F1E0D3',
|
||||
'warning-150': '#ECD0BC',
|
||||
'warning-200': '#E8C0A4',
|
||||
'warning-250': '#E8AE8A',
|
||||
'warning-300': '#EB9970',
|
||||
'warning-350': '#E98456',
|
||||
'warning-400': '#E57036',
|
||||
'warning-450': '#DA5E18',
|
||||
'warning-500': '#CB5000',
|
||||
'warning-550': '#BC4200',
|
||||
'warning-600': '#AD3300',
|
||||
'warning-650': '#9E2300',
|
||||
'warning-700': '#882011',
|
||||
'warning-750': '#731E16',
|
||||
'warning-800': '#58201A',
|
||||
'warning-850': '#401D18',
|
||||
'warning-900': '#2E1714',
|
||||
'warning-950': '#1D0F0D',
|
||||
'error-050': '#F9EFEC',
|
||||
'error-100': '#F4DFD9',
|
||||
'error-150': '#F0CEC6',
|
||||
'error-200': '#EEBCB2',
|
||||
'error-250': '#EEA99D',
|
||||
'error-300': '#EF9486',
|
||||
'error-350': '#F37C6E',
|
||||
'error-400': '#F65F53',
|
||||
'error-450': '#F0463D',
|
||||
'error-500': '#E82322',
|
||||
'error-550': '#D7010E',
|
||||
'error-600': '#C00100',
|
||||
'error-650': '#AA0000',
|
||||
'error-700': '#910C06',
|
||||
'error-750': '#731E16',
|
||||
'error-800': '#58201A',
|
||||
'error-850': '#401D18',
|
||||
'error-900': '#2E1714',
|
||||
'error-950': '#1D0F0D',
|
||||
'red-050': '#FAEFEE',
|
||||
'red-100': '#F4DEDD',
|
||||
'red-150': '#F1CDCB',
|
||||
'red-200': '#EFBBBA',
|
||||
'red-250': '#EEA8A8',
|
||||
'red-300': '#F09394',
|
||||
'red-350': '#F37B7E',
|
||||
'red-400': '#EF6569',
|
||||
'red-450': '#E94A55',
|
||||
'red-500': '#DA3B49',
|
||||
'red-550': '#CA2A3C',
|
||||
'red-600': '#BB1330',
|
||||
'red-650': '#A90021',
|
||||
'red-700': '#910A13',
|
||||
'red-750': '#731E16',
|
||||
'red-800': '#58201A',
|
||||
'red-850': '#411D18',
|
||||
'red-900': '#2E1714',
|
||||
'red-950': '#1D0F0D',
|
||||
'orange-050': '#F8F0E9',
|
||||
'orange-100': '#F1E0D3',
|
||||
'orange-150': '#ECD0BD',
|
||||
'orange-200': '#EABFA6',
|
||||
'orange-250': '#EBAC90',
|
||||
'orange-300': '#EC9772',
|
||||
'orange-350': '#E5845A',
|
||||
'orange-400': '#D6774D',
|
||||
'orange-450': '#C86A40',
|
||||
'orange-500': '#B95D33',
|
||||
'orange-550': '#AB5025',
|
||||
'orange-600': '#9D4315',
|
||||
'orange-650': '#8F3600',
|
||||
'orange-700': '#812900',
|
||||
'orange-750': '#6C2511',
|
||||
'orange-800': '#572017',
|
||||
'orange-850': '#401D18',
|
||||
'orange-900': '#2E1714',
|
||||
'orange-950': '#1D0F0D',
|
||||
'brown-050': '#F6F0E8',
|
||||
'brown-100': '#F1E0D3',
|
||||
'brown-150': '#EBD0BA',
|
||||
'brown-200': '#E2C0A6',
|
||||
'brown-250': '#D4B398',
|
||||
'brown-300': '#C6A58B',
|
||||
'brown-350': '#B8987E',
|
||||
'brown-400': '#AA8B71',
|
||||
'brown-450': '#9D7E65',
|
||||
'brown-500': '#8F7158',
|
||||
'brown-550': '#82654C',
|
||||
'brown-600': '#765841',
|
||||
'brown-650': '#694C35',
|
||||
'brown-700': '#5D412A',
|
||||
'brown-750': '#51361E',
|
||||
'brown-800': '#452A13',
|
||||
'brown-850': '#392008',
|
||||
'brown-900': '#29180A',
|
||||
'brown-950': '#1B0F08',
|
||||
'yellow-050': '#F3F0E7',
|
||||
'yellow-100': '#E9E2CF',
|
||||
'yellow-150': '#E1D4B7',
|
||||
'yellow-200': '#D9C599',
|
||||
'yellow-250': '#D2B677',
|
||||
'yellow-300': '#CAA756',
|
||||
'yellow-350': '#C2972E',
|
||||
'yellow-400': '#B98900',
|
||||
'yellow-450': '#AB7B00',
|
||||
'yellow-500': '#9D6E00',
|
||||
'yellow-550': '#916100',
|
||||
'yellow-600': '#855400',
|
||||
'yellow-650': '#784700',
|
||||
'yellow-700': '#6C3A00',
|
||||
'yellow-750': '#5F2E00',
|
||||
'yellow-800': '#512302',
|
||||
'yellow-850': '#3E1D10',
|
||||
'yellow-900': '#2D1711',
|
||||
'yellow-950': '#1D0F0D',
|
||||
'green-050': '#E6F1E9',
|
||||
'green-100': '#CFE4D5',
|
||||
'green-150': '#B8D8C1',
|
||||
'green-200': '#A0CFAE',
|
||||
'green-250': '#84C59A',
|
||||
'green-300': '#65BA86',
|
||||
'green-350': '#45B173',
|
||||
'green-400': '#23A562',
|
||||
'green-450': '#029755',
|
||||
'green-500': '#008948',
|
||||
'green-550': '#017B3B',
|
||||
'green-600': '#006E2E',
|
||||
'green-650': '#006022',
|
||||
'green-700': '#005314',
|
||||
'green-750': '#0D4510',
|
||||
'green-800': '#11380E',
|
||||
'green-850': '#132A11',
|
||||
'green-900': '#101E0F',
|
||||
'green-950': '#091209',
|
||||
'blue1-050': '#EBF1F9',
|
||||
'blue1-100': '#D6E4F4',
|
||||
'blue1-150': '#C1D7F0',
|
||||
'blue1-200': '#AACAEF',
|
||||
'blue1-250': '#8FBCEF',
|
||||
'blue1-300': '#7CAFEB',
|
||||
'blue1-350': '#68A1E4',
|
||||
'blue1-400': '#5B94D6',
|
||||
'blue1-450': '#4E86C7',
|
||||
'blue1-500': '#4279B9',
|
||||
'blue1-550': '#356CAC',
|
||||
'blue1-600': '#28609E',
|
||||
'blue1-650': '#1B5390',
|
||||
'blue1-700': '#0B4783',
|
||||
'blue1-750': '#0F3C6E',
|
||||
'blue1-800': '#133059',
|
||||
'blue1-850': '#152641',
|
||||
'blue1-900': '#121C2D',
|
||||
'blue1-950': '#0B111C',
|
||||
'blue2-050': '#E7F3F4',
|
||||
'blue2-100': '#CEE7E9',
|
||||
'blue2-150': '#B2DCE0',
|
||||
'blue2-200': '#91D1D7',
|
||||
'blue2-250': '#68C7D0',
|
||||
'blue2-300': '#43BBC5',
|
||||
'blue2-350': '#00AFBA',
|
||||
'blue2-400': '#01A0AA',
|
||||
'blue2-450': '#00929D',
|
||||
'blue2-500': '#00848F',
|
||||
'blue2-550': '#007682',
|
||||
'blue2-600': '#016874',
|
||||
'blue2-650': '#005B67',
|
||||
'blue2-700': '#004E5A',
|
||||
'blue2-750': '#00424E',
|
||||
'blue2-800': '#003642',
|
||||
'blue2-850': '#002A38',
|
||||
'blue2-900': '#061E28',
|
||||
'blue2-950': '#071219',
|
||||
'purple-050': '#F7F0F6',
|
||||
'purple-100': '#EEE0EE',
|
||||
'purple-150': '#E7D1E7',
|
||||
'purple-200': '#DBBFE4',
|
||||
'purple-250': '#D3AEE2',
|
||||
'purple-300': '#CB99E1',
|
||||
'purple-350': '#C188D9',
|
||||
'purple-400': '#B47BCB',
|
||||
'purple-450': '#A66EBD',
|
||||
'purple-500': '#9961AF',
|
||||
'purple-550': '#8B55A1',
|
||||
'purple-600': '#7E4894',
|
||||
'purple-650': '#723C87',
|
||||
'purple-700': '#633376',
|
||||
'purple-750': '#552A65',
|
||||
'purple-800': '#452551',
|
||||
'purple-850': '#35213D',
|
||||
'purple-900': '#261A2C',
|
||||
'purple-950': '#17111C',
|
||||
'pink-050': '#F8EFF4',
|
||||
'pink-100': '#F0DFEA',
|
||||
'pink-150': '#EACEDF',
|
||||
'pink-200': '#E9BBD1',
|
||||
'pink-250': '#E9A7C2',
|
||||
'pink-300': '#E095B4',
|
||||
'pink-350': '#D685A8',
|
||||
'pink-400': '#C7799B',
|
||||
'pink-450': '#B86C8D',
|
||||
'pink-500': '#AA5F80',
|
||||
'pink-550': '#9C5374',
|
||||
'pink-600': '#8E4767',
|
||||
'pink-650': '#813B5B',
|
||||
'pink-700': '#732E4F',
|
||||
'pink-750': '#632643',
|
||||
'pink-800': '#521F38',
|
||||
'pink-850': '#3E1C2B',
|
||||
'pink-900': '#2D171F',
|
||||
'pink-950': '#1C0E12',
|
||||
},
|
||||
font: {
|
||||
families: {
|
||||
@@ -250,18 +384,6 @@ const genericTheme = {
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
button: {
|
||||
primary: {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-focus)',
|
||||
'color-active': 'var(--c--theme--colors--primary-focus)',
|
||||
'color-focus': 'var(--c--theme--colors--primary-focus)',
|
||||
},
|
||||
},
|
||||
},
|
||||
'image-system-filter': 'saturate(0.2)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.28",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.2",
|
||||
"@gouvfr-lasuite/ui-kit": "0.18.0",
|
||||
"@hocuspocus/provider": "3.4.0",
|
||||
"@mantine/core": "8.3.6",
|
||||
"@mantine/hooks": "8.3.6",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"@openfun/cunningham-react": "4.0.0",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.22.0",
|
||||
"@sentry/nextjs": "10.27.0",
|
||||
"@tanstack/react-query": "5.90.6",
|
||||
"@tiptap/extensions": "*",
|
||||
"canvg": "4.0.3",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
12
src/frontend/apps/impress/public/assets/icon-docs-dsfr.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
|
||||
fill="#C9191E"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.58203 26.405V7.5977C4.58203 6.45251 4.88938 5.58519 5.50408 4.99575C6.1272 4.40631 6.95242 4.08212 7.97972 4.02318C9.49542 3.93055 10.9311 3.80425 12.2868 3.64425C13.6425 3.47584 14.9393 3.28217 16.1771 3.06324C17.4234 2.8443 18.6359 2.60011 19.8148 2.33065C21.0274 2.04435 21.9578 2.1875 22.6062 2.7601C23.2546 3.33269 23.5788 4.24632 23.5788 5.50099V23.9925C23.5788 25.0956 23.3893 25.9166 23.0104 26.4555C22.6315 27.0029 21.9915 27.4028 21.0905 27.6554C19.4906 28.0933 17.9833 28.4386 16.5687 28.6912C15.154 28.9522 13.7731 29.1501 12.4258 29.2848C11.0785 29.4196 9.69751 29.5248 8.28286 29.6006C7.11241 29.668 6.20299 29.4238 5.5546 28.868C4.90622 28.3207 4.58203 27.4997 4.58203 26.405ZM9.20865 11.0124C11.0635 10.8944 12.7632 10.7131 14.3075 10.4683C14.6822 10.4072 15.0564 10.3436 15.4291 10.2776C15.8192 10.2085 16.1013 9.86859 16.1013 9.47337C16.1013 8.96154 15.638 8.57609 15.135 8.66189C14.846 8.71118 14.5555 8.75909 14.2635 8.80562C12.7346 9.04923 11.0452 9.22998 9.19523 9.3477C8.91819 9.36558 8.69776 9.45188 8.55608 9.62391C8.42209 9.78661 8.35645 9.98229 8.35645 10.2053C8.35645 10.4321 8.43296 10.6295 8.58568 10.7918L8.58783 10.7939C8.75336 10.9595 8.96369 11.0311 9.20865 11.0124ZM9.20801 15.206C11.0631 15.088 12.763 14.9066 14.3075 14.6619C15.8588 14.4089 17.3936 14.1138 18.9112 13.7766C19.2191 13.7081 19.4498 13.6003 19.5652 13.433C19.6786 13.2721 19.7347 13.0876 19.7347 12.8832C19.7347 12.6526 19.6469 12.454 19.476 12.2926C19.2921 12.1189 19.0348 12.0784 18.7304 12.1411L18.7285 12.1415C17.2823 12.4694 15.794 12.7553 14.2635 12.9992C12.7346 13.2428 11.0452 13.4235 9.19523 13.5413C8.91819 13.5591 8.69776 13.6454 8.55608 13.8175C8.42276 13.9794 8.35645 14.1705 8.35645 14.3863C8.35645 14.6203 8.43209 14.8223 8.58558 14.9854L8.59 14.9896C8.75499 15.1449 8.96316 15.2155 9.20551 15.2062L9.20801 15.206ZM9.20847 19.3994C11.0634 19.2729 12.7631 19.0874 14.3075 18.8427C15.8589 18.5982 17.3934 18.3073 18.9112 17.97C19.2199 17.9014 19.4508 17.7891 19.566 17.6127C19.6783 17.4529 19.7347 17.2733 19.7347 17.0766C19.7347 16.8461 19.6469 16.6474 19.476 16.4861C19.2921 16.3123 19.0348 16.2718 18.7304 16.3345L18.729 16.3348C17.2827 16.6543 15.7942 16.9361 14.2635 17.18C12.7345 17.4236 11.045 17.6086 9.19495 17.7347C8.91804 17.7526 8.69771 17.8389 8.55608 18.0109C8.42276 18.1728 8.35645 18.3639 8.35645 18.5797C8.35645 18.8137 8.43209 19.0158 8.58558 19.1789L8.59 19.183C8.75499 19.3383 8.96316 19.4089 9.20551 19.3996L9.20847 19.3994ZM14.3075 23.007C12.7632 23.2518 11.0635 23.4331 9.20867 23.5512C8.9637 23.5698 8.75337 23.4982 8.58783 23.3326L8.58572 23.3305C8.433 23.1682 8.35645 22.9708 8.35645 22.7441C8.35645 22.521 8.42209 22.3253 8.55608 22.1626C8.69776 21.9906 8.91827 21.9043 9.19531 21.8864C11.0453 21.7687 12.7346 21.588 14.2635 21.3443C14.5555 21.2978 14.846 21.2499 15.135 21.2006C15.638 21.1148 16.1013 21.5003 16.1013 22.0121C16.1013 22.4073 15.8192 22.7472 15.4291 22.8163C15.0564 22.8823 14.6822 22.9459 14.3075 23.007Z"
|
||||
fill="#2845C1"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -1,12 +1,4 @@
|
||||
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
|
||||
fill="#C9191E"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.58203 26.405V7.5977C4.58203 6.45251 4.88938 5.58519 5.50408 4.99575C6.1272 4.40631 6.95242 4.08212 7.97972 4.02318C9.49542 3.93055 10.9311 3.80425 12.2868 3.64425C13.6425 3.47584 14.9393 3.28217 16.1771 3.06324C17.4234 2.8443 18.6359 2.60011 19.8148 2.33065C21.0274 2.04435 21.9578 2.1875 22.6062 2.7601C23.2546 3.33269 23.5788 4.24632 23.5788 5.50099V23.9925C23.5788 25.0956 23.3893 25.9166 23.0104 26.4555C22.6315 27.0029 21.9915 27.4028 21.0905 27.6554C19.4906 28.0933 17.9833 28.4386 16.5687 28.6912C15.154 28.9522 13.7731 29.1501 12.4258 29.2848C11.0785 29.4196 9.69751 29.5248 8.28286 29.6006C7.11241 29.668 6.20299 29.4238 5.5546 28.868C4.90622 28.3207 4.58203 27.4997 4.58203 26.405ZM9.20865 11.0124C11.0635 10.8944 12.7632 10.7131 14.3075 10.4683C14.6822 10.4072 15.0564 10.3436 15.4291 10.2776C15.8192 10.2085 16.1013 9.86859 16.1013 9.47337C16.1013 8.96154 15.638 8.57609 15.135 8.66189C14.846 8.71118 14.5555 8.75909 14.2635 8.80562C12.7346 9.04923 11.0452 9.22998 9.19523 9.3477C8.91819 9.36558 8.69776 9.45188 8.55608 9.62391C8.42209 9.78661 8.35645 9.98229 8.35645 10.2053C8.35645 10.4321 8.43296 10.6295 8.58568 10.7918L8.58783 10.7939C8.75336 10.9595 8.96369 11.0311 9.20865 11.0124ZM9.20801 15.206C11.0631 15.088 12.763 14.9066 14.3075 14.6619C15.8588 14.4089 17.3936 14.1138 18.9112 13.7766C19.2191 13.7081 19.4498 13.6003 19.5652 13.433C19.6786 13.2721 19.7347 13.0876 19.7347 12.8832C19.7347 12.6526 19.6469 12.454 19.476 12.2926C19.2921 12.1189 19.0348 12.0784 18.7304 12.1411L18.7285 12.1415C17.2823 12.4694 15.794 12.7553 14.2635 12.9992C12.7346 13.2428 11.0452 13.4235 9.19523 13.5413C8.91819 13.5591 8.69776 13.6454 8.55608 13.8175C8.42276 13.9794 8.35645 14.1705 8.35645 14.3863C8.35645 14.6203 8.43209 14.8223 8.58558 14.9854L8.59 14.9896C8.75499 15.1449 8.96316 15.2155 9.20551 15.2062L9.20801 15.206ZM9.20847 19.3994C11.0634 19.2729 12.7631 19.0874 14.3075 18.8427C15.8589 18.5982 17.3934 18.3073 18.9112 17.97C19.2199 17.9014 19.4508 17.7891 19.566 17.6127C19.6783 17.4529 19.7347 17.2733 19.7347 17.0766C19.7347 16.8461 19.6469 16.6474 19.476 16.4861C19.2921 16.3123 19.0348 16.2718 18.7304 16.3345L18.729 16.3348C17.2827 16.6543 15.7942 16.9361 14.2635 17.18C12.7345 17.4236 11.045 17.6086 9.19495 17.7347C8.91804 17.7526 8.69771 17.8389 8.55608 18.0109C8.42276 18.1728 8.35645 18.3639 8.35645 18.5797C8.35645 18.8137 8.43209 19.0158 8.58558 19.1789L8.59 19.183C8.75499 19.3383 8.96316 19.4089 9.20551 19.3996L9.20847 19.3994ZM14.3075 23.007C12.7632 23.2518 11.0635 23.4331 9.20867 23.5512C8.9637 23.5698 8.75337 23.4982 8.58783 23.3326L8.58572 23.3305C8.433 23.1682 8.35645 22.9708 8.35645 22.7441C8.35645 22.521 8.42209 22.3253 8.55608 22.1626C8.69776 21.9906 8.91827 21.9043 9.19531 21.8864C11.0453 21.7687 12.7346 21.588 14.2635 21.3443C14.5555 21.2978 14.846 21.2499 15.135 21.2006C15.638 21.1148 16.1013 21.5003 16.1013 22.0121C16.1013 22.4073 15.8192 22.7472 15.4291 22.8163C15.0564 22.8823 14.6822 22.9459 14.3075 23.007Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.6305 28.8312C22.7983 28.5038 23.9166 27.9062 24.6505 26.8503C25.3749 25.8163 25.5789 24.5047 25.5789 23.2425V4.75099C25.5789 4.42358 25.5611 4.09557 25.5216 3.77148C26.1016 3.99961 26.5486 4.37658 26.8626 4.90239C27.2331 5.50024 27.4184 6.28757 27.4184 7.26435V26.0464C27.4184 27.3684 27.0942 28.3578 26.4458 29.0146C25.7974 29.6714 24.8207 29.9998 23.5155 29.9998H16.4209C16.5889 29.9704 16.7574 29.9401 16.9262 29.909C18.4067 29.6444 19.9713 29.2854 21.6185 28.8346L21.6305 28.8312Z" fill="#4747A3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58203 25.655V6.8477C4.58203 5.70251 4.88938 4.83519 5.50408 4.24575C6.1272 3.65631 6.95242 3.33212 7.97972 3.27318C9.49542 3.18055 10.9311 3.05425 12.2868 2.89425C13.6425 2.72584 14.9393 2.53217 16.1771 2.31324C17.4234 2.0943 18.6359 1.85011 19.8148 1.58065C21.0274 1.29435 21.9578 1.4375 22.6062 2.0101C23.2546 2.58269 23.5788 3.49632 23.5788 4.75099V23.2425C23.5788 24.3456 23.3893 25.1666 23.0104 25.7055C22.6315 26.2529 21.9915 26.6528 21.0905 26.9054C19.4906 27.3433 17.9833 27.6886 16.5687 27.9412C15.154 28.2022 13.7731 28.4001 12.4258 28.5348C11.0785 28.6696 9.69751 28.7748 8.28286 28.8506C7.11241 28.918 6.20299 28.6738 5.5546 28.118C4.90622 27.5707 4.58203 26.7497 4.58203 25.655ZM9.20865 10.2624C11.0635 10.1444 12.7632 9.96305 14.3075 9.71831C14.6822 9.65722 15.0564 9.5936 15.4291 9.52759C15.8192 9.45851 16.1013 9.11859 16.1013 8.72337C16.1013 8.21154 15.638 7.82609 15.135 7.91189C14.846 7.96118 14.5555 8.00909 14.2635 8.05562C12.7346 8.29923 11.0452 8.47998 9.19523 8.5977C8.91819 8.61558 8.69776 8.70188 8.55608 8.87391C8.42209 9.03661 8.35645 9.23229 8.35645 9.45535C8.35645 9.68212 8.43296 9.87951 8.58568 10.0418L8.58783 10.0439C8.75336 10.2095 8.96369 10.2811 9.20865 10.2624ZM9.20801 14.456C11.0631 14.338 12.763 14.1566 14.3075 13.9119C15.8588 13.6589 17.3936 13.3638 18.9112 13.0266C19.2191 12.9581 19.4498 12.8503 19.5652 12.683C19.6786 12.5221 19.7347 12.3376 19.7347 12.1332C19.7347 11.9026 19.6469 11.704 19.476 11.5426C19.2921 11.3689 19.0348 11.3284 18.7304 11.3911L18.7285 11.3915C17.2823 11.7194 15.794 12.0053 14.2635 12.2492C12.7346 12.4928 11.0452 12.6735 9.19523 12.7913C8.91819 12.8091 8.69776 12.8954 8.55608 13.0675C8.42276 13.2294 8.35645 13.4205 8.35645 13.6363C8.35645 13.8703 8.43209 14.0723 8.58558 14.2354L8.59 14.2396C8.75499 14.3949 8.96316 14.4655 9.20551 14.4562L9.20801 14.456ZM9.20847 18.6494C11.0634 18.5229 12.7631 18.3374 14.3075 18.0927C15.8589 17.8482 17.3934 17.5573 18.9112 17.22C19.2199 17.1514 19.4508 17.0391 19.566 16.8627C19.6783 16.7029 19.7347 16.5233 19.7347 16.3266C19.7347 16.0961 19.6469 15.8974 19.476 15.7361C19.2921 15.5623 19.0348 15.5218 18.7304 15.5845L18.729 15.5848C17.2827 15.9043 15.7942 16.1861 14.2635 16.43C12.7345 16.6736 11.045 16.8586 9.19495 16.9847C8.91804 17.0026 8.69771 17.0889 8.55608 17.2609C8.42276 17.4228 8.35645 17.6139 8.35645 17.8297C8.35645 18.0637 8.43209 18.2658 8.58558 18.4289L8.59 18.433C8.75499 18.5883 8.96316 18.6589 9.20551 18.6496L9.20847 18.6494ZM14.3075 22.257C12.7632 22.5018 11.0635 22.6831 9.20867 22.8012C8.9637 22.8198 8.75337 22.7482 8.58783 22.5826L8.58572 22.5805C8.433 22.4182 8.35645 22.2208 8.35645 21.9941C8.35645 21.771 8.42209 21.5753 8.55608 21.4126C8.69776 21.2406 8.91827 21.1543 9.19531 21.1364C11.0453 21.0187 12.7346 20.838 14.2635 20.5943C14.5555 20.5478 14.846 20.4999 15.135 20.4506C15.638 20.3648 16.1013 20.7503 16.1013 21.2621C16.1013 21.6573 15.8192 21.9972 15.4291 22.0663C15.0564 22.1323 14.6822 22.1959 14.3075 22.257Z" fill="#4747A3"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
|
||||
fill="var(--c--theme--colors--secondary-icon)"
|
||||
fill="var(--c--globals--colors--red-600)"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -14,6 +14,7 @@ export interface BoxProps {
|
||||
as?: HTMLElementType;
|
||||
$align?: CSSProperties['alignItems'];
|
||||
$background?: CSSProperties['background'];
|
||||
$border?: CSSProperties['border'];
|
||||
$color?: CSSProperties['color'];
|
||||
$css?: string | RuleSet<object>;
|
||||
$cursor?: CSSProperties['cursor'];
|
||||
@@ -36,41 +37,34 @@ export interface BoxProps {
|
||||
$position?: CSSProperties['position'];
|
||||
$radius?: CSSProperties['borderRadius'];
|
||||
$shrink?: CSSProperties['flexShrink'];
|
||||
$transition?: CSSProperties['transition'];
|
||||
$width?: CSSProperties['width'];
|
||||
$zIndex?: CSSProperties['zIndex'];
|
||||
$wrap?: CSSProperties['flexWrap'];
|
||||
// Theming props
|
||||
$layer?: 'background' | 'content' | 'border';
|
||||
$theme?:
|
||||
| 'primary'
|
||||
| 'primary-text'
|
||||
| 'secondary'
|
||||
| 'secondary-text'
|
||||
| 'brand'
|
||||
| 'error'
|
||||
| 'gray'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'greyscale';
|
||||
$transition?: CSSProperties['transition'];
|
||||
$variation?:
|
||||
| 'text'
|
||||
| '000'
|
||||
| '100'
|
||||
| '200'
|
||||
| '300'
|
||||
| '400'
|
||||
| '500'
|
||||
| '600'
|
||||
| '700'
|
||||
| '800'
|
||||
| '900'
|
||||
| '1000';
|
||||
$width?: CSSProperties['width'];
|
||||
$wrap?: CSSProperties['flexWrap'];
|
||||
$zIndex?: CSSProperties['zIndex'];
|
||||
| 'neutral'
|
||||
| 'contextual'
|
||||
| 'disabled'
|
||||
| (string & {});
|
||||
$scope?: 'surface' | 'semantic' | 'palette' | (string & {});
|
||||
$variation?: 'primary' | 'secondary' | 'tertiary' | (string & {});
|
||||
$withThemeBG?: boolean;
|
||||
$withThemeBorder?: boolean;
|
||||
$withThemeInherited?: boolean;
|
||||
}
|
||||
|
||||
export type BoxType = ComponentPropsWithRef<typeof Box>;
|
||||
|
||||
export const Box = styled('div')<BoxProps>`
|
||||
${({ $align }) => $align && `align-items: ${$align};`}
|
||||
${({ $background }) => $background && `background: ${$background};`}
|
||||
${({ $color }) => $color && `color: ${$color};`}
|
||||
${({ $cursor }) => $cursor && `cursor: ${$cursor};`}
|
||||
${({ $direction }) => `flex-direction: ${$direction || 'column'};`}
|
||||
${({ $display, as }) =>
|
||||
@@ -80,9 +74,9 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $height }) => $height && `height: ${$height};`}
|
||||
${({ $hasTransition }) =>
|
||||
$hasTransition && $hasTransition === 'slow'
|
||||
? `transition: all 0.5s ease-in-out;`
|
||||
? `transition: all 0.5s var(--c--globals--transitions--ease-out);`
|
||||
: $hasTransition
|
||||
? `transition: all 0.3s ease-in-out;`
|
||||
? `transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);`
|
||||
: ''}
|
||||
${({ $justify }) => $justify && `justify-content: ${$justify};`}
|
||||
${({ $margin }) => $margin && stylesMargin($margin)}
|
||||
@@ -96,11 +90,85 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $position }) => $position && `position: ${$position};`}
|
||||
${({ $radius }) => $radius && `border-radius: ${$radius};`}
|
||||
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
|
||||
${({ $theme, $variation }) => {
|
||||
if (!$theme || !$variation) {
|
||||
${({
|
||||
$layer = 'border',
|
||||
$theme = 'brand',
|
||||
$variation = 'primary',
|
||||
$scope = 'semantic',
|
||||
$border,
|
||||
$withThemeBorder,
|
||||
$withThemeInherited,
|
||||
}) => {
|
||||
if ($border) {
|
||||
return `border: ${$border};`;
|
||||
}
|
||||
|
||||
if (!$layer || !$scope || !$theme || !$withThemeBorder) {
|
||||
return '';
|
||||
}
|
||||
return `color: var(--c--theme--colors--${$theme}-${$variation});`;
|
||||
|
||||
if ($withThemeInherited) {
|
||||
return `border: inherit;`;
|
||||
}
|
||||
|
||||
return `border: 1px solid var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
|
||||
}}
|
||||
${({
|
||||
$layer = 'background',
|
||||
$theme = 'brand',
|
||||
$variation = 'primary',
|
||||
$scope = 'semantic',
|
||||
$background,
|
||||
$withThemeBG,
|
||||
$withThemeInherited,
|
||||
}) => {
|
||||
if ($background) {
|
||||
return `background: ${$background};`;
|
||||
}
|
||||
|
||||
if (!$layer || !$scope || !$theme || !$withThemeBG) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($withThemeInherited) {
|
||||
return `background: inherit;`;
|
||||
}
|
||||
|
||||
return `background: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
|
||||
}}
|
||||
${({
|
||||
$layer = 'content',
|
||||
$theme = 'neutral',
|
||||
$variation = 'primary',
|
||||
$scope = 'semantic',
|
||||
$color,
|
||||
$withThemeBG,
|
||||
$withThemeInherited,
|
||||
}) => {
|
||||
if ($color) {
|
||||
return `color: ${$color};`;
|
||||
}
|
||||
|
||||
if (!$layer || !$scope) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// There is a special case when primary with background
|
||||
if (
|
||||
$withThemeBG &&
|
||||
$layer === 'content' &&
|
||||
$scope === 'semantic' &&
|
||||
$variation === 'primary' &&
|
||||
$theme
|
||||
) {
|
||||
$variation = `on-${$theme}`;
|
||||
}
|
||||
|
||||
if ($withThemeInherited) {
|
||||
return `color: inherit;`;
|
||||
}
|
||||
|
||||
return `color: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
|
||||
}}
|
||||
${({ $transition }) => $transition && `transition: ${$transition};`}
|
||||
${({ $width }) => $width && `width: ${$width};`}
|
||||
@@ -121,7 +189,7 @@ export const Box = styled('div')<BoxProps>`
|
||||
return (
|
||||
effect &&
|
||||
`
|
||||
transition: all 0.3s ease-in-out;
|
||||
transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);
|
||||
${effect}
|
||||
`
|
||||
);
|
||||
|
||||
@@ -24,8 +24,8 @@ export type BoxButtonType = BoxType & {
|
||||
*/
|
||||
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
||||
({ $css, ...props }, ref) => {
|
||||
const theme = props.$theme || 'greyscale';
|
||||
const variation = props.$variation || '400';
|
||||
const theme = props.$theme || 'gray';
|
||||
const variation = props.$variation || 'primary';
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -40,15 +40,14 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
color: ${props.disabled
|
||||
? `var(--c--theme--colors--${theme}-400) !important`
|
||||
: `inherit`};
|
||||
|
||||
color: ${props.disabled &&
|
||||
`var(--c--contextuals--content--semantic--disabled--primary)`};
|
||||
&:focus-visible {
|
||||
transition: none;
|
||||
outline: 2px solid var(--c--theme--colors--${theme}-${variation});
|
||||
outline: 2px solid
|
||||
var(--c--contextuals--content--semantic--${theme}--${variation});
|
||||
border-radius: 1px;
|
||||
outline-offset: 4px;
|
||||
outline-offset: var(--c--globals--spacings--st);
|
||||
}
|
||||
${$css || ''}
|
||||
`}
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Box, BoxType } from '.';
|
||||
|
||||
export const Card = ({
|
||||
children,
|
||||
className,
|
||||
$css,
|
||||
...props
|
||||
}: PropsWithChildren<BoxType>) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
$withThemeBG
|
||||
$withThemeBorder
|
||||
className={`--docs--card ${className || ''}`}
|
||||
$background="white"
|
||||
$radius="4px"
|
||||
$css={css`
|
||||
border: 1px solid ${colorsTokens['greyscale-200']};
|
||||
${$css}
|
||||
`}
|
||||
$radius="var(--c--globals--spacings--st)"
|
||||
$padding={{ horizontal: 'xs', vertical: '3xs' }}
|
||||
$scope={props.$theme ? props.$scope || 'semantic' : 'surface'}
|
||||
$theme={props.$theme || 'primary'}
|
||||
$variation={props.$theme ? props.$variation || 'tertiary' : ''}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -14,10 +14,11 @@ import { BoxProps } from './Box';
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #dddddd;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
box-shadow: 0 0 6px 0 rgba(0, 0, 145, 0.1);
|
||||
border: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
transition: opacity var(--c--globals--transitions--duration)
|
||||
var(--c--globals--transitions--ease-out);
|
||||
`;
|
||||
|
||||
interface StyledButtonProps {
|
||||
@@ -28,18 +29,22 @@ const StyledButton = styled(Button)<StyledButtonProps>`
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
font-weight: var(--c--components--button--font-weight);
|
||||
font-size: var(--c--components--button--medium-font-size);
|
||||
padding: var(--c--globals--spacings--0);
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-hover
|
||||
--c--contextuals--background--semantic--contextual--primary
|
||||
);
|
||||
}
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400);
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--brand--tertiary-hover
|
||||
);
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
${({ $css }) => $css};
|
||||
`;
|
||||
|
||||
@@ -13,7 +13,7 @@ export const Icon = ({
|
||||
iconName,
|
||||
disabled,
|
||||
variant = 'outlined',
|
||||
$variation = 'text',
|
||||
$theme = 'neutral',
|
||||
...textProps
|
||||
}: IconProps) => {
|
||||
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
|
||||
@@ -28,7 +28,7 @@ export const Icon = ({
|
||||
'material-icons': variant === 'outlined',
|
||||
'material-symbols-outlined': variant === 'symbols-outlined',
|
||||
})}
|
||||
$variation={disabled ? '300' : $variation}
|
||||
$theme={disabled ? 'disabled' : $theme}
|
||||
aria-disabled={disabled}
|
||||
{...textProps}
|
||||
>
|
||||
|
||||
@@ -39,7 +39,8 @@ export const InfiniteScroll = ({
|
||||
{!isLoading && hasMore && (
|
||||
<Button
|
||||
onClick={() => void next()}
|
||||
color="primary-text"
|
||||
color="brand"
|
||||
variant="bordered"
|
||||
icon={<Icon iconName="arrow_downward" />}
|
||||
>
|
||||
{buttonLabel ?? t('Load more')}
|
||||
|
||||
@@ -24,12 +24,12 @@ export const LoadMoreText = ({
|
||||
className="--docs--load-more"
|
||||
>
|
||||
<Icon
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
$theme="brand"
|
||||
$variation="secondary"
|
||||
iconName="arrow_downward"
|
||||
$size="md"
|
||||
/>
|
||||
<Text $theme="primary" $variation="800">
|
||||
<Text $theme="brand" $variation="secondary">
|
||||
{t('Load more')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tokens } from '@/cunningham';
|
||||
|
||||
import { Box, BoxProps } from './Box';
|
||||
|
||||
const { sizes } = tokens.themes.default.theme.font;
|
||||
const { sizes } = tokens.themes.default.globals.font;
|
||||
type TextSizes = keyof typeof sizes;
|
||||
|
||||
export interface TextProps extends BoxProps {
|
||||
@@ -27,24 +27,14 @@ export const TextStyled = styled(Box)<TextProps>`
|
||||
${({ $size }) =>
|
||||
$size &&
|
||||
`font-size: ${$size in sizes ? sizes[$size as TextSizes] : $size};`}
|
||||
${({ $color }) => $color && `color: ${$color};`}
|
||||
${({ $ellipsis }) =>
|
||||
$ellipsis &&
|
||||
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
|
||||
`;
|
||||
|
||||
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TextStyled
|
||||
ref={ref}
|
||||
as="span"
|
||||
$theme="greyscale"
|
||||
$variation="text"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
(props, ref) => {
|
||||
return <TextStyled ref={ref} as="span" {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Box, Text, TextType } from '@/components';
|
||||
|
||||
const AlertStyled = styled(Alert)`
|
||||
& .c__button--tertiary:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-200);
|
||||
background-color: var(--c--globals--colors--gray-200);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -54,8 +54,7 @@ export const TextOnlyErrors = ({
|
||||
causes.map((cause, i) => (
|
||||
<Text
|
||||
key={`causes-${i}`}
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$theme="error"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
@@ -64,12 +63,7 @@ export const TextOnlyErrors = ({
|
||||
))}
|
||||
|
||||
{!causes && (
|
||||
<Text
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
<Text $theme="error" $textAlign="center" {...textProps}>
|
||||
{defaultMessage || t('Something bad happens, please retry.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -124,18 +124,19 @@ export const DropdownMenu = ({
|
||||
>
|
||||
<Box>{children}</Box>
|
||||
<Icon
|
||||
$variation="600"
|
||||
$css={
|
||||
arrowCss ??
|
||||
css`
|
||||
color: var(--c--theme--colors--primary-600);
|
||||
color: var(--c--globals--colors--brand-600);
|
||||
`
|
||||
}
|
||||
iconName={isOpen ? 'arrow_drop_up' : 'arrow_drop_down'}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box ref={blockButtonRef}>{children}</Box>
|
||||
<Box ref={blockButtonRef} $color="inherit">
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -147,7 +148,6 @@ export const DropdownMenu = ({
|
||||
>
|
||||
{topMessage && (
|
||||
<Text
|
||||
$variation="700"
|
||||
$wrap="wrap"
|
||||
$size="xs"
|
||||
$weight="bold"
|
||||
@@ -186,8 +186,8 @@ export const DropdownMenu = ({
|
||||
key={option.label}
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$background={colorsTokens['greyscale-000']}
|
||||
$color={colorsTokens['primary-600']}
|
||||
$background={colorsTokens['gray-000']}
|
||||
$color={colorsTokens['brand-600']}
|
||||
$padding={{ vertical: 'xs', horizontal: 'base' }}
|
||||
$width="100%"
|
||||
$gap={spacingsTokens['base']}
|
||||
@@ -200,30 +200,39 @@ export const DropdownMenu = ({
|
||||
`}
|
||||
${index === options.length - 1 &&
|
||||
css`
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-left-radius: var(--c--globals--spacings--st);
|
||||
border-bottom-right-radius: var(--c--globals--spacings--st);
|
||||
`}
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
font-weight: 500;
|
||||
font-size: var(--c--globals--font--sizes--sm);
|
||||
color: var(--c--globals--colors--gray-1000);
|
||||
font-weight: var(--c--globals--font--weights--medium);
|
||||
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--contextual--primary
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--c--theme--colors--primary-400);
|
||||
outline: 2px solid var(--c--globals--colors--brand-400);
|
||||
outline-offset: -2px;
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--contextual--primary
|
||||
);
|
||||
}
|
||||
|
||||
${isFocused &&
|
||||
/**
|
||||
* TODO: This part seems to have a problem with DocToolBox
|
||||
*/
|
||||
/* ${isFocused &&
|
||||
css`
|
||||
outline-offset: -2px;
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
`}
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--contextual--primary
|
||||
);
|
||||
`} */
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
@@ -234,8 +243,8 @@ export const DropdownMenu = ({
|
||||
{option.icon && typeof option.icon === 'string' && (
|
||||
<Icon
|
||||
$size="20px"
|
||||
$theme="greyscale"
|
||||
$variation={isDisabled ? '400' : '1000'}
|
||||
$theme="gray"
|
||||
$variation={isDisabled ? 'tertiary' : 'primary'}
|
||||
iconName={option.icon}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -243,7 +252,7 @@ export const DropdownMenu = ({
|
||||
{option.icon &&
|
||||
typeof option.icon !== 'string' &&
|
||||
option.icon}
|
||||
<Text $variation={isDisabled ? '400' : '1000'}>
|
||||
<Text $variation={isDisabled ? 'tertiary' : 'primary'}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -252,7 +261,7 @@ export const DropdownMenu = ({
|
||||
<Icon
|
||||
iconName="check"
|
||||
$size="20px"
|
||||
$theme="greyscale"
|
||||
$theme="gray"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -33,32 +33,27 @@ export const FilterDropdown = ({
|
||||
<Box
|
||||
$css={css`
|
||||
border: 1px solid
|
||||
${selectedOption
|
||||
? 'var(--c--theme--colors--primary-500)'
|
||||
: 'var(--c--theme--colors--greyscale-250)'};
|
||||
border-radius: 4px;
|
||||
background-color: ${selectedOption
|
||||
? 'var(--c--theme--colors--primary-100)'
|
||||
: 'var(--c--theme--colors--greyscale-000)'};
|
||||
gap: var(--c--theme--spacings--2xs);
|
||||
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
|
||||
var(--c--contextuals--border--semantic--neutral--tertiary);
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--neutral--tertiary
|
||||
);
|
||||
gap: var(--c--globals--spacings--2xs);
|
||||
padding: var(--c--globals--spacings--2xs)
|
||||
var(--c--globals--spacings--xs);
|
||||
`}
|
||||
color="secondary"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
>
|
||||
<Text
|
||||
$weight={400}
|
||||
$variation={selectedOption ? '800' : '600'}
|
||||
$theme={selectedOption ? 'primary' : 'greyscale'}
|
||||
>
|
||||
<Text $weight={400} $variation="tertiary" $theme="neutral">
|
||||
{selectedOption?.label ?? options[0].label}
|
||||
</Text>
|
||||
<Icon
|
||||
$size="16px"
|
||||
$size="s"
|
||||
iconName="keyboard_arrow_down"
|
||||
$variation={selectedOption ? '800' : '600'}
|
||||
$theme={selectedOption ? 'primary' : 'greyscale'}
|
||||
$variation="tertiary"
|
||||
$theme="neutral"
|
||||
/>
|
||||
</Box>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -38,7 +38,6 @@ export const AlertModal = ({
|
||||
$margin="0"
|
||||
id="alert-modal-title"
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
@@ -47,7 +46,7 @@ export const AlertModal = ({
|
||||
<>
|
||||
<Button
|
||||
aria-label={`${t('Cancel')} - ${title}`}
|
||||
color="secondary"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
@@ -55,7 +54,7 @@ export const AlertModal = ({
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={confirmLabel ?? t('Confirm')}
|
||||
color="danger"
|
||||
color="error"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel ?? t('Confirm')}
|
||||
@@ -65,7 +64,7 @@ export const AlertModal = ({
|
||||
>
|
||||
<Box className="--docs--alert-modal">
|
||||
<Box>
|
||||
<Text $variation="600" as="p">
|
||||
<Text $variation="secondary" as="p">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Button, type ButtonProps } from '@openfun/cunningham-react';
|
||||
import React from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Icon } from '@/components';
|
||||
|
||||
export const ButtonCloseModal = (props: ButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
color="primary-text"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
icon={
|
||||
<Box as="span" aria-hidden="true" className="material-icons-filled">
|
||||
close
|
||||
</Box>
|
||||
<Icon
|
||||
$withThemeInherited
|
||||
iconName="close"
|
||||
className="material-icons-filled"
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -69,7 +69,7 @@ export const QuickSearch = ({
|
||||
label={label}
|
||||
shouldFilter={false}
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
tabIndex={-1}
|
||||
value={selectedValue}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
|
||||
@@ -59,11 +59,7 @@ export const QuickSearchGroup = <T,>({
|
||||
);
|
||||
})}
|
||||
{group.emptyString && group.elements.length === 0 && (
|
||||
<Text
|
||||
$variation="500"
|
||||
$margin={{ left: '2xs', bottom: '3xs' }}
|
||||
$size="sm"
|
||||
>
|
||||
<Text $margin={{ left: '2xs', bottom: '3xs' }} $size="sm">
|
||||
{group.emptyString}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const QuickSearchInput = ({
|
||||
$padding={{ horizontal: 'base', vertical: 'sm' }}
|
||||
>
|
||||
{!loading && (
|
||||
<Icon iconName="search" $variation="600" aria-hidden="true" />
|
||||
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
|
||||
)}
|
||||
{loading && (
|
||||
<div>
|
||||
|
||||
@@ -19,25 +19,25 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
border: none;
|
||||
width: 100%;
|
||||
font-size: 17px;
|
||||
padding: 8px;
|
||||
padding: var(--c--globals--spacings--xs);
|
||||
background: white;
|
||||
outline: none;
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
border-radius: 0;
|
||||
color: var(--c--globals--colors--gray-1000);
|
||||
border-radius: var(--c--globals--spacings--0);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
color: var(--c--globals--colors--gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-item] {
|
||||
content-visibility: auto;
|
||||
cursor: pointer;
|
||||
border-radius: var(--c--theme--spacings--xs);
|
||||
font-size: 14px;
|
||||
border-radius: var(--c--globals--spacings--xs);
|
||||
font-size: var(--c--globals--font--sizes--sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
user-select: none;
|
||||
will-change: background, color;
|
||||
transition: all 150ms ease;
|
||||
@@ -49,19 +49,19 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
|
||||
&:hover,
|
||||
&[data-selected='true'] {
|
||||
background: var(--c--theme--colors--greyscale-100);
|
||||
background: var(--c--contextuals--background--semantic--contextual--primary);
|
||||
.show-right-on-focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled='true'] {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
color: var(--c--globals--colors--gray-500);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
& + [cmdk-item] {
|
||||
margin-top: 4px;
|
||||
margin-top: var(--c--globals--spacings--st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,11 +79,11 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
kbd {
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
padding: 4px;
|
||||
padding: var(--c--globals--spacings--st);
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
color: white;
|
||||
background: var(--c--theme--colors--greyscale-500);
|
||||
background: var(--c--globals--colors--gray-500);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -94,23 +94,23 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
[cmdk-separator] {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--c--theme--colors--greyscale-500);
|
||||
margin: 4px 0;
|
||||
background: var(--c--globals--colors--gray-500);
|
||||
margin: var(--c--globals--spacings--st) 0;
|
||||
}
|
||||
|
||||
*:not([hidden]) + [cmdk-group] {
|
||||
margin-top: 8px;
|
||||
margin-top: var(--c--globals--spacings--xs);
|
||||
}
|
||||
|
||||
[cmdk-group-heading] {
|
||||
user-select: none;
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
font-size: var(--c--globals--font--sizes--sm);
|
||||
color: var(--c--globals--colors--gray-700);
|
||||
font-weight: bold;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--c--theme--spacings--xs);
|
||||
margin-bottom: var(--c--globals--spacings--xs);
|
||||
}
|
||||
|
||||
[cmdk-empty] {
|
||||
@@ -128,9 +128,9 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
}
|
||||
|
||||
.c__modal__title {
|
||||
font-size: var(--c--theme--font--sizes--xs);
|
||||
padding: var(--c--theme--spacings--base);
|
||||
margin-bottom: 0;
|
||||
font-size: var(--c--globals--font--sizes--xs);
|
||||
padding: var(--c--globals--spacings--base);
|
||||
margin-bottom: var(--c--globals--spacings--0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Spacings } from '@/utils';
|
||||
|
||||
import { Box } from '../Box';
|
||||
|
||||
export enum SeparatorVariant {
|
||||
LIGHT = 'light',
|
||||
DARK = 'dark',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
variant?: SeparatorVariant;
|
||||
$withPadding?: boolean;
|
||||
customPadding?: Spacings;
|
||||
};
|
||||
|
||||
export const HorizontalSeparator = ({
|
||||
variant = SeparatorVariant.LIGHT,
|
||||
$withPadding = true,
|
||||
customPadding,
|
||||
}: Props) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const padding = $withPadding
|
||||
? (customPadding ?? 'base')
|
||||
: ('none' as Spacings);
|
||||
@@ -30,11 +20,7 @@ export const HorizontalSeparator = ({
|
||||
$height="1px"
|
||||
$width="100%"
|
||||
$margin={{ vertical: padding }}
|
||||
$background={
|
||||
variant === SeparatorVariant.DARK
|
||||
? '#e5e5e533'
|
||||
: colorsTokens['greyscale-100']
|
||||
}
|
||||
$background="var(--c--contextuals--border--surface--primary)"
|
||||
className="--docs--horizontal-separator"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,8 @@ export const SeparatedSection = ({
|
||||
showSeparator = true,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) => {
|
||||
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
@@ -21,7 +22,8 @@ export const SeparatedSection = ({
|
||||
padding: ${spacingsTokens['sm']} 0;
|
||||
${showSeparator &&
|
||||
css`
|
||||
border-bottom: 1px solid ${colorsTokens['greyscale-200']};
|
||||
border-bottom: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useCunninghamTheme } from '../useCunninghamTheme';
|
||||
|
||||
describe('<useCunninghamTheme />', () => {
|
||||
it('has the logo correctly set', () => {
|
||||
expect(useCunninghamTheme.getState().themeTokens.logo?.src).toBe('');
|
||||
expect(useCunninghamTheme.getState().componentTokens.logo?.src).toBe('');
|
||||
|
||||
// Change theme
|
||||
useCunninghamTheme.getState().setTheme('dsfr');
|
||||
|
||||
const { themeTokens } = useCunninghamTheme.getState();
|
||||
const logo = themeTokens.logo;
|
||||
const { componentTokens } = useCunninghamTheme.getState();
|
||||
const logo = componentTokens.logo;
|
||||
expect(logo?.src).toBe('/assets/logo-gouv.svg');
|
||||
expect(logo?.widthHeader).toBe('110px');
|
||||
expect(logo?.widthFooter).toBe('220px');
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* Select
|
||||
**/
|
||||
--c--components--forms-select--value-color--disabled: var(
|
||||
--c--theme--colors--greyscale-400
|
||||
--c--globals--colors--gray-400
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,14 @@
|
||||
contain: content;
|
||||
}
|
||||
|
||||
.c__button:disabled {
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
.c__button:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.c__button--medium {
|
||||
min-height: var(--c--components--button--medium-height);
|
||||
height: auto;
|
||||
@@ -60,14 +68,11 @@
|
||||
* Tooltip
|
||||
*/
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
padding: var(--c--globals--font--sizes--sm) var(--c--globals--spacings--xxs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Image System
|
||||
*/
|
||||
.c__image-system-filter {
|
||||
filter: var(--c--components--image-system-filter);
|
||||
.c__tooltip .react-aria-OverlayArrow svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,21 +5,23 @@ import { tokens } from './cunningham-tokens';
|
||||
|
||||
type Tokens = typeof tokens.themes.default &
|
||||
Partial<(typeof tokens.themes)[keyof typeof tokens.themes]>;
|
||||
type ColorsTokens = Tokens['theme']['colors'];
|
||||
type FontSizesTokens = Tokens['theme']['font']['sizes'];
|
||||
type SpacingsTokens = Tokens['theme']['spacings'];
|
||||
type ColorsTokens = Tokens['globals']['colors'];
|
||||
type FontSizesTokens = Tokens['globals']['font']['sizes'];
|
||||
type SpacingsTokens = Tokens['globals']['spacings'];
|
||||
type ComponentTokens = Tokens['components'];
|
||||
type ContextualTokens = Tokens['contextuals'];
|
||||
export type Theme = keyof typeof tokens.themes;
|
||||
|
||||
interface ThemeStore {
|
||||
colorsTokens: Partial<ColorsTokens>;
|
||||
componentTokens: ComponentTokens;
|
||||
contextualTokens: ContextualTokens;
|
||||
currentTokens: Partial<Tokens>;
|
||||
fontSizesTokens: Partial<FontSizesTokens>;
|
||||
setTheme: (theme: Theme) => void;
|
||||
spacingsTokens: Partial<SpacingsTokens>;
|
||||
theme: Theme;
|
||||
themeTokens: Partial<Tokens['theme']>;
|
||||
themeTokens: Partial<Tokens['globals']>;
|
||||
}
|
||||
|
||||
const getMergedTokens = (theme: Theme) => {
|
||||
@@ -30,14 +32,15 @@ const DEFAULT_THEME: Theme = 'generic';
|
||||
const defaultTokens = getMergedTokens(DEFAULT_THEME);
|
||||
|
||||
const initialState: ThemeStore = {
|
||||
colorsTokens: defaultTokens.theme.colors,
|
||||
colorsTokens: defaultTokens.globals.colors,
|
||||
componentTokens: defaultTokens.components,
|
||||
contextualTokens: defaultTokens.contextuals,
|
||||
currentTokens: tokens.themes[DEFAULT_THEME] as Partial<Tokens>,
|
||||
fontSizesTokens: defaultTokens.theme.font.sizes,
|
||||
fontSizesTokens: defaultTokens.globals.font.sizes,
|
||||
setTheme: () => {},
|
||||
spacingsTokens: defaultTokens.theme.spacings,
|
||||
spacingsTokens: defaultTokens.globals.spacings,
|
||||
theme: DEFAULT_THEME,
|
||||
themeTokens: defaultTokens.theme,
|
||||
themeTokens: defaultTokens.globals,
|
||||
};
|
||||
|
||||
export const useCunninghamTheme = create<ThemeStore>((set) => ({
|
||||
@@ -46,13 +49,14 @@ export const useCunninghamTheme = create<ThemeStore>((set) => ({
|
||||
const newTokens = getMergedTokens(theme);
|
||||
|
||||
set({
|
||||
colorsTokens: newTokens.theme.colors,
|
||||
colorsTokens: newTokens.globals.colors,
|
||||
componentTokens: newTokens.components,
|
||||
contextualTokens: newTokens.contextuals,
|
||||
currentTokens: tokens.themes[theme] as Partial<Tokens>,
|
||||
fontSizesTokens: newTokens.theme.font.sizes,
|
||||
spacingsTokens: newTokens.theme.spacings,
|
||||
fontSizesTokens: newTokens.globals.font.sizes,
|
||||
spacingsTokens: newTokens.globals.spacings,
|
||||
theme,
|
||||
themeTokens: newTokens.theme,
|
||||
themeTokens: newTokens.globals,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -18,7 +18,8 @@ export const ButtonLogin = () => {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => gotoLogin()}
|
||||
color="primary-text"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
aria-label={t('Login')}
|
||||
className="--docs--button-login"
|
||||
>
|
||||
@@ -31,14 +32,15 @@ export const ButtonLogin = () => {
|
||||
<Box
|
||||
$css={css`
|
||||
.--docs--button-logout:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${colorsTokens['primary-400']} !important;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']} !important;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
onClick={gotoLogout}
|
||||
color="primary-text"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
aria-label={t('Logout')}
|
||||
className="--docs--button-logout"
|
||||
>
|
||||
@@ -56,9 +58,13 @@ export const ProConnectButton = () => {
|
||||
onClick={() => gotoLogin()}
|
||||
aria-label={t('Proconnect Login')}
|
||||
$css={css`
|
||||
background-color: var(--c--theme--colors--primary-text);
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--brand--primary
|
||||
);
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--primary-action);
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--brand--primary-hover
|
||||
);
|
||||
}
|
||||
`}
|
||||
$radius="4px"
|
||||
|
||||
@@ -4,15 +4,12 @@ import { tokens } from '@/cunningham';
|
||||
|
||||
import { AvatarSvg } from './AvatarSvg';
|
||||
|
||||
const colors = tokens.themes.default.theme.colors;
|
||||
const colors = tokens.themes.default.globals.colors;
|
||||
|
||||
const avatarsColors = [
|
||||
colors['blue-500'],
|
||||
colors['blue-1-500'],
|
||||
colors['brown-500'],
|
||||
colors['cyan-500'],
|
||||
colors['gold-500'],
|
||||
colors['green-500'],
|
||||
colors['olive-500'],
|
||||
colors['orange-500'],
|
||||
colors['pink-500'],
|
||||
colors['purple-500'],
|
||||
|
||||
@@ -289,11 +289,13 @@ export const BlockNoteReader = ({
|
||||
editor={editor}
|
||||
editable={false}
|
||||
theme="light"
|
||||
aria-label={t('Document version viewer')}
|
||||
aria-label={t('Document viewer')}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
comments={false}
|
||||
/>
|
||||
>
|
||||
<BlockNoteToolbar />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,7 +91,7 @@ export function MarkdownButton() {
|
||||
<Text
|
||||
aria-hidden={true}
|
||||
$css={css`
|
||||
font-family: var(--c--theme--font--families--base);
|
||||
font-family: var(--c--globals--font--families--base);
|
||||
`}
|
||||
$weight="bold"
|
||||
>
|
||||
|
||||
@@ -24,14 +24,14 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Cancel the download')}
|
||||
color="secondary"
|
||||
variant="secondary"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
color="danger"
|
||||
color="error"
|
||||
data-testid="modal-download-unsafe-button"
|
||||
onClick={() => {
|
||||
if (onConfirm) {
|
||||
@@ -52,7 +52,6 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
$gap="0.7rem"
|
||||
$size="h6"
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
$direction="row"
|
||||
$margin="0"
|
||||
>
|
||||
@@ -67,8 +66,10 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
>
|
||||
<Box>
|
||||
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
|
||||
<Text $variation="700">{t('This file is flagged as unsafe.')}</Text>
|
||||
<Text $variation="600">
|
||||
<Text $variation="secondary">
|
||||
{t('This file is flagged as unsafe.')}
|
||||
</Text>
|
||||
<Text $variation="secondary">
|
||||
{t('Please download it only if it comes from a trusted source.')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useBlockNoteEditor, useComponentsContext } from '@blocknote/react';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -24,7 +29,18 @@ export const CommentToolbarButton = () => {
|
||||
DocsStyleSchema
|
||||
>();
|
||||
|
||||
if (!editor.isEditable || !Components || !currentDoc?.abilities.comment) {
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
|
||||
const show = useMemo(() => {
|
||||
return !!selectedBlocks.find((block) => block.content !== undefined);
|
||||
}, [selectedBlocks]);
|
||||
|
||||
if (
|
||||
!show ||
|
||||
!editor.isEditable ||
|
||||
!Components ||
|
||||
!currentDoc?.abilities.comment
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,8 +50,10 @@ export const CommentToolbarButton = () => {
|
||||
className="bn-button"
|
||||
onClick={() => {
|
||||
editor.comments?.startPendingComment();
|
||||
editor.formattingToolbar.closeMenu();
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
data-test="comment-toolbar-button"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
@@ -46,20 +64,18 @@ export const CommentToolbarButton = () => {
|
||||
<Icon
|
||||
iconName="comment"
|
||||
className="--docs--icon-bg"
|
||||
$theme="greyscale"
|
||||
$variation="600"
|
||||
$theme="gray"
|
||||
$padding="0.15rem"
|
||||
$size="16px"
|
||||
$color={colorsTokens['greyscale-600']}
|
||||
$size="md"
|
||||
/>
|
||||
{t('Comment')}
|
||||
</Box>
|
||||
</Components.Generic.Toolbar.Button>
|
||||
<Box
|
||||
$background={colorsTokens['greyscale-100']}
|
||||
$background={colorsTokens['gray-100']}
|
||||
$width="1px"
|
||||
$height="70%"
|
||||
$margin={{ left: '2px' }}
|
||||
$margin={{ left: 'var(--c--globals--spacings--4xs)' }}
|
||||
$css={css`
|
||||
align-self: center;
|
||||
`}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const cssComments = (
|
||||
.bn-thread-mark:not([data-orphan='true']),
|
||||
.bn-thread-mark-selected:not([data-orphan='true']) {
|
||||
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
color: var(--c--globals--colors--greyscale-700);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const cssComments = (
|
||||
max-height: 500px;
|
||||
|
||||
.bn-default-styles {
|
||||
font-family: var(--c--theme--font--families--base);
|
||||
font-family: var(--c--globals--font--families--base);
|
||||
}
|
||||
|
||||
.bn-block {
|
||||
@@ -52,21 +52,22 @@ export const cssComments = (
|
||||
padding: 8px;
|
||||
|
||||
& .bn-editor {
|
||||
padding-left: 32px;
|
||||
padding-left: var(--c--globals--spacings--lg);
|
||||
.bn-inline-content {
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
color: var(--c--globals--colors--greyscale-700);
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji
|
||||
& .bn-badge-group {
|
||||
padding-left: 32px;
|
||||
padding-left: var(--c--globals--spacings--lg);
|
||||
.bn-badge label {
|
||||
padding: 0 4px;
|
||||
padding: var(--c--globals--spacings--0)
|
||||
var(--c--globals--spacings--st);
|
||||
background: none;
|
||||
border: 1px solid var(--c--theme--colors--greyscale-300);
|
||||
border-radius: 4px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--c--globals--colors--greyscale-300);
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
height: var(--c--globals--spacings--md);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,17 +103,17 @@ export const cssComments = (
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
background-color: var(--c--globals--colors--greyscale-100);
|
||||
}
|
||||
}
|
||||
|
||||
button[role='menuitem'] svg {
|
||||
color: var(--c--theme--colors--greyscale-600);
|
||||
color: var(--c--globals--colors--greyscale-600);
|
||||
}
|
||||
}
|
||||
|
||||
& svg {
|
||||
color: var(--c--theme--colors--info-600);
|
||||
color: var(--c--globals--colors--info-600);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,19 +126,19 @@ export const cssComments = (
|
||||
gap: 0.4rem !important;
|
||||
|
||||
& > button {
|
||||
height: 24px;
|
||||
padding-inline: 4px;
|
||||
height: var(--c--globals--spacings--md);
|
||||
padding-inline: var(--c--globals--spacings--st);
|
||||
|
||||
&[data-test='save'] {
|
||||
border: 1px solid var(--c--theme--colors--info-600);
|
||||
background: var(--c--theme--colors--info-600);
|
||||
border: 1px solid var(--c--globals--colors--info-600);
|
||||
background: var(--c--globals--colors--info-600);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&[data-test='cancel'] {
|
||||
background: white;
|
||||
border: 1px solid var(--c--theme--colors--greyscale-300);
|
||||
color: var(--c--theme--colors--info-600);
|
||||
border: 1px solid var(--c--globals--colors--greyscale-300);
|
||||
color: var(--c--globals--colors--info-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,19 +180,19 @@ export const cssComments = (
|
||||
|
||||
button {
|
||||
font-size: 0;
|
||||
background: var(--c--theme--colors--info-600);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: var(--c--globals--colors--info-600);
|
||||
width: var(--c--globals--spacings--md);
|
||||
height: var(--c--globals--spacings--md);
|
||||
padding: var(--c--globals--spacings--0);
|
||||
|
||||
&:disabled {
|
||||
background: var(--c--theme--colors--greyscale-300);
|
||||
background: var(--c--globals--colors--greyscale-300);
|
||||
}
|
||||
|
||||
& .mantine-Button-label::before {
|
||||
content: '🡡';
|
||||
font-size: 13px;
|
||||
color: var(--c--theme--colors--greyscale-100);
|
||||
color: var(--c--globals--colors--greyscale-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ 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);
|
||||
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -59,9 +59,15 @@ const UploadLoaderBlockComponent = ({
|
||||
editor,
|
||||
}: UploadLoaderBlockComponentProps) => {
|
||||
const mediaUrl = useMediaUrl();
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
useEffect(() => {
|
||||
if (!block.props.blockUploadUrl || block.props.type !== 'loading') {
|
||||
const shouldCheckStatus =
|
||||
block.props.blockUploadUrl &&
|
||||
block.props.type === 'loading' &&
|
||||
isEditable;
|
||||
|
||||
if (!shouldCheckStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,7 +114,7 @@ const UploadLoaderBlockComponent = ({
|
||||
/* During collaboration, another user might have updated the block */
|
||||
}
|
||||
});
|
||||
}, [block, editor, mediaUrl]);
|
||||
}, [block, editor, mediaUrl, isEditable]);
|
||||
|
||||
return (
|
||||
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
|
||||
|
||||
@@ -26,14 +26,19 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: ({ inlineContent, updateInlineContent }) => {
|
||||
render: ({ editor, inlineContent, updateInlineContent }) => {
|
||||
const { data: doc } = useDoc({ id: inlineContent.props.docId });
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
/**
|
||||
* Update the content title if the referenced doc title changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (doc?.title && doc.title !== inlineContent.props.title) {
|
||||
if (
|
||||
isEditable &&
|
||||
doc?.title &&
|
||||
doc.title !== inlineContent.props.title
|
||||
) {
|
||||
updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
@@ -50,7 +55,7 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
* not when inlineContent.props.title changes.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doc?.title]);
|
||||
}, [doc?.title, isEditable]);
|
||||
|
||||
return <LinkSelected {...inlineContent.props} />;
|
||||
},
|
||||
@@ -86,9 +91,10 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
background-color: ${colorsTokens['gray-100']};
|
||||
}
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
transition: background-color var(--c--globals--transitions--duration)
|
||||
var(--c--globals--transitions--ease-out);
|
||||
|
||||
.--docs--doc-deleted & {
|
||||
pointer-events: none;
|
||||
@@ -98,7 +104,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
{emoji ? (
|
||||
<Text $size="16px">{emoji}</Text>
|
||||
) : (
|
||||
<SelectedPageIcon width={11.5} color={colorsTokens['primary-400']} />
|
||||
<SelectedPageIcon width={11.5} color={colorsTokens['brand-400']} />
|
||||
)}
|
||||
<Text
|
||||
$weight="500"
|
||||
|
||||
@@ -35,11 +35,11 @@ import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const inputStyle = css`
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
background-color: var(--c--globals--colors--gray-100);
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
font-size: 16px;
|
||||
color: var(--c--globals--colors--gray-700);
|
||||
font-size: var(--c--globals--font--sizes--md);
|
||||
width: 100%;
|
||||
font-family: 'Inter';
|
||||
`;
|
||||
@@ -86,6 +86,7 @@ export const SearchPage = ({
|
||||
const [search, setSearch] = useState('');
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
/**
|
||||
* createReactInlineContentSpec add automatically the focus after
|
||||
@@ -101,6 +102,10 @@ export const SearchPage = ({
|
||||
}, [inputRef]);
|
||||
|
||||
const closeSearch = (insertContent: string) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateInlineContent({
|
||||
type: 'interlinkingSearchInline',
|
||||
props: {
|
||||
@@ -154,8 +159,8 @@ export const SearchPage = ({
|
||||
<Box
|
||||
as="span"
|
||||
className="inline-content"
|
||||
$background={colorsTokens['greyscale-100']}
|
||||
$color="var(--c--theme--colors--greyscale-700)"
|
||||
$background={colorsTokens['gray-100']}
|
||||
$color="var(--c--globals--colors--gray-700)"
|
||||
$direction="row"
|
||||
$radius="3px"
|
||||
$padding="1px"
|
||||
@@ -193,9 +198,9 @@ export const SearchPage = ({
|
||||
<QuickSearch showInput={false}>
|
||||
<Card
|
||||
$css={css`
|
||||
box-shadow: 0 0 3px 0px var(--c--theme--colors--greyscale-200);
|
||||
box-shadow: 0 0 3px 0px var(--c--globals--colors--gray-200);
|
||||
& > div {
|
||||
margin-top: 0;
|
||||
margin-top: var(--c--globals--spacings--0);
|
||||
& [cmdk-group-heading] {
|
||||
padding: 0.4rem;
|
||||
margin: 0;
|
||||
@@ -223,6 +228,10 @@ export const SearchPage = ({
|
||||
search={search}
|
||||
filters={{ target: DocSearchTarget.CURRENT }}
|
||||
onSelect={(doc) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateInlineContent({
|
||||
type: 'interlinkingSearchInline',
|
||||
props: {
|
||||
@@ -278,8 +287,8 @@ export const SearchPage = ({
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
$size="14px"
|
||||
$color="var(--c--theme--colors--greyscale-1000)"
|
||||
$size="sm"
|
||||
$color="var(--c--globals--colors--gray-1000)"
|
||||
spellCheck="false"
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
@@ -287,11 +296,7 @@ export const SearchPage = ({
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<Icon
|
||||
iconName="keyboard_return"
|
||||
$variation="600"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<Icon iconName="keyboard_return" spellCheck="false" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -308,7 +313,7 @@ export const SearchPage = ({
|
||||
<Box
|
||||
$css={css`
|
||||
border-top: 1px solid
|
||||
var(--c--theme--colors--greyscale-200);
|
||||
var(--c--globals--colors--gray-200);
|
||||
`}
|
||||
$width="100%"
|
||||
>
|
||||
@@ -323,15 +328,15 @@ export const SearchPage = ({
|
||||
$css={css`
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--c--theme--colors--greyscale-100
|
||||
--c--globals--colors--gray-100
|
||||
);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<AddPageIcon />
|
||||
<Text
|
||||
$size="14px"
|
||||
$color="var(--c--theme--colors--greyscale-1000)"
|
||||
$size="sm"
|
||||
$color="var(--c--globals--colors--gray-1000)"
|
||||
contentEditable={false}
|
||||
>
|
||||
{t('New sub-doc')}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const cssEditor = css`
|
||||
}
|
||||
|
||||
& .bn-editor {
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
color: var(--c--globals--colors--greyscale-700);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,14 +104,14 @@ export const cssEditor = css`
|
||||
* Callout, Paragraph and Heading blocks
|
||||
*/
|
||||
.bn-block {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.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);
|
||||
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
|
||||
.bn-inline-content {
|
||||
@@ -127,7 +127,7 @@ export const cssEditor = css`
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
a {
|
||||
color: var(--c--theme--colors--greyscale-600);
|
||||
color: var(--c--globals--colors--greyscale-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bn-block-group
|
||||
@@ -144,7 +144,7 @@ export const cssEditor = css`
|
||||
* Quotes
|
||||
*/
|
||||
blockquote {
|
||||
border-left: 4px solid var(--c--theme--colors--greyscale-300);
|
||||
border-left: 4px solid var(--c--globals--colors--gray-300);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Cancel the download')}
|
||||
color="secondary"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
@@ -183,7 +183,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
<Button
|
||||
data-testid="doc-export-download-button"
|
||||
aria-label={t('Download')}
|
||||
color="primary"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => void onSubmit()}
|
||||
disabled={isExporting}
|
||||
@@ -205,7 +205,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
$margin="0"
|
||||
id="modal-export-title"
|
||||
$size="h6"
|
||||
$variation="1000"
|
||||
$align="flex-start"
|
||||
data-testid="modal-export-title"
|
||||
>
|
||||
@@ -225,7 +224,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
$gap="1rem"
|
||||
className="--docs--modal-export-content"
|
||||
>
|
||||
<Text $variation="600" $size="sm" as="p">
|
||||
<Text $variation="secondary" $size="sm" as="p">
|
||||
{t('Download your document in a .docx, .odt or .pdf format.')}
|
||||
</Text>
|
||||
<Select
|
||||
|
||||
@@ -2,36 +2,37 @@ import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, Icon, Text } from '@/components';
|
||||
import { Box, BoxButton, Card, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
export const AlertNetwork = () => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Box
|
||||
<Card
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$width="100%"
|
||||
$background={colorsTokens['warning-100']}
|
||||
$radius={spacingsTokens['3xs']}
|
||||
$padding="xs"
|
||||
$flex={1}
|
||||
$align="center"
|
||||
$gap={spacingsTokens['2xs']}
|
||||
$css={css`
|
||||
border: 1px solid var(--c--theme--colors--warning-300);
|
||||
`}
|
||||
$theme="warning"
|
||||
>
|
||||
<Box $direction="row" $gap={spacingsTokens['2xs']} $align="center">
|
||||
<Icon iconName="mobiledata_off" $theme="warning" $variation="600" />
|
||||
<Text $theme="warning" $variation="600" $weight={500}>
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap={spacingsTokens['2xs']}
|
||||
$align="center"
|
||||
$withThemeInherited
|
||||
>
|
||||
<Icon iconName="mobiledata_off" $withThemeInherited />
|
||||
<Text $withThemeInherited $weight={500}>
|
||||
{t('Others are editing. Your network prevent changes.')}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -40,20 +41,20 @@ export const AlertNetwork = () => {
|
||||
$gap={spacingsTokens['3xs']}
|
||||
$align="center"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
$withThemeInherited
|
||||
>
|
||||
<Icon
|
||||
iconName="info"
|
||||
$theme="warning"
|
||||
$variation="600"
|
||||
$size="16px"
|
||||
$withThemeInherited
|
||||
$size="md"
|
||||
$weight="500"
|
||||
$margin={{ top: 'auto' }}
|
||||
/>
|
||||
<Text $theme="warning" $variation="600" $weight="500" $size="xs">
|
||||
<Text $withThemeInherited $weight="500" $size="xs">
|
||||
{t('Learn more')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
{isModalOpen && (
|
||||
<AlertNetworkModal onClose={() => setIsModalOpen(false)} />
|
||||
@@ -74,20 +75,14 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
|
||||
onClose={() => onClose()}
|
||||
rightActions={
|
||||
<>
|
||||
<Button aria-label={t('OK')} onClick={onClose} color="danger">
|
||||
<Button aria-label={t('OK')} onClick={onClose} color="error">
|
||||
{t('I understand')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Text
|
||||
$size="h6"
|
||||
as="h6"
|
||||
$margin={{ all: '0' }}
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
>
|
||||
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
|
||||
{t("Why you can't edit the document?")}
|
||||
</Text>
|
||||
}
|
||||
@@ -97,14 +92,14 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
|
||||
className="--docs--modal-alert-network"
|
||||
$margin={{ top: 'md' }}
|
||||
>
|
||||
<Text $size="sm" $variation="600">
|
||||
<Text $size="sm" $variation="secondary">
|
||||
{t(
|
||||
'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.',
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
$size="sm"
|
||||
$variation="600"
|
||||
$variation="secondary"
|
||||
$margin={{ top: 'xs' }}
|
||||
$weight="bold"
|
||||
$display="inline"
|
||||
@@ -112,7 +107,7 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
|
||||
{t("This means you can't edit until others leave.")}{' '}
|
||||
<Text
|
||||
$size="sm"
|
||||
$variation="600"
|
||||
$variation="secondary"
|
||||
$margin={{ top: 'xs' }}
|
||||
$weight="normal"
|
||||
$display="inline"
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { Card, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
export const AlertPublic = ({ isPublicDoc }: { isPublicDoc: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
<Card
|
||||
aria-label={t('Public document')}
|
||||
$color={colorsTokens['primary-800']}
|
||||
$background={colorsTokens['primary-050']}
|
||||
$radius={spacingsTokens['3xs']}
|
||||
$direction="row"
|
||||
$padding="xs"
|
||||
$flex={1}
|
||||
$align="center"
|
||||
$gap={spacingsTokens['2xs']}
|
||||
$css={css`
|
||||
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
|
||||
`}
|
||||
$theme="brand"
|
||||
>
|
||||
<Icon
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
$withThemeInherited
|
||||
data-testid="public-icon"
|
||||
iconName={isPublicDoc ? 'public' : 'vpn_lock'}
|
||||
/>
|
||||
<Text $theme="primary" $variation="800" $weight="500">
|
||||
<Text $withThemeInherited $weight="500">
|
||||
{isPublicDoc
|
||||
? t('Public document')
|
||||
: t('Document accessible to any connected person')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, Icon, Text } from '@/components';
|
||||
import { Box, Card, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
@@ -17,7 +20,7 @@ export const AlertRestore = ({ doc }: { doc: Doc }) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { mutate: restoreDoc, error } = useRestoreDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN, KEY_DOC],
|
||||
options: {
|
||||
@@ -44,57 +47,52 @@ export const AlertRestore = ({ doc }: { doc: Doc }) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
<Card
|
||||
className="--docs--alert-restore"
|
||||
aria-label={t('Alert deleted document')}
|
||||
$color={colorsTokens['danger-800']}
|
||||
$background={colorsTokens['danger-100']}
|
||||
$radius={spacingsTokens['3xs']}
|
||||
$direction="row"
|
||||
$padding="xs"
|
||||
$flex={1}
|
||||
$align="center"
|
||||
$gap={spacingsTokens['3xs']}
|
||||
$css={css`
|
||||
border: 1px solid var(--c--theme--colors--danger-300, #e3e3fd);
|
||||
`}
|
||||
$justify="space-between"
|
||||
$theme="error"
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
|
||||
<Box
|
||||
$withThemeInherited
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacingsTokens['2xs']}
|
||||
>
|
||||
<Icon
|
||||
$theme="danger"
|
||||
$variation="700"
|
||||
$withThemeInherited
|
||||
data-testid="public-icon"
|
||||
iconName="delete"
|
||||
variant="symbols-outlined"
|
||||
/>
|
||||
<Text $theme="danger" $variation="700" $weight="500">
|
||||
{t('Document deleted')}
|
||||
</Text>
|
||||
{t('Document deleted')}
|
||||
</Box>
|
||||
<BoxButton
|
||||
<Button
|
||||
onClick={() =>
|
||||
restoreDoc({
|
||||
docId: doc.id,
|
||||
})
|
||||
}
|
||||
$direction="row"
|
||||
$gap="0.2rem"
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$align="center"
|
||||
color="error"
|
||||
variant="tertiary"
|
||||
size="nano"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="undo"
|
||||
$withThemeInherited
|
||||
$size="18px"
|
||||
variant="symbols-outlined"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
iconName="undo"
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$size="18px"
|
||||
variant="symbols-outlined"
|
||||
/>
|
||||
<Text $theme="danger" $variation="600" $size="s" $css="line-height:1;">
|
||||
{t('Restore')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
</Box>
|
||||
Restore
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,20 +46,19 @@ export const BoutonShare = ({
|
||||
<Box
|
||||
$css={css`
|
||||
.c__button--medium {
|
||||
height: 32px;
|
||||
padding: 10px var(--c--theme--spacings--xs);
|
||||
height: var(--c--globals--spacings--lg);
|
||||
padding: 10px var(--c--globals--spacings--xs);
|
||||
gap: 7px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
color="tertiary"
|
||||
aria-label={t('Share button')}
|
||||
variant="secondary"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="group"
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
$color="inherit"
|
||||
variant="filled"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
@@ -76,7 +75,8 @@ export const BoutonShare = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="primary-text"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
onClick={open}
|
||||
size="medium"
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -47,15 +47,15 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
$variation="600"
|
||||
$variation="tertiary"
|
||||
$size="s"
|
||||
$weight="bold"
|
||||
$theme={isEditable ? 'greyscale' : 'warning'}
|
||||
$theme={isEditable ? 'gray' : 'warning'}
|
||||
>
|
||||
{transRole(isEditable ? doc.user_role || doc.link_role : Role.READER)}
|
||||
·
|
||||
</Text>
|
||||
<Text $variation="600" $size="s">
|
||||
<Text $variation="tertiary" $size="s">
|
||||
{dateToDisplay}
|
||||
</Text>
|
||||
</>
|
||||
@@ -64,11 +64,11 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text $variation="400" $size="s">
|
||||
<Text $variation="tertiary" $size="s">
|
||||
{hasChildren ? relativeOnly : dateToDisplay}
|
||||
</Text>
|
||||
{hasChildren && (
|
||||
<Text $variation="400" $size="s">
|
||||
<Text $variation="tertiary" $size="s">
|
||||
•
|
||||
{t('Contains {{count}} sub-documents', {
|
||||
count: childrenCount,
|
||||
|
||||
@@ -43,7 +43,6 @@ export const DocTitleText = () => {
|
||||
as="h2"
|
||||
$margin={{ all: 'none', left: 'none' }}
|
||||
$size={isMobile ? 'h4' : 'h2'}
|
||||
$variation="1000"
|
||||
>
|
||||
{currentDoc?.title || untitledDocument}
|
||||
</Text>
|
||||
@@ -71,10 +70,11 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
|
||||
padding-top: 3px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
border-radius: 4px;
|
||||
background-color: ${colorsTokens['gray-100']};
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
transition: background-color var(--c--globals--transitions--duration)
|
||||
var(--c--globals--transitions--ease-out);
|
||||
`}
|
||||
>
|
||||
<DocIcon
|
||||
@@ -95,7 +95,7 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
|
||||
height="25px"
|
||||
aria-hidden="true"
|
||||
aria-label={t('Simple document icon')}
|
||||
color={colorsTokens['primary-500']}
|
||||
color={colorsTokens['brand-500']}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -107,7 +107,6 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
|
||||
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { isTopRoot } = useDocUtils(doc);
|
||||
const { untitledDocument } = useTrans();
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
@@ -122,11 +121,17 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
if (isTopRoot) {
|
||||
const sanitizedTitle = updateDocTitle(doc, inputText);
|
||||
setTitleDisplay(sanitizedTitle);
|
||||
return sanitizedTitle;
|
||||
} else {
|
||||
const sanitizedTitle = updateDocTitle(
|
||||
doc,
|
||||
emoji ? `${emoji} ${inputText}` : inputText,
|
||||
);
|
||||
const { emoji: pastedEmoji } = getEmojiAndTitle(inputText);
|
||||
const textPreservingPastedEmoji = pastedEmoji
|
||||
? `\u200B${inputText}`
|
||||
: inputText;
|
||||
const finalTitle = emoji
|
||||
? `${emoji} ${textPreservingPastedEmoji}`
|
||||
: textPreservingPastedEmoji;
|
||||
|
||||
const sanitizedTitle = updateDocTitle(doc, finalTitle);
|
||||
const { titleWithoutEmoji: sanitizedTitleWithoutEmoji } =
|
||||
getEmojiAndTitle(sanitizedTitle);
|
||||
|
||||
@@ -171,18 +176,19 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
$color={colorsTokens['greyscale-1000']}
|
||||
$padding={{ right: 'big' }}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
content: '${untitledDocument}';
|
||||
color: grey;
|
||||
color: var(
|
||||
--c--contextuals--content--semantic--neutral--tertiary
|
||||
);
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
}
|
||||
font-size: ${isDesktop
|
||||
? css`var(--c--theme--font--sizes--h2)`
|
||||
: css`var(--c--theme--font--sizes--sm)`};
|
||||
? css`var(--c--globals--font--sizes--h2)`
|
||||
: css`var(--c--globals--font--sizes--sm)`};
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
`}
|
||||
|
||||
@@ -215,14 +215,9 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
{!isSmallMobile && ModalExport && (
|
||||
<Button
|
||||
data-testid="doc-open-modal-download-button"
|
||||
color="tertiary-text"
|
||||
variant="tertiary"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="download"
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<Icon iconName="download" $color="inherit" aria-hidden={true} />
|
||||
}
|
||||
onClick={() => {
|
||||
setIsModalExportOpen(true);
|
||||
@@ -236,17 +231,14 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
label={t('Open the document options')}
|
||||
buttonCss={css`
|
||||
padding: ${spacingsTokens['xs']};
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
}
|
||||
${isSmallMobile
|
||||
? css`
|
||||
border: 1px solid ${colorsTokens['greyscale-300']};
|
||||
border: 1px solid ${colorsTokens['gray-300']};
|
||||
`
|
||||
: ''}
|
||||
`}
|
||||
>
|
||||
<IconOptions aria-hidden="true" isHorizontal $theme="primary" />
|
||||
<IconOptions aria-hidden="true" isHorizontal $color="inherit" />
|
||||
</DropdownMenu>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -44,16 +44,7 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
export type DocsResponse = APIList<Doc>;
|
||||
export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
|
||||
const searchParams = constructParams(params);
|
||||
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()}`);
|
||||
}
|
||||
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
|
||||
@@ -21,7 +21,7 @@ export const DocIcon = ({
|
||||
emoji,
|
||||
defaultIcon,
|
||||
$size = 'sm',
|
||||
$variation = '1000',
|
||||
$variation = 'secondary',
|
||||
$weight = '400',
|
||||
docId,
|
||||
title,
|
||||
|
||||
@@ -65,7 +65,6 @@ export const DocPage403 = ({ id }: DocProps) => {
|
||||
$padding={{ bottom: '2rem' }}
|
||||
>
|
||||
<Image
|
||||
className="c__image-system-filter"
|
||||
src={img403}
|
||||
alt={t('Image 403')}
|
||||
width={300}
|
||||
@@ -77,7 +76,7 @@ export const DocPage403 = ({ id }: DocProps) => {
|
||||
/>
|
||||
|
||||
<Box $align="center" $gap="0.8rem">
|
||||
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
|
||||
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="brand">
|
||||
{hasRequested
|
||||
? t('Your access request for this document is pending.')
|
||||
: t('Insufficient access rights to view the document.')}
|
||||
@@ -88,7 +87,6 @@ export const DocPage403 = ({ id }: DocProps) => {
|
||||
as="p"
|
||||
$maxWidth="320px"
|
||||
$textAlign="center"
|
||||
$variation="600"
|
||||
$size="sm"
|
||||
$margin={{ top: '0' }}
|
||||
>
|
||||
@@ -101,8 +99,9 @@ export const DocPage403 = ({ id }: DocProps) => {
|
||||
<Box $direction="row" $gap="0.7rem">
|
||||
<StyledLink href="/">
|
||||
<StyledButton
|
||||
icon={<Icon iconName="house" $theme="primary" />}
|
||||
color="tertiary"
|
||||
icon={<Icon iconName="house" $withThemeInherited />}
|
||||
color="brand"
|
||||
variant="secondary"
|
||||
>
|
||||
{t('Home')}
|
||||
</StyledButton>
|
||||
|
||||
@@ -98,7 +98,7 @@ export const ModalRemoveDoc = ({
|
||||
<Button
|
||||
ref={cancelButtonRef}
|
||||
aria-label={t('Cancel the deletion')}
|
||||
color="secondary"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
onKeyDown={handleCloseKeyDown}
|
||||
@@ -107,7 +107,7 @@ export const ModalRemoveDoc = ({
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('Delete document')}
|
||||
color="danger"
|
||||
color="error"
|
||||
fullWidth
|
||||
onClick={handleDelete}
|
||||
onKeyDown={handleDeleteKeyDown}
|
||||
@@ -130,7 +130,6 @@ export const ModalRemoveDoc = ({
|
||||
id="modal-remove-doc-title"
|
||||
$margin="0"
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
>
|
||||
{t('Delete a doc')}
|
||||
</Text>
|
||||
@@ -144,7 +143,12 @@ export const ModalRemoveDoc = ({
|
||||
>
|
||||
<Box className="--docs--modal-remove-doc">
|
||||
{!isError && (
|
||||
<Text $size="sm" $variation="600" $display="inline-block" as="p">
|
||||
<Text
|
||||
$size="sm"
|
||||
$variation="secondary"
|
||||
$display="inline-block"
|
||||
as="p"
|
||||
>
|
||||
{hasChildren ? (
|
||||
<Trans t={t}>
|
||||
This document and <strong>any sub-documents</strong> will be
|
||||
|
||||
@@ -33,7 +33,7 @@ export const SimpleDocItem = ({
|
||||
showAccesses = false,
|
||||
}: SimpleDocItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
const { isChild } = useDocUtils(doc);
|
||||
@@ -63,13 +63,13 @@ export const SimpleDocItem = ({
|
||||
<PinnedDocumentIcon
|
||||
aria-hidden="true"
|
||||
data-testid="doc-pinned-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
/>
|
||||
) : isChild ? (
|
||||
<ChildDocument
|
||||
aria-hidden="true"
|
||||
data-testid="doc-child-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
/>
|
||||
) : (
|
||||
<SimpleFileIcon
|
||||
@@ -77,14 +77,13 @@ export const SimpleDocItem = ({
|
||||
height="32px"
|
||||
aria-hidden="true"
|
||||
data-testid="doc-simple-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box $justify="center" $overflow="auto">
|
||||
<Text
|
||||
$size="sm"
|
||||
$variation="1000"
|
||||
$weight="500"
|
||||
$css={ItemTextCss}
|
||||
data-testid="doc-title"
|
||||
@@ -99,7 +98,7 @@ export const SimpleDocItem = ({
|
||||
$margin={{ top: '-2px' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Text $variation="600" $size="xs">
|
||||
<Text $size="xs" $variation="tertiary">
|
||||
{DateTime.fromISO(doc.updated_at).toRelative()}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -26,12 +26,13 @@ export const getEmojiAndTitle = (title: string) => {
|
||||
// Use emoji-regex library for comprehensive emoji detection compatible with ES5
|
||||
const regex = emojiRegex();
|
||||
|
||||
// Check if the title starts with an emoji
|
||||
const match = title.match(regex);
|
||||
// Ignore leading spaces when checking for a leading emoji
|
||||
const trimmedTitle = title.trimStart();
|
||||
const match = trimmedTitle.match(regex);
|
||||
|
||||
if (match && title.startsWith(match[0])) {
|
||||
if (match && trimmedTitle.startsWith(match[0])) {
|
||||
const emoji = match[0];
|
||||
const titleWithoutEmoji = title.substring(emoji.length).trim();
|
||||
const titleWithoutEmoji = trimmedTitle.substring(emoji.length).trim();
|
||||
return { emoji, titleWithoutEmoji };
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export const DocSearchFilters = ({
|
||||
/>
|
||||
</Box>
|
||||
{hasFilters && (
|
||||
<Button color="primary-text" size="small" onClick={onReset}>
|
||||
<Button color="brand" variant="tertiary" size="small" onClick={onReset}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,11 @@ export const DocSearchItem = ({ doc }: DocSearchItemProps) => {
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<Icon iconName="keyboard_return" $theme="primary" $variation="800" />
|
||||
<Icon
|
||||
iconName="keyboard_return"
|
||||
$theme="brand"
|
||||
$variation="secondary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -85,7 +85,8 @@ const DocSearchModalGlobal = ({
|
||||
aria-label={t('Close the search modal')}
|
||||
onClick={modalProps.onClose}
|
||||
size="small"
|
||||
color="primary-text"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
/>
|
||||
</Box>
|
||||
<QuickSearch
|
||||
@@ -112,7 +113,6 @@ const DocSearchModalGlobal = ({
|
||||
$justify="center"
|
||||
>
|
||||
<Image
|
||||
className="c__image-system-filter"
|
||||
width={320}
|
||||
src={EmptySearchIcon}
|
||||
alt={t('No active search')}
|
||||
|
||||