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
108 changed files with 2981 additions and 3774 deletions
+78
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.
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && AUTO_INSTALL_EXTRA_DEPS=true yarn install --frozen-lockfile
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
+4 -2
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
-11
View File
@@ -8,15 +8,6 @@ and this project adheres to
## [Unreleased]
## Added
- 🚩(backend) add feature flag for the footer #841
- 🔧(backend) add view to manage footer json #841
## Changed
- 🚨(frontend) block button when creating doc #749
## Fixed
- 🐛(back) validate document content in serializer #822
@@ -26,7 +17,6 @@ and this project adheres to
## Added
- 📄(legal) Require contributors to sign a DCO #779
- ✨(frontend) add custom css style #771
## Changed
@@ -35,7 +25,6 @@ and this project adheres to
## Fixed
- 🐛(frontend) conditionally render AI button only when feature is enabled #814
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801
-1
View File
@@ -158,7 +158,6 @@ services:
Y_PROVIDER_URL: "ws://localhost:4444"
MEDIA_URL: "http://localhost:8083"
SW_DEACTIVATED: "true"
AUTO_INSTALL_EXTRA_DEPS: "true"
image: impress:frontend-development
ports:
- "3000:3000"
+20 -20
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.
-33
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.
-3
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
-25
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)
@@ -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 {}
-8
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."""
@@ -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."""
@@ -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."""
@@ -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,
}
-81
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
@@ -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):
@@ -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):
-1
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()),
]
-19
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)
+9 -8
View File
@@ -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]
+1 -4
View File
@@ -1,16 +1,13 @@
FROM node:20-alpine AS frontend-deps
ARG AUTO_INSTALL_EXTRA_DEPS=false
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/bin/conditional-install.js ./bin/conditional-install.js
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/impress/package.json ./apps/impress/package.json
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
RUN AUTO_INSTALL_EXTRA_DEPS=${AUTO_INSTALL_EXTRA_DEPS} yarn install --frozen-lockfile
RUN yarn install --frozen-lockfile
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/.prettierrc.js ./.prettierrc.js
@@ -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').dblclick();
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', () => {
@@ -58,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(
@@ -99,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()
@@ -338,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: [
@@ -365,17 +364,11 @@ 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');
@@ -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(
-1
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'],
+3 -5
View File
@@ -19,6 +19,8 @@
"@blocknote/core": "0.23.2-hotfix.0",
"@blocknote/mantine": "0.23.2-hotfix.0",
"@blocknote/react": "0.23.2-hotfix.0",
"@blocknote/xl-docx-exporter": "0.23.2-hotfix.0",
"@blocknote/xl-pdf-exporter": "0.23.2-hotfix.0",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.2",
"@gouvfr-lasuite/ui-kit": "0.1.3",
@@ -36,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",
@@ -77,9 +79,5 @@
"typescript": "*",
"webpack": "5.98.0",
"workbox-webpack-plugin": "7.1.0"
},
"extraDependencies": {
"@blocknote/xl-docx-exporter": "0.23.2-hotfix.0",
"@blocknote/xl-pdf-exporter": "0.23.2-hotfix.0"
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
@@ -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;
@@ -14,7 +14,6 @@ export const Card = ({
return (
<Box
className={`--docs--card ${props.className || ''}`}
$background="white"
$radius="4px"
$css={css`
@@ -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>
@@ -36,7 +36,6 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
$padding="4px"
$margin="auto"
{...textProps}
className={`--docs--icon-bg ${textProps.className || ''}`}
>
{iconName}
</Text>
@@ -30,10 +30,7 @@ export const InfiniteScroll = ({
};
return (
<Box
{...boxProps}
className={`--docs--infinite-scroll ${boxProps.className || ''}`}
>
<Box {...boxProps}>
{children}
<InView onChange={loadMore}>
{!isLoading && hasMore && (
@@ -20,7 +20,6 @@ export const LoadMoreText = ({
$align="center"
$gap="0.4rem"
$padding={{ horizontal: '2xs', vertical: 'sm' }}
className="--docs--load-more"
>
<Icon
$theme="primary"
@@ -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) => (
@@ -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;
}
}
`;
@@ -28,7 +28,6 @@ export const HorizontalSeparator = ({
? '#e5e5e533'
: colorsTokens()['greyscale-100']
}
className="--docs--horizontal-separator"
/>
);
};
@@ -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>
);
};
@@ -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> => {
@@ -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>
@@ -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>
);
@@ -104,7 +104,7 @@ 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')}
@@ -116,7 +116,7 @@ export function AIGroupButton() {
/>
</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 && (
@@ -193,7 +193,7 @@ 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">
@@ -206,7 +206,7 @@ export function AIGroupButton() {
</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
@@ -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 (
<>
@@ -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']
@@ -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>
@@ -59,10 +59,7 @@ export const ModalConfirmDownloadUnsafe = ({
</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>
@@ -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={
@@ -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),
);
});
});
@@ -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,34 +29,56 @@ 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;
}
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
setInitialDoc(newDoc);
updateDoc({
id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
content: newDoc,
});
}, [canSave, yDoc, docId, isLocalChange, updateDoc]);
}, [doc, docId, updateDoc]);
const timeout = useRef<NodeJS.Timeout | null>(null);
const router = useRouter();
useEffect(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
const onSave = (e?: Event) => {
if (!shouldSave()) {
return;
}
saveDoc();
/**
@@ -71,19 +94,21 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
};
// 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;
@@ -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: [] })),
}));
@@ -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.')}
@@ -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
@@ -107,7 +107,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
<Box
as="span"
role="textbox"
className="--docs--doc-title-input"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
@@ -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"
@@ -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 />
@@ -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 ?')}
@@ -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%">
@@ -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')}
@@ -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"
@@ -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}
@@ -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}
@@ -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}
@@ -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
@@ -20,7 +20,6 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
$css={css`
flex-shrink: 0;
`}
className="--docs--doc-share-modal-footer"
>
<HorizontalSeparator $withPadding={true} />
@@ -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={
@@ -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')}
@@ -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}
@@ -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"
@@ -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%"
@@ -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">
@@ -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.')}
@@ -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)"
@@ -44,7 +44,6 @@ export const VersionItem = ({
`}
$hasTransition
$minWidth="13rem"
className="--docs--version-item"
>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
@@ -109,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}
@@ -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
@@ -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`
@@ -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' }}
@@ -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>
@@ -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"
@@ -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
@@ -21,7 +21,6 @@ export const ButtonTogglePanel = () => {
iconName={isPanelOpen ? 'close' : 'menu'}
/>
}
className="--docs--button-toggle-panel"
/>
);
};
@@ -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="/">
@@ -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"
@@ -29,7 +29,6 @@ export default function HomeBanner() {
$height="100vh"
$margin={{ top: `-${getHeaderHeight(isSmallMobile)}px` }}
$position="relative"
className="--docs--home-banner"
>
<Box
$width="100%"
@@ -30,7 +30,6 @@ function HomeProConnect() {
<Box
$justify="center"
$height={!isMobile ? `calc(100vh - ${parentGap})` : 'auto'}
className="--docs--home-proconnect"
>
<Box
$gap={spacings['md']}
@@ -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>
)}
@@ -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"
@@ -93,7 +93,6 @@ export const HomeSection = ({
opacity: ${isVisible ? 1 : 0};
${$css}
`}
className="--docs--home-section"
>
<Box
$direction={direction}
@@ -70,7 +70,6 @@ export const LanguagePicker = () => {
aria-label={t('Language')}
$direction="row"
$gap="0.5rem"
className="--docs--language-picker-text"
>
<Text $isMaterialIcon $color="inherit" $size="xl">
translate
@@ -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;
@@ -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"
@@ -21,7 +21,6 @@ export const LeftPanelContent = () => {
$css={css`
flex: 0 0 auto;
`}
className="--docs--home-left-panel-content"
>
<SeparatedSection showSeparator={false}>
<LeftPanelTargetFilters />
@@ -18,7 +18,6 @@ export const LeftPanelDocContent = () => {
$flex={1}
$width="100%"
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
className="--docs--left-panel-doc-content"
>
<SeparatedSection showSeparator={false}>
<Box $padding={{ horizontal: 'sm' }}>
@@ -38,7 +38,6 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => {
}
`}
key={doc.id}
className="--docs--left-panel-favorite-item"
>
<StyledLink href={`/docs/${doc.id}`} $css="overflow: auto;">
<SimpleDocItem showAccesses doc={doc} />
@@ -24,7 +24,7 @@ export const LeftPanelFavorites = () => {
}
return (
<Box className="--docs--left-panel-favorites">
<Box>
<HorizontalSeparator $withPadding={false} />
<Box
$justify="center"
@@ -1,7 +1,7 @@
import { Button } from '@openfun/cunningham-react';
import { Button, ModalSize, useModal } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import { PropsWithChildren, useCallback, useState } from 'react';
import { PropsWithChildren } from 'react';
import { Box, Icon, SeparatedSection } from '@/components';
import { useCreateDoc } from '@/docs/doc-management';
@@ -13,27 +13,20 @@ import { useLeftPanelStore } from '../stores';
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const router = useRouter();
const searchModal = useModal();
const { authenticated } = useAuth();
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const openSearchModal = useCallback(() => {
useCmdK(() => {
const isEditorToolbarOpen =
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
if (isEditorToolbarOpen) {
return;
}
setIsSearchModalOpen(true);
}, []);
const closeSearchModal = useCallback(() => {
setIsSearchModalOpen(false);
}, []);
useCmdK(openSearchModal);
searchModal.open();
});
const { togglePanel } = useLeftPanelStore();
const { mutate: createDoc, isPending: isCreatingDoc } = useCreateDoc({
const { mutate: createDoc } = useCreateDoc({
onSuccess: (doc) => {
router.push(`/docs/${doc.id}`);
togglePanel();
@@ -51,7 +44,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
return (
<>
<Box $width="100%" className="--docs--left-panel-header">
<Box $width="100%" className="panel-header">
<SeparatedSection>
<Box
$padding={{ horizontal: 'sm' }}
@@ -71,7 +64,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
/>
{authenticated && (
<Button
onClick={openSearchModal}
onClick={searchModal.open}
size="medium"
color="tertiary-text"
icon={
@@ -81,16 +74,14 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
)}
</Box>
{authenticated && (
<Button onClick={createNewDoc} disabled={isCreatingDoc}>
{t('New doc')}
</Button>
<Button onClick={createNewDoc}>{t('New doc')}</Button>
)}
</Box>
</SeparatedSection>
{children}
</Box>
{isSearchModalOpen && (
<DocSearchModal onClose={closeSearchModal} isOpen={isSearchModalOpen} />
{searchModal.isOpen && (
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
)}
</>
);
@@ -23,7 +23,7 @@ export function MainLayout({
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
return (
<Box className="--docs--main-layout">
<div>
<Header />
<Box
$direction="row"
@@ -54,6 +54,6 @@ export function MainLayout({
{children}
</Box>
</Box>
</Box>
</div>
);
}
@@ -20,7 +20,6 @@ export function PageLayout({
<Box
$minHeight={`calc(100vh - ${HEADER_HEIGHT}px)`}
$margin={{ top: `${HEADER_HEIGHT}px` }}
className="--docs--page-layout"
>
<Header />
<Box as="main" $width="100%" $css="flex-grow:1;">
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const readline = require('readline');
// Check if AUTO_INSTALL_EXTRA_DEPS is explicitly set to false
if (process.env.AUTO_INSTALL_EXTRA_DEPS === 'false') {
console.log('AUTO_INSTALL_EXTRA_DEPS is set to false, skipping script execution');
process.exit(0);
}
// Get the workspace root directory
const workspaceRoot = process.cwd();
// Check if AUTO_INSTALL_EXTRA_DEPS environment variable is set to bypass prompts
const autoMode = process.env.AUTO_INSTALL_EXTRA_DEPS === 'true';
// Create readline interface for user prompts
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// Function to prompt user for confirmation
function promptUser(question) {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.toLowerCase().startsWith('y'));
});
});
}
// Function to find all package.json files in the apps directory
function findPackageJsonFiles(directory) {
const appsDir = path.join(workspaceRoot, directory);
// Check if the directory exists
if (!fs.existsSync(appsDir)) {
console.log(`Directory ${directory} does not exist, skipping...`);
return [];
}
const packageJsonFiles = [];
// Read all items in the directory
const items = fs.readdirSync(appsDir);
for (const item of items) {
const itemPath = path.join(appsDir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
// Check if this directory has a package.json
const packageJsonPath = path.join(itemPath, 'package.json');
if (fs.existsSync(packageJsonPath)) {
packageJsonFiles.push(packageJsonPath);
// Skip searching in subdirectories once a package.json is found
continue;
}
// Only search subdirectories if no package.json was found in this directory
packageJsonFiles.push(...findPackageJsonFiles(path.join(directory, item)));
}
}
return packageJsonFiles;
}
// Find all package.json files in the apps directory
const packageJsonFiles = findPackageJsonFiles('apps');
if (packageJsonFiles.length === 0) {
console.log('No package.json files found in the apps directory');
process.exit(0);
}
console.log(`Found ${packageJsonFiles.length} package.json files in the apps directory`);
// Process each package.json file
async function processPackageJsonFiles() {
for (const packageJsonPath of packageJsonFiles) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Check if extraDependencies exists
if (packageJson.extraDependencies && Object.keys(packageJson.extraDependencies).length > 0) {
console.log(`Found extraDependencies in ${packageJsonPath}, installing...`);
// Process each dependency individually
for (const [pkg, version] of Object.entries(packageJson.extraDependencies)) {
const dependencyToInstall = `${pkg}@${version}`;
// Prompt user if not in auto mode
let shouldInstall = autoMode;
if (!autoMode) {
shouldInstall = await promptUser(`
Do you want to install ${dependencyToInstall}? (y/n):
Note that these packages are dual-licensed by Blocknotejs
under AGPL-3.0 or a proprietary license. If you choose
to install them, please ensure you fulfill your licensing
obligations with respect to BlockNoteJS
`);
}
if (shouldInstall) {
// Install the dependency using npm install
try {
console.log(`Installing: ${dependencyToInstall}`);
execSync(`npm install --no-save --ignore-scripts --no-audit ${dependencyToInstall}`, { stdio: 'inherit' });
console.log(`Extra dependency ${dependencyToInstall} installed successfully`);
} catch (error) {
console.error(`Failed to install extra dependency ${dependencyToInstall}:`, error.message);
// Continue with other dependencies even if one fails
}
} else {
console.log(`Skipping installation of ${dependencyToInstall}`);
}
}
} else {
console.log(`No extraDependencies found in ${packageJsonPath}, skipping installation`);
}
} catch (error) {
console.error(`Error reading or parsing ${packageJsonPath}:`, error.message);
// Continue with other package.json files even if one fails
}
}
console.log('Finished processing all package.json files');
rl.close();
}
// Run the async function
processPackageJsonFiles().catch(error => {
console.error('An error occurred:', error);
rl.close();
process.exit(1);
});
+1 -2
View File
@@ -25,8 +25,7 @@
"i18n:deploy": "yarn I18N run format-deploy && yarn APP_IMPRESS prettier",
"i18n:test": "yarn I18N run test",
"test": "yarn server:test && yarn app:test",
"server:test": "yarn COLLABORATION_SERVER run test",
"postinstall": "node ./bin/conditional-install.js"
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@types/node": "22.13.9",

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