Compare commits

..

1 Commits

Author SHA1 Message Date
Manuel Raynaud
17e28c487c 📝(dev) add cursor rule for django code
We apply a cursor rule to the project related to the django application.
This rule is heavily inspired from the posthog's rule.
2025-03-31 10:44:14 +02:00
127 changed files with 537 additions and 1189 deletions

View File

@@ -0,0 +1,78 @@
---
description: Rules for writing Python with Django
globs: src/backend/**/*.py
alwaysApply: false
---
You are an expert in Python, Django, and scalable web application development.
Key Principles
- Write clear, technical responses with precise Django examples.
- Use Django's built-in features and tools wherever possible to leverage its full capabilities.
- Prioritize readability and maintainability; follow Django's coding style guide (PEP 8 compliance for the most part, with the one exception being 100 characters per line instead of 79).
- Use descriptive variable and function names; adhere to naming conventions (e.g., lowercase with underscores for functions and variables).
Django/Python
- Use Django REST Framework viewsets for API endpoints.
- Leverage Djangos ORM for database interactions; avoid raw SQL queries unless necessary for performance.
- Use Djangos built-in user model and authentication framework for user management.
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
Error Handling and Validation
- Implement error handling at the view level and use Django's built-in error handling mechanisms.
- Prefer try-except blocks for handling exceptions in business logic and views.
Dependencies
- Django
- Django REST Framework (for API development)
- Celery (for background tasks)
- Redis (for caching and task queues)
- PostgreSQL (preferred databases for production)
- Minio (file storage for production)
- OIDC prodiver (for managing authentication)
Django-Specific Guidelines
- Use Django templates for rendering HTML and DRF serializers for JSON responses.
- Keep business logic in models and forms; keep views light and focused on request handling.
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
- Use Djangos built-in tools for testing (pytest-django) to ensure code quality and reliability.
- Leverage Djangos caching framework to optimize performance for frequently accessed data.
- Use Djangos middleware for common tasks such as authentication, logging, and security.
Performance Optimization
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
- Use Djangos cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
- Implement database indexing and query optimization techniques for better performance.
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
- Optimize static file handling with Djangos static file management system (e.g., WhiteNoise).
Logging
- As a general rule, we should have logs for every expected and unexpected actions of the application, using the appropriate log level.
- We should also be logging these exceptions to Sentry with the Sentry Python SDK. Python exceptions should almost always be captured automatically without extra instrumentation, but custom ones (such as failed requests to external services, query errors, or Celery task failures) can be tracked using capture_exception().
Log Levels
- A log level or log severity is a piece of information telling how important a given log message is:
- DEBUG: should be used for information that may be needed for diagnosing issues and troubleshooting or when running application in the test environment for the purpose of making sure everything is running correctly
- INFO: should be used as standard log level, indicating that something happened
- WARN: should be used when something unexpected happened but the code can continue the work
- ERROR: should be used when the application hits an issue preventing one or more functionalities from properly functioning
Security
- Dont log sensitive information. Make sure you never log:
- authorization tokens
- passwords
- financial data
- health data
- PII (Personal Identifiable Information)
Testing
- All new packages and most new significant functionality should come with unit tests
Unit tests
- A good unit test should:
- focus on a single use-case at a time
- have a minimal set of assertions per test
- demonstrate every use case. The rule of thumb is: if it can happen, it should be covered
Refer to Django documentation for best practices in views, models, forms, and security considerations.

View File

@@ -11,7 +11,6 @@ on:
pull_request:
branches:
- 'main'
- 'ci/trivy-fails'
env:
DOCKER_USER: 1001:127
@@ -39,6 +38,7 @@ jobs:
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -72,6 +72,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -105,7 +106,8 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6

View File

@@ -8,23 +8,9 @@ and this project adheres to
## [Unreleased]
## [3.1.0] - 2025-04-07
## Added
- 🚩(backend) add feature flag for the footer #841
- 🔧(backend) add view to manage footer json #841
- ✨(frontend) add custom css style #771
- 🚩(frontend) conditionally render AI button only when feature is enabled #814
## Changed
- 🚨(frontend) block button when creating doc #749
## Fixed
- 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840
## [3.0.0] - 2025-03-28
@@ -524,8 +510,7 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.1.0...main
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.0.0...main
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0

View File

@@ -1,20 +1,20 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it works. It needs to be adapted for a production environment.
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it's work. It needs to be adapt for production environment.
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we provide an example)
- a PostgreSQL server (if you don't have one, we provide an example)
- a Memcached server (if you don't have one, we provide an example)
- a S3 bucket (if you don't have one, we provide an example)
- an OIDC provider (if you don't have one, we will provide an example)
- a PostgreSQL server (if you don't have one, we will provide an example)
- a Memcached server (if you don't have one, we will provide an example)
- a S3 bucket (if you don't have one, we will provide an example)
### Test cluster
If you do not have a test cluster, you can install everything on a local Kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
To be able to use the script, you need to install:
To be able to use the script, you will need to install:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
@@ -96,13 +96,13 @@ ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
```
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the `*.127.0.0.1.nip.io` domain and mkcert certificates to have full HTTPS support and easy domain name management.
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the \*.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
### What do you use to authenticate your users?
### What will you use to authenticate your users ?
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
@@ -117,9 +117,9 @@ keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
From here the important information you will need are:
From here the important informations you will need are :
```yaml
```
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
@@ -135,7 +135,7 @@ You can find these values in **examples/keycloak.values.yaml**
### Find redis server connexion values
Docs needs a redis so we start by deploying one:
Impress need a redis so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
@@ -148,7 +148,7 @@ redis-master-0 1/1 Running 0 35s
### Find postgresql connexion values
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
Impress uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follow:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
@@ -160,9 +160,9 @@ postgresql-0 1/1 Running 0 14m
redis-master-0 1/1 Running 0 42s
```
From here the important information you will need are:
From here important informations you will need are :
```yaml
```
DB_HOST: postgres-postgresql
DB_NAME: impress
DB_USER: dinum
@@ -175,7 +175,7 @@ POSTGRES_PASSWORD: pass
### Find s3 bucket connexion values
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
Impress uses a s3 bucket to store documents so if you have a provider obtain the necessary information to use it. If you do not have, you can install a local minio testing environment as follow:
```
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
@@ -191,7 +191,7 @@ redis-master-0 1/1 Running 0 10m
## Deployment
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous informations to the helm chart.
Now you are ready to deploy Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
```
$ helm repo add impress https://suitenumerique.github.io/docs/
@@ -214,7 +214,7 @@ redis-master-0 1/1 Running 0 20m
## Test your deployment
In order to test your deployment you have to log into your instance. If you exclusively use our examples you can do:
In order to test your deployment you have to login to your instance. If you use exclusively our examples you can do :
```
$ kubectl get ingress
@@ -227,4 +227,4 @@ impress-docs-ws <none> impress.127.0.0.1.nip.io localhost
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
```
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.

View File

@@ -1,33 +0,0 @@
# Runtime Theming 🎨
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=http://anything/custom-style.css
```
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
### Benefits
This feature provides several benefits, including:
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
```css
body {
background-color: #3498db;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.

View File

@@ -50,7 +50,6 @@ OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
@@ -64,5 +63,3 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
# Frontend
FRONTEND_THEME=default
FRONTEND_FOOTER_FEATURE_ENABLED=True
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json

View File

@@ -16,10 +16,8 @@ from django.db import transaction
from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.utils.decorators import method_decorator
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page
import requests
import rest_framework as drf
@@ -32,7 +30,6 @@ from rest_framework.throttling import UserRateThrottle
from core import authentication, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.config_services import get_footer_json
from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
@@ -1687,12 +1684,9 @@ class ConfigView(drf.views.APIView):
Return a dictionary of public settings.
"""
array_settings = [
"AI_FEATURE_ENABLED",
"COLLABORATION_WS_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_FOOTER_FEATURE_ENABLED",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
@@ -1706,22 +1700,3 @@ class ConfigView(drf.views.APIView):
dict_settings[setting] = getattr(settings, setting)
return drf.response.Response(dict_settings)
class FooterView(drf.views.APIView):
"""API ViewSet for sharing the footer JSON."""
permission_classes = [AllowAny]
@method_decorator(cache_page(settings.FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT))
def get(self, request):
"""
GET /api/v1.0/footer/
Return the footer JSON.
"""
json_footer = (
get_footer_json(settings.FRONTEND_URL_JSON_FOOTER)
if settings.FRONTEND_URL_JSON_FOOTER
else {}
)
return drf.response.Response(json_footer)

View File

@@ -1,25 +0,0 @@
"""Config services."""
import logging
import requests
logger = logging.getLogger(__name__)
def get_footer_json(footer_json_url: str) -> dict:
"""
Fetches the footer JSON from the given URL."
"""
try:
response = requests.get(
footer_json_url, timeout=5, headers={"User-Agent": "Docs-Application"}
)
response.raise_for_status()
footer_json = response.json()
return footer_json
except (requests.RequestException, ValueError) as e:
logger.error("Failed to fetch footer JSON: %s", e)
return {}

View File

@@ -2,8 +2,6 @@
from unittest import mock
from django.core.cache import cache
import pytest
USER = "user"
@@ -11,12 +9,6 @@ TEAM = "team"
VIA = [USER, TEAM]
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""

View File

@@ -5,6 +5,7 @@ Test AI transform API endpoint for users in impress's core app.
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
@@ -16,6 +17,12 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""

View File

@@ -5,6 +5,7 @@ Test AI translate API endpoint for users in impress's core app.
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
@@ -16,6 +17,12 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""

View File

@@ -18,8 +18,6 @@ pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_WS_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_FOOTER_FEATURE_ENABLED=True,
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
@@ -40,8 +38,6 @@ def test_api_config(is_authenticated):
"COLLABORATION_WS_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_FOOTER_FEATURE_ENABLED": True,
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
@@ -53,5 +49,4 @@ def test_api_config(is_authenticated):
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
"AI_FEATURE_ENABLED": False,
}

View File

@@ -1,81 +0,0 @@
"""Test the footer API."""
import responses
from rest_framework.test import APIClient
def test_api_footer_without_settings_configured(settings):
"""Test the footer API without settings configured."""
settings.FRONTEND_URL_JSON_FOOTER = None
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
@responses.activate
def test_api_footer_with_invalid_request(settings):
"""Test the footer API with an invalid request."""
settings.FRONTEND_URL_JSON_FOOTER = "https://invalid-request.com"
footer_response = responses.get(settings.FRONTEND_URL_JSON_FOOTER, status=404)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_invalid_json(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, body="invalid json"
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_valid_json(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_valid_json_and_cache(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
assert footer_response.call_count == 1
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
# The cache should have been used
assert footer_response.call_count == 1

View File

@@ -4,8 +4,10 @@ Test throttling on documents for the AI endpoint.
from unittest.mock import patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
@@ -23,6 +25,12 @@ class DocumentAPIView(APIView):
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_document_rate_throttle_minute_limit(mock_time):

View File

@@ -5,6 +5,7 @@ Test throttling on users for the AI endpoint.
from unittest.mock import patch
from uuid import uuid4
from django.core.cache import cache
from django.test import override_settings
import pytest
@@ -28,6 +29,12 @@ class DocumentAPIView(APIView):
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_user_rate_throttle_minute_limit(mock_time):

View File

@@ -56,5 +56,4 @@ urlpatterns = [
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
path(f"api/{settings.API_VERSION}/footer/", viewsets.FooterView.as_view()),
]

View File

@@ -410,22 +410,6 @@ class Base(Configuration):
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
FRONTEND_URL_JSON_FOOTER = values.Value(
None, environ_name="FRONTEND_URL_JSON_FOOTER", environ_prefix=None
)
FRONTEND_FOOTER_FEATURE_ENABLED = values.BooleanValue(
default=False,
environ_name="FRONTEND_FOOTER_FEATURE_ENABLED",
environ_prefix=None,
)
FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT = values.Value(
60 * 60 * 24,
environ_name="FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT",
environ_prefix=None,
)
FRONTEND_CSS_URL = values.Value(
None, environ_name="FRONTEND_CSS_URL", environ_prefix=None
)
# Posthog
POSTHOG_KEY = values.DictValue(
@@ -544,9 +528,6 @@ class Base(Configuration):
)
# AI service
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
)
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.1.0"
version = "3.0.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,10 +25,10 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.3",
"boto3==1.37.24",
"beautifulsoup4==4.12.3",
"boto3==1.37.18",
"Brotli==1.1.0",
"celery[redis]==5.5.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
@@ -38,9 +38,9 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.5",
"django-timezone-field>=5.1",
"django==5.1.8",
"django==5.1.7",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
@@ -51,13 +51,14 @@ dependencies = [
"markdown==3.7",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.70.0",
"openai==1.68.2",
"psycopg[binary]==3.2.6",
"pycrdt==0.12.10",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.25.0",
"sentry-sdk==2.24.0",
"url-normalize==1.4.3",
"whitenoise==6.9.0",
]
@@ -85,7 +86,7 @@ dev = [
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.2",
"types-requests==2.32.0.20250328",
"types-requests==2.32.0.20250306",
]
[tool.setuptools]

View File

@@ -2,15 +2,12 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
import { createDoc, verifyDocName } from './common';
const config = {
AI_FEATURE_ENABLED: true,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_FOOTER_FEATURE_ENABLED: true,
FRONTEND_THEME: 'default',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
@@ -101,46 +98,23 @@ test.describe('Config', () => {
page,
browserName,
}) => {
await page.goto('/');
void createDoc(page, 'doc-collaboration', browserName, 1);
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/collaboration/ws/');
});
expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/');
});
test('it checks the AI feature flag from config endpoint', async ({
page,
browserName,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
AI_FEATURE_ENABLED: false,
},
});
} else {
await route.continue();
}
});
await page.goto('/');
await createDoc(page, 'doc-ai-feature', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
expect(await page.locator('button[data-test="ai-actions"]').count()).toBe(
0,
const randomDoc = await createDoc(
page,
'doc-collaboration',
browserName,
1,
);
await verifyDocName(page, randomDoc[0]);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({
@@ -166,30 +140,6 @@ test.describe('Config', () => {
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
test('it checks FRONTEND_CSS_URL config', async ({ page }) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
FRONTEND_CSS_URL: 'http://localhost:123465/css/style.css',
},
});
} else {
await route.continue();
}
});
await page.goto('/');
await expect(
page
.locator('head link[href="http://localhost:123465/css/style.css"]')
.first(),
).toBeAttached();
});
});
test.describe('Config: Not loggued', () => {

View File

@@ -25,11 +25,7 @@ test.describe('Doc Editor', () => {
await editor.click();
await editor.fill('test content');
await editor
.getByText('test content', {
exact: true,
})
.selectText();
await editor.getByText('test content').dblclick();
const toolbar = page.locator('.bn-formatting-toolbar');
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
@@ -62,18 +58,18 @@ test.describe('Doc Editor', () => {
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({ page }) => {
test('checks the connection with collaborative server', async ({
page,
browserName,
}) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:4444/collaboration/ws/?room=');
});
await page
.getByRole('button', {
name: 'New doc',
})
.click();
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, randomDoc[0]);
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
@@ -103,7 +99,7 @@ test.describe('Doc Editor', () => {
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
// Checkt the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
@@ -130,7 +126,7 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('[test markdown]')).toBeVisible();
await editor.getByText('[test markdown]').selectText();
await editor.getByText('[test markdown]').dblclick();
await page.locator('button[data-test="convertMarkdown"]').click();
await expect(editor.getByText('[test markdown]')).toBeHidden();
@@ -223,8 +219,11 @@ test.describe('Doc Editor', () => {
await editor.fill('Hello World Doc persisted 2');
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
const urlDoc = page.url();
await page.goto(urlDoc);
await page.goto('/');
await goToGridDoc(page, {
title: doc,
});
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
@@ -298,7 +297,7 @@ test.describe('Doc Editor', () => {
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await editor.getByText('Hello').dblclick();
await page.getByRole('button', { name: 'AI' }).click();
@@ -339,7 +338,6 @@ test.describe('Doc Editor', () => {
].forEach(({ ai_transform, ai_translate }) => {
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
page,
browserName,
}) => {
await mockedDocument(page, {
accesses: [
@@ -366,22 +364,16 @@ test.describe('Doc Editor', () => {
link_reach: 'public',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
title: '',
});
const [randomDoc] = await createDoc(
page,
'doc-editor-ai',
browserName,
1,
);
await goToGridDoc(page);
await verifyDocName(page, randomDoc);
await verifyDocName(page, 'Mocked document');
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await editor.getByText('Hello').dblclick();
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */

View File

@@ -190,7 +190,7 @@ test.describe('Document grid item options', () => {
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
void page.goto('/');
await page.goto('/');
// All Docs
const response = await page.waitForResponse(
@@ -263,7 +263,7 @@ test.describe('Documents filters', () => {
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
void page.goto('/');
await page.goto('/');
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(

View File

@@ -86,7 +86,7 @@ test.describe('Document search', () => {
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello world');
await editor.getByText('Hello world').selectText();
await editor.getByText('Hello world').dblclick();
await page.keyboard.press('Control+k');
await expect(page.getByRole('textbox', { name: 'Edit URL' })).toBeVisible();

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.1.0",
"version": "3.0.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",

View File

@@ -9,7 +9,6 @@ const createJestConfig = nextJest({
const config: Config = {
coverageProvider: 'v8',
moduleNameMapper: {
'^@/docs/(.*)$': '<rootDir>/src/features/docs/$1',
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.1.0",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -30,7 +30,6 @@
"@sentry/nextjs": "9.3.0",
"@tanstack/react-query": "5.67.1",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.0.4",
"crisp-sdk-web": "1.0.25",
"docx": "9.1.1",
@@ -39,7 +38,7 @@
"idb": "8.0.2",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.2.4",
"next": "15.2.3",
"posthog-js": "1.227.0",
"react": "*",
"react-aria-components": "1.6.0",

View File

@@ -1,121 +0,0 @@
{
"default": {
"externalLinks": [
{
"label": "Github",
"href": "https://github.com/suitenumerique/docs/"
},
{
"label": "DINUM",
"href": "https://www.numerique.gouv.fr/dinum/"
},
{
"label": "ZenDiS",
"href": "https://zendis.de/"
},
{
"label": "BlockNote.js",
"href": "https://www.blocknotejs.org/"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"en": {
"legalLinks": [
{
"label": "Legal Notice",
"href": "#"
},
{
"label": "Personal data and cookies",
"href": "#"
},
{
"label": "Accessibility",
"href": "#"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"fr": {
"legalLinks": [
{
"label": "Mentions légales",
"href": "#"
},
{
"label": "Données personnelles et cookies",
"href": "#"
},
{
"label": "Accessibilité",
"href": "#"
}
],
"bottomInformation": {
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"de": {
"legalLinks": [
{
"label": "Impressum",
"href": "#"
},
{
"label": "Personenbezogene Daten und Cookies",
"href": "#"
},
{
"label": "Barrierefreiheit",
"href": "#"
}
],
"bottomInformation": {
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"nl": {
"legalLinks": [
{
"label": "Wettelijke bepalingen",
"href": "#"
},
{
"label": "Persoonlijke gegevens en cookies",
"href": "#"
},
{
"label": "Toegankelijkheid",
"href": "#"
}
],
"bottomInformation": {
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
}
}

View File

@@ -1,135 +0,0 @@
{
"default": {
"externalLinks": [
{
"label": "legifrance.gouv.fr",
"href": "https://legifrance.gouv.fr/"
},
{
"label": "info.gouv.fr",
"href": "https://info.gouv.fr/"
},
{
"label": "service-public.fr",
"href": "https://service-public.fr/"
},
{
"label": "data.gouv.fr",
"href": "https://data.gouv.fr/"
}
],
"legalLinks": [
{
"label": "Legal Notice",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Personal data and cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Accessibility",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"en": {
"legalLinks": [
{
"label": "Legal Notice",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Personal data and cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Accessibility",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"fr": {
"legalLinks": [
{
"label": "Mentions légales",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Données personnelles et cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Accessibilité",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"de": {
"legalLinks": [
{
"label": "Impressum",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Personenbezogene Daten und Cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Barrierefreiheit",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"nl": {
"legalLinks": [
{
"label": "Wettelijke bepalingen",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Persoonlijke gegevens en cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Toegankelijkheid",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 B

View File

@@ -24,7 +24,6 @@ export interface BoxProps {
$hasTransition?: boolean | 'slow';
$height?: CSSProperties['height'];
$justify?: CSSProperties['justifyContent'];
$opacity?: CSSProperties['opacity'];
$overflow?: CSSProperties['overflow'];
$margin?: MarginPadding;
$maxHeight?: CSSProperties['maxHeight'];
@@ -66,7 +65,6 @@ export const Box = styled('div')<BoxProps>`
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight};`}
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
${({ $opacity }) => $opacity && `opacity: ${$opacity};`}
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
${({ $padding }) => $padding && stylesPadding($padding)}
${({ $position }) => $position && `position: ${$position};`}

View File

@@ -44,7 +44,6 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
${$css || ''}
`}
{...props}
className={`--docs--box-button ${props.className || ''}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;

View File

@@ -14,7 +14,6 @@ export const Card = ({
return (
<Box
className={`--docs--card ${props.className || ''}`}
$background="white"
$radius="4px"
$css={css`

View File

@@ -71,7 +71,6 @@ export const DropButton = ({
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
$css={buttonCss}
className="--docs--drop-button"
>
{button}
</StyledButton>
@@ -80,7 +79,6 @@ export const DropButton = ({
triggerRef={triggerRef}
isOpen={isLocalOpen}
onOpenChange={onOpenChangeHandler}
className="--docs--drop-button-popover"
>
{children}
</StyledPopover>

View File

@@ -1,24 +1,41 @@
import clsx from 'clsx';
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type IconProps = TextType & {
iconName: string;
variant?: 'filled' | 'outlined';
};
export const Icon = ({
iconName,
variant = 'outlined',
...textProps
}: IconProps) => {
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
};
interface IconBGProps extends TextType {
iconName: string;
}
export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Text
$isMaterialIcon
$size="36px"
$theme="primary"
$variation="600"
$background={colorsTokens()['primary-bg']}
$css={`
border: 1px solid ${colorsTokens()['primary-200']};
user-select: none;
`}
$radius="12px"
$padding="4px"
$margin="auto"
{...textProps}
className={clsx('--docs--icon-bg', textProps.className, {
'material-icons-filled': variant === 'filled',
'material-icons': variant === 'outlined',
})}
>
{iconName}
</Text>
@@ -31,13 +48,15 @@ type IconOptionsProps = TextType & {
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
return (
<Icon
<Text
{...props}
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
$isMaterialIcon
$css={css`
user-select: none;
${props.$css}
`}
/>
>
{isHorizontal ? 'more_horiz' : 'more_vert'}
</Text>
);
};

View File

@@ -30,10 +30,7 @@ export const InfiniteScroll = ({
};
return (
<Box
{...boxProps}
className={`--docs--infinite-scroll ${boxProps.className || ''}`}
>
<Box {...boxProps}>
{children}
<InView onChange={loadMore}>
{!isLoading && hasMore && (

View File

@@ -20,7 +20,6 @@ export const LoadMoreText = ({
$align="center"
$gap="0.4rem"
$padding={{ horizontal: '2xs', vertical: 'sm' }}
className="--docs--load-more"
>
<Icon
$theme="primary"

View File

@@ -11,6 +11,7 @@ type TextSizes = keyof typeof sizes;
export interface TextProps extends BoxProps {
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
$elipsis?: boolean;
$isMaterialIcon?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$size?: TextSizes | (string & {});
@@ -56,14 +57,14 @@ export const TextStyled = styled(Box)<TextProps>`
`;
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, ...props }, ref) => {
({ className, $isMaterialIcon, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={className}
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);

View File

@@ -28,12 +28,7 @@ export const TextErrors = ({
const { t } = useTranslation();
return (
<AlertStyled
canClose={canClose}
type={VariantType.ERROR}
icon={icon}
className="--docs--text-errors"
>
<AlertStyled canClose={canClose} type={VariantType.ERROR} icon={icon}>
<Box $direction="column" $gap="0.2rem">
{causes &&
causes.map((cause, i) => (

View File

@@ -119,6 +119,8 @@ export const QuickSearchStyle = createGlobalStyle`
}
}
.c__modal__scroller:has(.quick-search-container),
.c__modal__scroller:has(.noPadding) {
padding: 0 !important;
@@ -136,4 +138,6 @@ export const QuickSearchStyle = createGlobalStyle`
margin-bottom: 0;
}
}
`;

View File

@@ -28,7 +28,6 @@ export const HorizontalSeparator = ({
? '#e5e5e533'
: colorsTokens()['greyscale-100']
}
className="--docs--horizontal-separator"
/>
);
};

View File

@@ -1,5 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import Head from 'next/head';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
@@ -55,17 +54,10 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
}
return (
<>
{conf?.FRONTEND_CSS_URL && (
<Head>
<link rel="stylesheet" href={conf?.FRONTEND_CSS_URL} />
</Head>
)}
<AnalyticsProvider>
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
{children}
</CrispProvider>
</AnalyticsProvider>
</>
<AnalyticsProvider>
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
{children}
</CrispProvider>
</AnalyticsProvider>
);
};

View File

@@ -11,11 +11,9 @@ interface ConfigResponse {
COLLABORATION_WS_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
FRONTEND_CSS_URL?: string;
MEDIA_BASE_URL?: string;
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
AI_FEATURE_ENABLED?: boolean;
}
export const getConfig = async (): Promise<ConfigResponse> => {

View File

@@ -1,6 +1,3 @@
@import url('@gouvfr-lasuite/ui-kit/style');
@import url('./cunningham-tokens.css');
:root {
/**
* Input
@@ -36,10 +33,3 @@
--c--components--button--border-radius
);
}
/**
* Tooltip
*/
.c__tooltip {
padding: 4px 6px;
}

View File

@@ -18,7 +18,6 @@ export const ButtonLogin = () => {
onClick={() => gotoLogin()}
color="primary-text"
aria-label={t('Login')}
className="--docs--button-login"
>
{t('Login')}
</Button>
@@ -26,12 +25,7 @@ export const ButtonLogin = () => {
}
return (
<Button
onClick={gotoLogout}
color="primary-text"
aria-label={t('Logout')}
className="--docs--button-logout"
>
<Button onClick={gotoLogout} color="primary-text" aria-label={t('Logout')}>
{t('Logout')}
</Button>
);
@@ -51,7 +45,6 @@ export const ProConnectButton = () => {
}
`}
$radius="4px"
className="--docs--proconnect-button"
>
<ProConnectImg />
</BoxButton>

View File

@@ -133,7 +133,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly)}
className="--docs--editor-container"
>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
@@ -193,7 +192,7 @@ export const BlockNoteEditorVersion = ({
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)} className="--docs--editor-container">
<Box $css={cssEditor(readOnly)}>
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
</Box>
);

View File

@@ -14,7 +14,7 @@ import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { isAPIError } from '@/api';
import { Box, Icon } from '@/components';
import { Box, Text } from '@/components';
import { useDocOptions, useDocStore } from '@/docs/doc-management/';
import {
@@ -104,15 +104,19 @@ export function AIGroupButton() {
<Components.Generic.Menu.Root>
<Components.Generic.Menu.Trigger>
<Components.FormattingToolbar.Button
className="bn-button bn-menu-item --docs--ai-actions-menu-trigger"
className="bn-button bn-menu-item"
data-test="ai-actions"
label="AI"
mainTooltip={t('AI Actions')}
icon={<Icon iconName="auto_awesome" $size="l" />}
icon={
<Text $isMaterialIcon $size="l">
auto_awesome
</Text>
}
/>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown
className="bn-menu-dropdown bn-drag-handle-menu --docs--ai-actions-menu"
className="bn-menu-dropdown bn-drag-handle-menu"
sub={true}
>
{canAITransform && (
@@ -120,42 +124,66 @@ export function AIGroupButton() {
<AIMenuItemTransform
action="prompt"
docId={currentDoc.id}
icon={<Icon iconName="text_fields" $size="s" />}
icon={
<Text $isMaterialIcon $size="s">
text_fields
</Text>
}
>
{t('Use as prompt')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="rephrase"
docId={currentDoc.id}
icon={<Icon iconName="refresh" $size="s" />}
icon={
<Text $isMaterialIcon $size="s">
refresh
</Text>
}
>
{t('Rephrase')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="summarize"
docId={currentDoc.id}
icon={<Icon iconName="summarize" $size="s" />}
icon={
<Text $isMaterialIcon $size="s">
summarize
</Text>
}
>
{t('Summarize')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="correct"
docId={currentDoc.id}
icon={<Icon iconName="check" $size="s" />}
icon={
<Text $isMaterialIcon $size="s">
check
</Text>
}
>
{t('Correct')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="beautify"
docId={currentDoc.id}
icon={<Icon iconName="draw" $size="s" />}
icon={
<Text $isMaterialIcon $size="s">
draw
</Text>
}
>
{t('Beautify')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="emojify"
docId={currentDoc.id}
icon={<Icon iconName="emoji_emotions" $size="s" />}
icon={
<Text $isMaterialIcon $size="s">
emoji_emotions
</Text>
}
>
{t('Emojify')}
</AIMenuItemTransform>
@@ -165,18 +193,20 @@ export function AIGroupButton() {
<Components.Generic.Menu.Root position="right" sub={true}>
<Components.Generic.Menu.Trigger sub={false}>
<Components.Generic.Menu.Item
className="bn-menu-item --docs--ai-translate-menu-trigger"
className="bn-menu-item"
subTrigger={true}
>
<Box $direction="row" $gap="0.6rem">
<Icon iconName="translate" $size="s" />
<Text $isMaterialIcon $size="s">
translate
</Text>
{t('Language')}
</Box>
</Components.Generic.Menu.Item>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown
sub={true}
className="bn-menu-dropdown --docs--ai-translate-menu"
className="bn-menu-dropdown"
>
{languages.map((language) => (
<AIMenuItemTranslate

View File

@@ -8,8 +8,6 @@ import {
import React, { JSX, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core/config/api';
import { getQuoteFormattingToolbarItems } from '../custom-blocks';
import { AIGroupButton } from './AIButton';
@@ -22,7 +20,6 @@ export const BlockNoteToolbar = () => {
const [confirmOpen, setIsConfirmOpen] = useState(false);
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
const { t } = useTranslation();
const { data: conf } = useConfig();
const toolbarItems = useMemo(() => {
const toolbarItems = getFormattingToolbarItems([
@@ -59,13 +56,13 @@ export const BlockNoteToolbar = () => {
{toolbarItems}
{/* Extra button to do some AI powered actions */}
{conf?.AI_FEATURE_ENABLED && <AIGroupButton key="AIButton" />}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
</FormattingToolbar>
);
}, [toolbarItems, conf?.AI_FEATURE_ENABLED]);
}, [toolbarItems]);
return (
<>

View File

@@ -94,7 +94,7 @@ export const FileDownloadButton = ({
return (
<>
<Components.FormattingToolbar.Button
className="bn-button --docs--editor-file-download-button"
className="bn-button"
label={
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
dict.formatting_toolbar.file_download.tooltip['file']

View File

@@ -82,7 +82,6 @@ export function MarkdownButton() {
<Components.FormattingToolbar.Button
mainTooltip={t('Convert Markdown')}
onClick={handleConvertMarkdown}
className="--docs--editor-markdown-button"
>
M
</Components.FormattingToolbar.Button>

View File

@@ -1,7 +1,7 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { Box, Text } from '@/components';
interface ModalConfirmDownloadUnsafeProps {
onClose: () => void;
@@ -52,15 +52,14 @@ export const ModalConfirmDownloadUnsafe = ({
$variation="1000"
$direction="row"
>
<Icon iconName="warning" $theme="warning" />
<Text $isMaterialIcon $theme="warning">
warning
</Text>
{t('Warning')}
</Text>
}
>
<Box
aria-label={t('Modal confirmation to download the attachment')}
className="--docs--modal-confirm-download-unsafe"
>
<Box aria-label={t('Modal confirmation to download the attachment')}>
<Box>
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
<Text $variation="700">{t('This file is flagged as unsafe.')}</Text>

View File

@@ -49,16 +49,8 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
<TableContent />
</Box>
)}
<Box
$maxWidth="868px"
$width="100%"
$height="100%"
className="--docs--doc-editor"
>
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
className="--docs--doc-editor-header"
>
<Box $maxWidth="868px" $width="100%" $height="100%">
<Box $padding={{ horizontal: isDesktop ? '54px' : 'base' }}>
{isVersion ? (
<DocVersionHeader title={doc.title} />
) : (
@@ -72,7 +64,6 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content"
>
<Box $css="flex:1;" $position="relative" $width="100%">
{isVersion ? (
@@ -124,7 +115,7 @@ export const DocVersionEditor = ({
}
return (
<Box $margin="large" className="--docs--doc-version-editor-error">
<Box $margin="large">
<TextErrors
causes={error.cause}
icon={

View File

@@ -2,7 +2,7 @@ import { insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import { Box, Icon } from '@/components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../../types';
@@ -45,7 +45,11 @@ export const getDividerReactSlashMenuItems = (
},
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
group,
icon: <Icon iconName="remove" $size="18px" />,
icon: (
<Text $isMaterialIcon $size="18px">
remove
</Text>
),
subtext: t('Add a horizontal line'),
},
];

View File

@@ -1,8 +1,9 @@
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React from 'react';
import { Box, Icon } from '@/components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../../types';
@@ -53,7 +54,11 @@ export const getQuoteReactSlashMenuItems = (
},
aliases: ['quote', 'blockquote', 'citation'],
group,
icon: <Icon iconName="format_quote" $size="18px" />,
icon: (
<Text $isMaterialIcon $size="18px">
format_quote
</Text>
),
subtext: t('Add a quote block'),
},
];
@@ -63,6 +68,10 @@ export const getQuoteFormattingToolbarItems = (
): BlockTypeSelectItem => ({
name: t('Quote'),
type: 'quote',
icon: () => <Icon iconName="format_quote" $size="16px" />,
icon: () => (
<Text $isMaterialIcon $size="16px">
format_quote
</Text>
),
isSelected: (block) => block.type === 'quote',
});

View File

@@ -1,185 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { useRouter } from 'next/router';
import * as Y from 'yjs';
import { AppWrapper } from '@/tests/utils';
import useSaveDoc from '../useSaveDoc';
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
jest.mock('@/docs/doc-versioning', () => ({
KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions',
}));
jest.mock('@/docs/doc-management', () => ({
useUpdateDoc: jest.requireActual('@/docs/doc-management/api/useUpdateDoc')
.useUpdateDoc,
}));
describe('useSaveDoc', () => {
const mockRouterEvents = {
on: jest.fn(),
off: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
fetchMock.restore();
(useRouter as jest.Mock).mockReturnValue({
events: mockRouterEvents,
});
});
it('should setup event listeners on mount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
// Verify router event listeners are set up
expect(mockRouterEvents.on).toHaveBeenCalledWith(
'routeChangeStart',
expect.any(Function),
);
// Verify window event listener is set up
expect(addEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
addEventListenerSpy.mockRestore();
});
it('should not save when canSave is false', async () => {
jest.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, false), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
jest.useRealTimers();
});
it('should save when there are local changes', async () => {
jest.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
await waitFor(() => {
expect(fetchMock.lastCall()?.[0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
);
});
jest.useRealTimers();
});
it('should not save when there are no local changes', async () => {
jest.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
jest.useRealTimers();
});
it('should cleanup event listeners on unmount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
unmount();
// Verify router event listeners are cleaned up
expect(mockRouterEvents.off).toHaveBeenCalledWith(
'routeChangeStart',
expect.any(Function),
);
// Verify window event listener is cleaned up
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
});

View File

@@ -1,5 +1,5 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/docs/doc-management/';
@@ -8,16 +8,17 @@ import { isFirefox } from '@/utils/userAgent';
import { toBase64 } from '../utils';
const SAVE_INTERVAL = 60000;
const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
setIsLocalChange(false);
},
});
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),
);
useEffect(() => {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(doc)));
}, [doc]);
/**
* Update initial doc when doc is updated by other users,
@@ -28,37 +29,57 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
const onUpdate = (
_uintArray: Uint8Array,
_pluginKey: string,
_updatedDoc: Y.Doc,
updatedDoc: Y.Doc,
transaction: Y.Transaction,
) => {
setIsLocalChange(transaction.local ? true : false);
if (!transaction.local) {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc)));
}
};
yDoc.on('update', onUpdate);
doc.on('update', onUpdate);
return () => {
yDoc.off('update', onUpdate);
doc.off('update', onUpdate);
};
}, [yDoc]);
}, [doc]);
/**
* Check if the doc has been updated and can be saved.
*/
const hasChanged = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
return initialDoc !== newDoc;
}, [doc, initialDoc]);
const shouldSave = useCallback(() => {
return hasChanged() && canSave;
}, [canSave, hasChanged]);
const saveDoc = useCallback(() => {
if (!canSave || !isLocalChange) {
return false;
}
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
setInitialDoc(newDoc);
updateDoc({
id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
content: newDoc,
});
}, [doc, docId, updateDoc]);
return true;
}, [canSave, yDoc, docId, isLocalChange, updateDoc]);
const timeout = useRef<NodeJS.Timeout | null>(null);
const router = useRouter();
useEffect(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
const onSave = (e?: Event) => {
const isSaving = saveDoc();
if (!shouldSave()) {
return;
}
saveDoc();
/**
* Firefox does not trigger the request everytime the user leaves the page.
@@ -67,30 +88,27 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
* if he wants to leave the page, by adding the popup, we let the time to the
* request to be sent, and intercepted by the service worker (for the offline part).
*/
if (
isSaving &&
typeof e !== 'undefined' &&
e.preventDefault &&
isFirefox()
) {
if (typeof e !== 'undefined' && e.preventDefault && isFirefox()) {
e.preventDefault();
}
};
// Save every minute
const timeout = setInterval(onSave, SAVE_INTERVAL);
timeout.current = setInterval(onSave, 60000);
// Save when the user leaves the page
addEventListener('beforeunload', onSave);
// Save when the user navigates to another page
router.events.on('routeChangeStart', onSave);
return () => {
clearInterval(timeout);
if (timeout.current) {
clearTimeout(timeout.current);
}
removeEventListener('beforeunload', onSave);
router.events.off('routeChangeStart', onSave);
};
}, [router.events, saveDoc]);
}, [router.events, saveDoc, shouldSave]);
};
export default useSaveDoc;

View File

@@ -1,4 +1,3 @@
import _ from 'lodash';
import { create } from 'zustand';
import { DocsBlockNoteEditor, HeadingBlock } from '../types';
@@ -25,7 +24,7 @@ export interface UseHeadingStore {
resetHeadings: () => void;
}
export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
export const useHeadingStore = create<UseHeadingStore>((set) => ({
headings: [],
setHeadings: (editor) => {
const headingBlocks = editor?.document
@@ -37,9 +36,7 @@ export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
),
})) as unknown as HeadingBlock[];
if (!_.isEqual(get().headings, headingBlocks)) {
set(() => ({ headings: headingBlocks }));
}
set(() => ({ headings: headingBlocks }));
},
resetHeadings: () => set(() => ({ headings: [] })),
}));

View File

@@ -105,12 +105,6 @@ export const cssEditor = (readonly: boolean) => css`
padding: 2px;
border-radius: 4px;
}
& .bn-inline-content {
width: 100%;
}
.bn-block-content[data-content-type='checkListItem'] > div {
width: 100%;
}
@media screen and (width <= 768px) {
& .bn-editor {

View File

@@ -155,7 +155,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
$margin={{ bottom: 'xl' }}
aria-label={t('Content modal to export the document')}
$gap="1rem"
className="--docs--modal-export-content"
>
<Text $variation="600" $size="sm">
{t('Download your document in a .docx or .pdf format.')}

View File

@@ -38,7 +38,6 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$padding={{ top: isDesktop ? '4xl' : 'md' }}
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')}
className="--docs--doc-header"
>
{(docIsPublic || docIsAuth) && (
<Box

View File

@@ -107,7 +107,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
<Box
as="span"
role="textbox"
className="--docs--doc-title-input"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}

View File

@@ -176,7 +176,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$align="center"
$gap="0.5rem 1.5rem"
$wrap={isSmallMobile ? 'wrap' : 'nowrap'}
className="--docs--doc-toolbox"
>
<Box
$direction="row"

View File

@@ -22,7 +22,6 @@ export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {
$padding={{ vertical: 'base' }}
$gap={spacings['base']}
aria-label={t('It is the document title')}
className="--docs--doc-version-header"
>
<DocTitleText title={title} />
<HorizontalSeparator />

View File

@@ -84,10 +84,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
</Text>
}
>
<Box
aria-label={t('Content modal to delete document')}
className="--docs--modal-remove-doc"
>
<Box aria-label={t('Content modal to delete document')}>
{!isError && (
<Text $size="sm" $variation="600">
{t('Are you sure you want to delete this document ?')}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 280 KiB

View File

@@ -11,11 +11,7 @@ type DocSearchItemProps = {
export const DocSearchItem = ({ doc }: DocSearchItemProps) => {
const { isDesktop } = useResponsiveStore();
return (
<Box
data-testid={`doc-search-item-${doc.id}`}
$width="100%"
className="--docs--doc-search-item"
>
<Box data-testid={`doc-search-item-${doc.id}`} $width="100%">
<QuickSearchItemContent
left={
<Box $direction="row" $align="center" $gap="10px" $width="100%">

View File

@@ -1,4 +1,4 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { Modal, ModalProps, ModalSize } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
@@ -19,10 +19,7 @@ import EmptySearchIcon from '../assets/illustration-docs-empty.png';
import { DocSearchItem } from './DocSearchItem';
type DocSearchModalProps = {
onClose: () => void;
isOpen: boolean;
};
type DocSearchModalProps = ModalProps & {};
export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
const { t } = useTranslation();
@@ -71,7 +68,6 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
aria-label={t('Search modal')}
$direction="column"
$justify="space-between"
className="--docs--doc-search-modal"
>
<QuickSearch
placeholder={t('Type the name of a document')}

View File

@@ -122,7 +122,6 @@ export const DocShareAddMemberList = ({
$css={css`
border: 1px solid ${color['greyscale-200']};
`}
className="--docs--doc-share-add-member-list"
>
<Box
$direction="row"

View File

@@ -34,7 +34,6 @@ export const DocShareAddMemberListItem = ({ user, onRemoveUser }: Props) => {
color: ${color['greyscale-1000']};
font-size: ${fontSize['xs']};
`}
className="--docs--doc-share-add-member-list-item"
>
<Text $variation="1000" $size="xs">
{user.full_name || user.email}

View File

@@ -84,7 +84,6 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
<Box
$width="100%"
data-testid={`doc-share-invitation-row-${invitation.email}`}
className="--docs--doc-share-invitation-item"
>
<SearchUserRow
isInvitation={true}

View File

@@ -72,7 +72,6 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
<Box
$width="100%"
data-testid={`doc-share-member-row-${access.user.email}`}
className="--docs--doc-share-member-item"
>
<SearchUserRow
alwaysShowRight={true}

View File

@@ -195,7 +195,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
aria-label={t('Share modal')}
$height={canViewAccesses ? modalContentHeight : 'auto'}
$overflow="hidden"
className="--docs--doc-share-modal noPadding "
className="noPadding"
$justify="space-between"
>
<Box

View File

@@ -20,7 +20,6 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
$css={css`
flex-shrink: 0;
`}
className="--docs--doc-share-modal-footer"
>
<HorizontalSeparator $withPadding={true} />

View File

@@ -12,11 +12,7 @@ type Props = {
export const DocShareModalInviteUserRow = ({ user }: Props) => {
const { t } = useTranslation();
return (
<Box
$width="100%"
data-testid={`search-user-row-${user.email}`}
className="--docs--doc-share-modal-invite-user-row"
>
<Box $width="100%" data-testid={`search-user-row-${user.email}`}>
<SearchUserRow
user={user}
right={

View File

@@ -91,7 +91,6 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
$padding={{ horizontal: 'base' }}
aria-label={t('Doc visibility card')}
$gap={spacing['base']}
className="--docs--doc-visibility"
>
<Text $weight="700" $size="sm" $variation="700">
{t('Link parameters')}

View File

@@ -31,12 +31,7 @@ export const SearchUserRow = ({
right={right}
alwaysShowRight={alwaysShowRight}
left={
<Box
$direction="row"
$align="center"
$gap={spacings['xs']}
className="--docs--search-user-row"
>
<Box $direction="row" $align="center" $gap={spacings['xs']}>
<UserAvatar
user={user}
background={isInvitation ? colors['greyscale-400'] : undefined}

View File

@@ -49,7 +49,6 @@ export const UserAvatar = ({ user, background }: Props) => {
color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.5);
`}
className="--docs--user-avatar"
>
<Box
$direction="row"

View File

@@ -60,7 +60,6 @@ export const Heading = ({
$radius="4px"
$background={isActive ? `${colorsTokens()['greyscale-100']}` : 'none'}
$css="text-align: left;"
className="--docs--table-content-heading"
>
<Text
$width="100%"

View File

@@ -122,7 +122,6 @@ export const TableContent = () => {
gap: var(--c--theme--spacings--2xs);
`}
`}
className="--docs--table-content"
>
{!isHover && (
<BoxButton onClick={onOpen} $justify="center" $align="center">

View File

@@ -107,10 +107,7 @@ export const ModalConfirmationVersion = ({
</Text>
}
>
<Box
aria-label={t('Modal confirmation to restore the version')}
className="--docs--modal-confirmation-version"
>
<Box aria-label={t('Modal confirmation to restore the version')}>
<Box>
<Text $variation="600">
{t('Your current document will revert to this version.')}

View File

@@ -51,7 +51,7 @@ export const ModalSelectVersion = ({
<NoPaddingStyle />
<Box
aria-label="version history modal"
className="--docs--modal-select-version noPadding"
className="noPadding"
$direction="row"
$height="100%"
$maxHeight="calc(100vh - 2em - 12px)"

View File

@@ -44,7 +44,6 @@ export const VersionItem = ({
`}
$hasTransition
$minWidth="13rem"
className="--docs--version-item"
>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}

View File

@@ -3,14 +3,7 @@ import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import {
Box,
BoxButton,
Icon,
InfiniteScroll,
Text,
TextErrors,
} from '@/components';
import { Box, BoxButton, InfiniteScroll, Text, TextErrors } from '@/components';
import { Doc } from '@/docs/doc-management';
import { useDate } from '@/hook';
@@ -75,7 +68,9 @@ const VersionListState = ({
causes={error.cause}
icon={
error.status === 502 ? (
<Icon iconName="wifi_off" $theme="danger" />
<Text $isMaterialIcon $theme="danger">
wifi_off
</Text>
) : undefined
}
/>
@@ -114,10 +109,7 @@ export const VersionList = ({
}, [] as Versions[]);
return (
<Box
$css="overflow-y: auto; overflow-x: hidden;"
className="--docs--version-list"
>
<Box $css="overflow-y: auto; overflow-x: hidden;">
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}

View File

@@ -60,7 +60,6 @@ export const DocsGrid = ({
$maxWidth="960px"
$maxHeight="calc(100vh - 52px - 2rem)"
$align="center"
className="--docs--doc-grid"
>
<DocsGridLoader isLoading={isRefetching || loading} />
<Card

View File

@@ -49,7 +49,6 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
background-color: var(--c--theme--colors--greyscale-100);
}
`}
className="--docs--doc-grid-item"
>
<StyledLink
$css={css`

View File

@@ -26,7 +26,6 @@ export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
</Text>
}
placement="top"
className="--docs--doc-tooltip-grid-item-shared-button"
>
<Button
style={{ minWidth: '50px', justifyContent: 'center' }}

View File

@@ -31,7 +31,6 @@ export const DocsGridLoader = ({ isLoading }: DocsGridLoaderProps) => {
$background="rgba(255, 255, 255, 0.5)"
$zIndex={998}
$position="absolute"
className="--docs--doc-grid-loader"
>
<Loader />
</Box>

View File

@@ -38,12 +38,7 @@ export const SimpleDocItem = ({
const { untitledDocument } = useTrans();
return (
<Box
$direction="row"
$gap={spacings.sm}
$overflow="auto"
className="--docs--simple-doc-item"
>
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
<Box
$direction="row"
$align="center"

View File

@@ -21,7 +21,7 @@ export const Footer = () => {
const logo = themeTokens().logo;
return (
<Box $position="relative" as="footer" className="--docs--footer">
<Box $position="relative" as="footer">
<BlueStripe />
<Box $padding={{ top: 'large', horizontal: 'big', bottom: 'small' }}>
<Box
@@ -31,7 +31,7 @@ export const Footer = () => {
$justify="space-between"
$css="flex-wrap: wrap;"
>
<Box className="--docs--footer-logo">
<Box>
<Box $align="center" $gap="6rem" $direction="row">
{logo && (
<Image
@@ -52,7 +52,6 @@ export const Footer = () => {
row-gap: .5rem;
flex-wrap: wrap;
`}
className="--docs--footer-external-links"
>
{[
{
@@ -100,7 +99,6 @@ export const Footer = () => {
column-gap: 1rem;
row-gap: .5rem;
`}
className="--docs--footer-internal-links"
>
{[
{
@@ -147,7 +145,6 @@ export const Footer = () => {
$margin={{ top: 'big' }}
$variation="600"
$display="inline"
className="--docs--footer-licence"
>
{t('Unless otherwise stated, all content on this site is under')}{' '}
<StyledLink

View File

@@ -21,7 +21,6 @@ export const ButtonTogglePanel = () => {
iconName={isPanelOpen ? 'close' : 'menu'}
/>
}
className="--docs--button-toggle-panel"
/>
);
};

View File

@@ -39,7 +39,6 @@ export const Header = () => {
background-color: ${colors['greyscale-000']};
border-bottom: 1px solid ${colors['greyscale-200']};
`}
className="--docs--header"
>
{!isDesktop && <ButtonTogglePanel />}
<StyledLink href="/">

View File

@@ -10,12 +10,7 @@ export const Title = () => {
const spacings = theme.spacingsTokens();
return (
<Box
$direction="row"
$align="center"
$gap={spacings['2xs']}
className="--docs--title"
>
<Box $direction="row" $align="center" $gap={spacings['2xs']}>
<Text
$margin="none"
as="h2"

View File

@@ -29,7 +29,6 @@ export default function HomeBanner() {
$height="100vh"
$margin={{ top: `-${getHeaderHeight(isSmallMobile)}px` }}
$position="relative"
className="--docs--home-banner"
>
<Box
$width="100%"
@@ -76,7 +75,11 @@ export default function HomeBanner() {
) : (
<Button
onClick={() => gotoLogin()}
icon={<Icon iconName="bolt" $color="white" />}
icon={
<Text $isMaterialIcon $color="white">
bolt
</Text>
}
>
{t('Start Writing')}
</Button>

View File

@@ -30,7 +30,6 @@ function HomeProConnect() {
<Box
$justify="center"
$height={!isMobile ? `calc(100vh - ${parentGap})` : 'auto'}
className="--docs--home-proconnect"
>
<Box
$gap={spacings['md']}

View File

@@ -2,7 +2,7 @@ import { Button } from '@openfun/cunningham-react';
import { Trans, useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Footer } from '@/features/footer';
import { LeftPanel } from '@/features/left-panel';
@@ -33,10 +33,10 @@ export function HomeContent() {
const isFrLanguage = i18n.resolvedLanguage === 'fr';
return (
<Box as="main" className="--docs--home-content">
<Box as="main">
<HomeHeader />
{isSmallMobile && (
<Box $css="& .--docs--left-panel-header{display: none;}">
<Box $css="& .panel-header{display: none;}">
<LeftPanel />
</Box>
)}
@@ -155,7 +155,11 @@ export function HomeContent() {
$margin={{ top: 'small' }}
>
<Button
icon={<Icon iconName="chat" $color="white" />}
icon={
<Text $isMaterialIcon $color="white">
chat
</Text>
}
href="https://matrix.to/#/#docs-official:matrix.org"
target="_blank"
>

View File

@@ -31,7 +31,6 @@ export const HomeHeader = () => {
$width="100%"
$padding={{ horizontal: 'small' }}
$height={`${isSmallMobile ? HEADER_HEIGHT_MOBILE : HEADER_HEIGHT}px`}
className="--docs--home-header"
>
<Box
$align="center"

View File

@@ -93,7 +93,6 @@ export const HomeSection = ({
opacity: ${isVisible ? 1 : 0};
${$css}
`}
className="--docs--home-section"
>
<Box
$direction={direction}

View File

@@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, Icon, Text } from '@/components/';
import { DropdownMenu, Text } from '@/components/';
import { useConfig } from '@/core';
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
@@ -70,9 +70,10 @@ export const LanguagePicker = () => {
aria-label={t('Language')}
$direction="row"
$gap="0.5rem"
className="--docs--language-picker-text"
>
<Icon iconName="translate" $color="inherit" $size="xl" />
<Text $isMaterialIcon $color="inherit" $size="xl">
translate
</Text>
{currentLanguageLabel}
</Text>
</DropdownMenu>

View File

@@ -44,7 +44,7 @@ export const LeftPanelTargetFilters = () => {
const onSelectQuery = (query: DocDefaultFilter) => {
const params = new URLSearchParams(searchParams);
params.set('target', query);
router.push(`${pathname}?${params.toString()}`);
router.replace(`${pathname}?${params.toString()}`);
togglePanel();
};
@@ -53,7 +53,6 @@ export const LeftPanelTargetFilters = () => {
$justify="center"
$padding={{ horizontal: 'sm' }}
$gap={spacing['2xs']}
className="--docs--left-panel-target-filters"
>
{defaultQueries.map((query) => {
const isActive = target === query.targetQuery;

View File

@@ -1,5 +1,5 @@
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { createGlobalStyle, css } from 'styled-components';
import { Box, SeparatedSection } from '@/components';
@@ -30,9 +30,13 @@ export const LeftPanel = () => {
const colors = theme.colorsTokens();
const spacings = theme.spacingsTokens();
useEffect(() => {
const toggle = useCallback(() => {
togglePanel(false);
}, [pathname, togglePanel]);
}, [togglePanel]);
useEffect(() => {
toggle();
}, [pathname, toggle]);
return (
<>
@@ -45,8 +49,7 @@ export const LeftPanel = () => {
min-width: 300px;
overflow: hidden;
border-right: 1px solid ${colors['greyscale-200']};
`}
className="--docs--left-panel-desktop"
`}
>
<Box
$css={css`
@@ -73,7 +76,6 @@ export const LeftPanel = () => {
transform: translateX(${isPanelOpen ? '0' : '-100dvw'});
background-color: var(--c--theme--colors--greyscale-000);
`}
className="--docs--left-panel-mobile"
>
<Box
data-testid="left-panel-mobile"

View File

@@ -21,7 +21,6 @@ export const LeftPanelContent = () => {
$css={css`
flex: 0 0 auto;
`}
className="--docs--home-left-panel-content"
>
<SeparatedSection showSeparator={false}>
<LeftPanelTargetFilters />

Some files were not shown because too many files have changed in this diff Show More