mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-13 10:27:07 +02:00
Compare commits
28 Commits
fix/782-ai
...
v3.1.0-pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf1cf6862 | ||
|
|
de8e812f2f | ||
|
|
7a1601c682 | ||
|
|
0537572542 | ||
|
|
8aab007ad1 | ||
|
|
cde3de43f7 | ||
|
|
8c0c3c2f44 | ||
|
|
c11d59c434 | ||
|
|
8836109945 | ||
|
|
ba136ff82f | ||
|
|
96d9d1a184 | ||
|
|
771ffdc7cc | ||
|
|
82eba1e8ea | ||
|
|
8c42599d0f | ||
|
|
8620cf4857 | ||
|
|
2a7da73248 | ||
|
|
e8e9922832 | ||
|
|
2da4ce4570 | ||
|
|
50b90f9ae7 | ||
|
|
65ddf7fbe8 | ||
|
|
d3a7ee74b3 | ||
|
|
65e450c6cc | ||
|
|
725cae5470 | ||
|
|
3881930e82 | ||
|
|
910686293c | ||
|
|
7e7c9ac4c5 | ||
|
|
d5d2cfab8e | ||
|
|
f2ed8e0ea1 |
6
.github/workflows/docker-hub.yml
vendored
6
.github/workflows/docker-hub.yml
vendored
@@ -11,6 +11,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'ci/trivy-fails'
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
@@ -38,7 +39,6 @@ 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,7 +72,6 @@ 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
|
||||
@@ -106,8 +105,7 @@ 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-frontend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -8,9 +8,23 @@ 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
|
||||
|
||||
@@ -25,7 +39,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
|
||||
|
||||
@@ -511,7 +524,8 @@ 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.0.0...main
|
||||
[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
|
||||
[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
|
||||
|
||||
@@ -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's work. It needs to be adapt for 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 works. It needs to be adapted for a production environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
### 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 will need to install:
|
||||
To be able to use the script, you 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 will you use to authenticate your users ?
|
||||
### What do 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 informations you will need are :
|
||||
From here the important information 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
|
||||
|
||||
Impress need a redis so we will start by deploying a redis :
|
||||
Docs needs a redis so we start by deploying one:
|
||||
|
||||
```
|
||||
$ 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
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```
|
||||
$ 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 important informations you will need are :
|
||||
From here the important information 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
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```
|
||||
$ 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 Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
|
||||
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.
|
||||
|
||||
```
|
||||
$ 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 login to your instance. If you use exclusively our examples you can do :
|
||||
In order to test your deployment you have to log into your instance. If you exclusively use 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 impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||
|
||||
33
docs/theming.md
Normal file
33
docs/theming.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -64,3 +64,5 @@ 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
|
||||
|
||||
@@ -16,8 +16,10 @@ 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
|
||||
@@ -30,6 +32,7 @@ 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
|
||||
@@ -1688,6 +1691,8 @@ class ConfigView(drf.views.APIView):
|
||||
"COLLABORATION_WS_URL",
|
||||
"CRISP_WEBSITE_ID",
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_FOOTER_FEATURE_ENABLED",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
@@ -1701,3 +1706,22 @@ 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)
|
||||
|
||||
25
src/backend/core/services/config_services.py
Normal file
25
src/backend/core/services/config_services.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""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 {}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
USER = "user"
|
||||
@@ -9,6 +11,12 @@ 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,7 +5,6 @@ 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
|
||||
@@ -17,12 +16,6 @@ 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,7 +5,6 @@ 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
|
||||
@@ -17,12 +16,6 @@ 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,6 +18,8 @@ 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"},
|
||||
@@ -38,6 +40,8 @@ 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"],
|
||||
|
||||
81
src/backend/core/tests/test_api_footer.py
Normal file
81
src/backend/core/tests/test_api_footer.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""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,10 +4,8 @@ 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
|
||||
@@ -25,12 +23,6 @@ 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,7 +5,6 @@ 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
|
||||
@@ -29,12 +28,6 @@ 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):
|
||||
|
||||
@@ -56,4 +56,5 @@ urlpatterns = [
|
||||
),
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
path(f"api/{settings.API_VERSION}/footer/", viewsets.FooterView.as_view()),
|
||||
]
|
||||
|
||||
@@ -410,6 +410,22 @@ 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(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "3.0.0"
|
||||
version = "3.1.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.12.3",
|
||||
"boto3==1.37.18",
|
||||
"beautifulsoup4==4.13.3",
|
||||
"boto3==1.37.24",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"celery[redis]==5.5.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.7",
|
||||
"django==5.1.8",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"djangorestframework==3.16.0",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
@@ -51,14 +51,13 @@ dependencies = [
|
||||
"markdown==3.7",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.68.2",
|
||||
"openai==1.70.0",
|
||||
"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.24.0",
|
||||
"url-normalize==1.4.3",
|
||||
"sentry-sdk==2.25.0",
|
||||
"whitenoise==6.9.0",
|
||||
]
|
||||
|
||||
@@ -86,7 +85,7 @@ dev = [
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.11.2",
|
||||
"types-requests==2.32.0.20250306",
|
||||
"types-requests==2.32.0.20250328",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export const CONFIG = {
|
||||
AI_FEATURE_ENABLED: false,
|
||||
CRISP_WEBSITE_ID: null,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_THEME: 'default',
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'Français'],
|
||||
['de-de', 'Deutsch'],
|
||||
['nl-nl', 'Nederlands'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
POSTHOG_KEY: {},
|
||||
SENTRY_DSN: null,
|
||||
};
|
||||
|
||||
export const keyCloakSignIn = async (
|
||||
page: Page,
|
||||
browserName: string,
|
||||
|
||||
@@ -2,7 +2,27 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { CONFIG, createDoc, verifyDocName } from './common';
|
||||
import { createDoc } 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: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'Français'],
|
||||
['de-de', 'Deutsch'],
|
||||
['nl-nl', 'Nederlands'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
POSTHOG_KEY: {},
|
||||
SENTRY_DSN: null,
|
||||
};
|
||||
|
||||
test.describe('Config', () => {
|
||||
test('it checks the config api is called', async ({ page }) => {
|
||||
@@ -16,7 +36,7 @@ test.describe('Config', () => {
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
expect(await response.json()).toStrictEqual(CONFIG);
|
||||
expect(await response.json()).toStrictEqual(config);
|
||||
});
|
||||
|
||||
test('it checks that sentry is trying to init from config endpoint', async ({
|
||||
@@ -27,7 +47,7 @@ test.describe('Config', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...CONFIG,
|
||||
...config,
|
||||
SENTRY_DSN: 'https://sentry.io/123',
|
||||
},
|
||||
});
|
||||
@@ -81,22 +101,13 @@ test.describe('Config', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket.url().includes('ws://localhost:4444/collaboration/ws/');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const randomDoc = await createDoc(
|
||||
page,
|
||||
'doc-collaboration',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
void createDoc(page, 'doc-collaboration', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
const webSocket = await webSocketPromise;
|
||||
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket.url().includes('ws://localhost:4444/collaboration/ws/');
|
||||
});
|
||||
expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/');
|
||||
});
|
||||
|
||||
@@ -104,12 +115,26 @@ test.describe('Config', () => {
|
||||
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();
|
||||
await page.getByText('Anything').selectText();
|
||||
expect(
|
||||
await page.locator('button[data-test="convertMarkdown"]').count(),
|
||||
).toBe(1);
|
||||
@@ -126,7 +151,7 @@ test.describe('Config', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...CONFIG,
|
||||
...config,
|
||||
CRISP_WEBSITE_ID: '1234',
|
||||
},
|
||||
});
|
||||
@@ -141,6 +166,30 @@ 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', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
|
||||
import {
|
||||
CONFIG,
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
@@ -26,7 +25,11 @@ test.describe('Doc Editor', () => {
|
||||
await editor.click();
|
||||
await editor.fill('test content');
|
||||
|
||||
await editor.getByText('test content').dblclick();
|
||||
await editor
|
||||
.getByText('test content', {
|
||||
exact: true,
|
||||
})
|
||||
.selectText();
|
||||
|
||||
const toolbar = page.locator('.bn-formatting-toolbar');
|
||||
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
|
||||
@@ -59,18 +62,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,
|
||||
browserName,
|
||||
}) => {
|
||||
test('checks the connection with collaborative server', async ({ page }) => {
|
||||
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:4444/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
let webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain(
|
||||
@@ -100,7 +103,7 @@ test.describe('Doc Editor', () => {
|
||||
const wsClose = await wsClosePromise;
|
||||
expect(wsClose.isClosed()).toBeTruthy();
|
||||
|
||||
// Checkt the ws is connected again
|
||||
// Check the ws is connected again
|
||||
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
@@ -127,7 +130,7 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor.getByText('[test markdown]')).toBeVisible();
|
||||
|
||||
await editor.getByText('[test markdown]').dblclick();
|
||||
await editor.getByText('[test markdown]').selectText();
|
||||
await page.locator('button[data-test="convertMarkdown"]').click();
|
||||
|
||||
await expect(editor.getByText('[test markdown]')).toBeHidden();
|
||||
@@ -220,11 +223,8 @@ test.describe('Doc Editor', () => {
|
||||
await editor.fill('Hello World Doc persisted 2');
|
||||
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: doc,
|
||||
});
|
||||
const urlDoc = page.url();
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
});
|
||||
@@ -280,22 +280,6 @@ test.describe('Doc Editor', () => {
|
||||
});
|
||||
|
||||
test('it checks the AI buttons', 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: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await page.route(/.*\/ai-translate\//, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('POST')) {
|
||||
@@ -314,7 +298,7 @@ test.describe('Doc Editor', () => {
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
await editor.getByText('Hello').selectText();
|
||||
|
||||
await page.getByRole('button', { name: 'AI' }).click();
|
||||
|
||||
@@ -357,22 +341,6 @@ test.describe('Doc Editor', () => {
|
||||
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: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await mockedDocument(page, {
|
||||
accesses: [
|
||||
{
|
||||
@@ -413,7 +381,7 @@ test.describe('Doc Editor', () => {
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
await editor.getByText('Hello').selectText();
|
||||
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
|
||||
@@ -190,7 +190,7 @@ test.describe('Document grid item options', () => {
|
||||
|
||||
test.describe('Documents filters', () => {
|
||||
test('it checks the prebuild left panel filters', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
void 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 }) => {
|
||||
await page.goto('/');
|
||||
void page.goto('/');
|
||||
|
||||
let docs: SmallDoc[] = [];
|
||||
const response = await page.waitForResponse(
|
||||
|
||||
@@ -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').dblclick();
|
||||
await editor.getByText('Hello world').selectText();
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(page.getByRole('textbox', { name: 'Edit URL' })).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
|
||||
const config: Config = {
|
||||
coverageProvider: 'v8',
|
||||
moduleNameMapper: {
|
||||
'^@/docs/(.*)$': '<rootDir>/src/features/docs/$1',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -30,6 +30,7 @@
|
||||
"@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",
|
||||
@@ -38,7 +39,7 @@
|
||||
"idb": "8.0.2",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "15.2.3",
|
||||
"next": "15.2.4",
|
||||
"posthog-js": "1.227.0",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.6.0",
|
||||
|
||||
121
src/frontend/apps/impress/public/contents/footer-demo.json
Normal file
121
src/frontend/apps/impress/public/contents/footer-demo.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
src/frontend/apps/impress/public/contents/footer-dsfr.json
Normal file
135
src/frontend/apps/impress/public/contents/footer-dsfr.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/frontend/apps/impress/public/favicon-dark.png
Normal file
BIN
src/frontend/apps/impress/public/favicon-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/frontend/apps/impress/public/favicon.png
Normal file
BIN
src/frontend/apps/impress/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 992 B |
@@ -24,6 +24,7 @@ export interface BoxProps {
|
||||
$hasTransition?: boolean | 'slow';
|
||||
$height?: CSSProperties['height'];
|
||||
$justify?: CSSProperties['justifyContent'];
|
||||
$opacity?: CSSProperties['opacity'];
|
||||
$overflow?: CSSProperties['overflow'];
|
||||
$margin?: MarginPadding;
|
||||
$maxHeight?: CSSProperties['maxHeight'];
|
||||
@@ -65,6 +66,7 @@ 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};`}
|
||||
|
||||
@@ -44,6 +44,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
||||
${$css || ''}
|
||||
`}
|
||||
{...props}
|
||||
className={`--docs--box-button ${props.className || ''}`}
|
||||
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
|
||||
@@ -14,6 +14,7 @@ export const Card = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={`--docs--card ${props.className || ''}`}
|
||||
$background="white"
|
||||
$radius="4px"
|
||||
$css={css`
|
||||
|
||||
@@ -71,6 +71,7 @@ export const DropButton = ({
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
aria-label={label}
|
||||
$css={buttonCss}
|
||||
className="--docs--drop-button"
|
||||
>
|
||||
{button}
|
||||
</StyledButton>
|
||||
@@ -79,6 +80,7 @@ export const DropButton = ({
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
className="--docs--drop-button-popover"
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
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, ...textProps }: IconProps) => {
|
||||
return (
|
||||
<Text $isMaterialIcon {...textProps}>
|
||||
{iconName}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface IconBGProps extends TextType {
|
||||
iconName: string;
|
||||
}
|
||||
|
||||
export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
export const Icon = ({
|
||||
iconName,
|
||||
variant = 'outlined',
|
||||
...textProps
|
||||
}: IconProps) => {
|
||||
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>
|
||||
@@ -48,15 +31,13 @@ type IconOptionsProps = TextType & {
|
||||
|
||||
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
|
||||
return (
|
||||
<Text
|
||||
<Icon
|
||||
{...props}
|
||||
$isMaterialIcon
|
||||
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
|
||||
$css={css`
|
||||
user-select: none;
|
||||
${props.$css}
|
||||
`}
|
||||
>
|
||||
{isHorizontal ? 'more_horiz' : 'more_vert'}
|
||||
</Text>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,10 @@ export const InfiniteScroll = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
<Box
|
||||
{...boxProps}
|
||||
className={`--docs--infinite-scroll ${boxProps.className || ''}`}
|
||||
>
|
||||
{children}
|
||||
<InView onChange={loadMore}>
|
||||
{!isLoading && hasMore && (
|
||||
|
||||
@@ -20,6 +20,7 @@ export const LoadMoreText = ({
|
||||
$align="center"
|
||||
$gap="0.4rem"
|
||||
$padding={{ horizontal: '2xs', vertical: 'sm' }}
|
||||
className="--docs--load-more"
|
||||
>
|
||||
<Icon
|
||||
$theme="primary"
|
||||
|
||||
@@ -11,7 +11,6 @@ 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 & {});
|
||||
@@ -57,14 +56,14 @@ export const TextStyled = styled(Box)<TextProps>`
|
||||
`;
|
||||
|
||||
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
|
||||
({ className, $isMaterialIcon, ...props }, ref) => {
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TextStyled
|
||||
ref={ref}
|
||||
as="span"
|
||||
$theme="greyscale"
|
||||
$variation="text"
|
||||
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,12 @@ export const TextErrors = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AlertStyled canClose={canClose} type={VariantType.ERROR} icon={icon}>
|
||||
<AlertStyled
|
||||
canClose={canClose}
|
||||
type={VariantType.ERROR}
|
||||
icon={icon}
|
||||
className="--docs--text-errors"
|
||||
>
|
||||
<Box $direction="column" $gap="0.2rem">
|
||||
{causes &&
|
||||
causes.map((cause, i) => (
|
||||
|
||||
@@ -119,8 +119,6 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.c__modal__scroller:has(.quick-search-container),
|
||||
.c__modal__scroller:has(.noPadding) {
|
||||
padding: 0 !important;
|
||||
@@ -138,6 +136,4 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
`;
|
||||
|
||||
@@ -28,6 +28,7 @@ export const HorizontalSeparator = ({
|
||||
? '#e5e5e533'
|
||||
: colorsTokens()['greyscale-100']
|
||||
}
|
||||
className="--docs--horizontal-separator"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import { PropsWithChildren, useEffect } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
@@ -54,10 +55,17 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalyticsProvider>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
</CrispProvider>
|
||||
</AnalyticsProvider>
|
||||
<>
|
||||
{conf?.FRONTEND_CSS_URL && (
|
||||
<Head>
|
||||
<link rel="stylesheet" href={conf?.FRONTEND_CSS_URL} />
|
||||
</Head>
|
||||
)}
|
||||
<AnalyticsProvider>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
</CrispProvider>
|
||||
</AnalyticsProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
@import url('@gouvfr-lasuite/ui-kit/style');
|
||||
@import url('./cunningham-tokens.css');
|
||||
|
||||
:root {
|
||||
/**
|
||||
* Input
|
||||
@@ -33,3 +36,10 @@
|
||||
--c--components--button--border-radius
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip
|
||||
*/
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const ButtonLogin = () => {
|
||||
onClick={() => gotoLogin()}
|
||||
color="primary-text"
|
||||
aria-label={t('Login')}
|
||||
className="--docs--button-login"
|
||||
>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
@@ -25,7 +26,12 @@ export const ButtonLogin = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={gotoLogout} color="primary-text" aria-label={t('Logout')}>
|
||||
<Button
|
||||
onClick={gotoLogout}
|
||||
color="primary-text"
|
||||
aria-label={t('Logout')}
|
||||
className="--docs--button-logout"
|
||||
>
|
||||
{t('Logout')}
|
||||
</Button>
|
||||
);
|
||||
@@ -45,6 +51,7 @@ export const ProConnectButton = () => {
|
||||
}
|
||||
`}
|
||||
$radius="4px"
|
||||
className="--docs--proconnect-button"
|
||||
>
|
||||
<ProConnectImg />
|
||||
</BoxButton>
|
||||
|
||||
@@ -133,6 +133,7 @@ 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' }}>
|
||||
@@ -192,7 +193,7 @@ export const BlockNoteEditorVersion = ({
|
||||
}, [setEditor, editor]);
|
||||
|
||||
return (
|
||||
<Box $css={cssEditor(readOnly)}>
|
||||
<Box $css={cssEditor(readOnly)} className="--docs--editor-container">
|
||||
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isAPIError } from '@/api';
|
||||
import { Box, Text } from '@/components';
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useDocOptions, useDocStore } from '@/docs/doc-management/';
|
||||
|
||||
import {
|
||||
@@ -104,19 +104,15 @@ export function AIGroupButton() {
|
||||
<Components.Generic.Menu.Root>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button bn-menu-item"
|
||||
className="bn-button bn-menu-item --docs--ai-actions-menu-trigger"
|
||||
data-test="ai-actions"
|
||||
label="AI"
|
||||
mainTooltip={t('AI Actions')}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="l">
|
||||
auto_awesome
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="auto_awesome" $size="l" />}
|
||||
/>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
className="bn-menu-dropdown bn-drag-handle-menu"
|
||||
className="bn-menu-dropdown bn-drag-handle-menu --docs--ai-actions-menu"
|
||||
sub={true}
|
||||
>
|
||||
{canAITransform && (
|
||||
@@ -124,66 +120,42 @@ export function AIGroupButton() {
|
||||
<AIMenuItemTransform
|
||||
action="prompt"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
text_fields
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="text_fields" $size="s" />}
|
||||
>
|
||||
{t('Use as prompt')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="rephrase"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
refresh
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="refresh" $size="s" />}
|
||||
>
|
||||
{t('Rephrase')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="summarize"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
summarize
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="summarize" $size="s" />}
|
||||
>
|
||||
{t('Summarize')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="correct"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
check
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="check" $size="s" />}
|
||||
>
|
||||
{t('Correct')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="beautify"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
draw
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="draw" $size="s" />}
|
||||
>
|
||||
{t('Beautify')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="emojify"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
emoji_emotions
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="emoji_emotions" $size="s" />}
|
||||
>
|
||||
{t('Emojify')}
|
||||
</AIMenuItemTransform>
|
||||
@@ -193,20 +165,18 @@ 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"
|
||||
className="bn-menu-item --docs--ai-translate-menu-trigger"
|
||||
subTrigger={true}
|
||||
>
|
||||
<Box $direction="row" $gap="0.6rem">
|
||||
<Text $isMaterialIcon $size="s">
|
||||
translate
|
||||
</Text>
|
||||
<Icon iconName="translate" $size="s" />
|
||||
{t('Language')}
|
||||
</Box>
|
||||
</Components.Generic.Menu.Item>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
sub={true}
|
||||
className="bn-menu-dropdown"
|
||||
className="bn-menu-dropdown --docs--ai-translate-menu"
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<AIMenuItemTranslate
|
||||
|
||||
@@ -94,7 +94,7 @@ export const FileDownloadButton = ({
|
||||
return (
|
||||
<>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button"
|
||||
className="bn-button --docs--editor-file-download-button"
|
||||
label={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
|
||||
@@ -82,6 +82,7 @@ export function MarkdownButton() {
|
||||
<Components.FormattingToolbar.Button
|
||||
mainTooltip={t('Convert Markdown')}
|
||||
onClick={handleConvertMarkdown}
|
||||
className="--docs--editor-markdown-button"
|
||||
>
|
||||
M
|
||||
</Components.FormattingToolbar.Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
|
||||
interface ModalConfirmDownloadUnsafeProps {
|
||||
onClose: () => void;
|
||||
@@ -52,14 +52,15 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
$variation="1000"
|
||||
$direction="row"
|
||||
>
|
||||
<Text $isMaterialIcon $theme="warning">
|
||||
warning
|
||||
</Text>
|
||||
<Icon iconName="warning" $theme="warning" />
|
||||
{t('Warning')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box aria-label={t('Modal confirmation to download the attachment')}>
|
||||
<Box
|
||||
aria-label={t('Modal confirmation to download the attachment')}
|
||||
className="--docs--modal-confirm-download-unsafe"
|
||||
>
|
||||
<Box>
|
||||
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
|
||||
<Text $variation="700">{t('This file is flagged as unsafe.')}</Text>
|
||||
|
||||
@@ -49,8 +49,16 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
<TableContent />
|
||||
</Box>
|
||||
)}
|
||||
<Box $maxWidth="868px" $width="100%" $height="100%">
|
||||
<Box $padding={{ horizontal: isDesktop ? '54px' : 'base' }}>
|
||||
<Box
|
||||
$maxWidth="868px"
|
||||
$width="100%"
|
||||
$height="100%"
|
||||
className="--docs--doc-editor"
|
||||
>
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
|
||||
className="--docs--doc-editor-header"
|
||||
>
|
||||
{isVersion ? (
|
||||
<DocVersionHeader title={doc.title} />
|
||||
) : (
|
||||
@@ -64,6 +72,7 @@ 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 ? (
|
||||
@@ -115,7 +124,7 @@ export const DocVersionEditor = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box $margin="large">
|
||||
<Box $margin="large" className="--docs--doc-version-editor-error">
|
||||
<TextErrors
|
||||
causes={error.cause}
|
||||
icon={
|
||||
|
||||
@@ -2,7 +2,7 @@ import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
@@ -45,11 +45,7 @@ export const getDividerReactSlashMenuItems = (
|
||||
},
|
||||
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
|
||||
group,
|
||||
icon: (
|
||||
<Text $isMaterialIcon $size="18px">
|
||||
remove
|
||||
</Text>
|
||||
),
|
||||
icon: <Icon iconName="remove" $size="18px" />,
|
||||
subtext: t('Add a horizontal line'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
@@ -54,11 +53,7 @@ export const getQuoteReactSlashMenuItems = (
|
||||
},
|
||||
aliases: ['quote', 'blockquote', 'citation'],
|
||||
group,
|
||||
icon: (
|
||||
<Text $isMaterialIcon $size="18px">
|
||||
format_quote
|
||||
</Text>
|
||||
),
|
||||
icon: <Icon iconName="format_quote" $size="18px" />,
|
||||
subtext: t('Add a quote block'),
|
||||
},
|
||||
];
|
||||
@@ -68,10 +63,6 @@ export const getQuoteFormattingToolbarItems = (
|
||||
): BlockTypeSelectItem => ({
|
||||
name: t('Quote'),
|
||||
type: 'quote',
|
||||
icon: () => (
|
||||
<Text $isMaterialIcon $size="16px">
|
||||
format_quote
|
||||
</Text>
|
||||
),
|
||||
icon: () => <Icon iconName="format_quote" $size="16px" />,
|
||||
isSelected: (block) => block.type === 'quote',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||
@@ -8,17 +8,16 @@ import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
import { toBase64 } from '../utils';
|
||||
|
||||
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
const SAVE_INTERVAL = 60000;
|
||||
|
||||
const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
onSuccess: () => {
|
||||
setIsLocalChange(false);
|
||||
},
|
||||
});
|
||||
const [initialDoc, setInitialDoc] = useState<string>(
|
||||
toBase64(Y.encodeStateAsUpdate(doc)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInitialDoc(toBase64(Y.encodeStateAsUpdate(doc)));
|
||||
}, [doc]);
|
||||
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Update initial doc when doc is updated by other users,
|
||||
@@ -29,57 +28,37 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
const onUpdate = (
|
||||
_uintArray: Uint8Array,
|
||||
_pluginKey: string,
|
||||
updatedDoc: Y.Doc,
|
||||
_updatedDoc: Y.Doc,
|
||||
transaction: Y.Transaction,
|
||||
) => {
|
||||
if (!transaction.local) {
|
||||
setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc)));
|
||||
}
|
||||
setIsLocalChange(transaction.local ? true : false);
|
||||
};
|
||||
|
||||
doc.on('update', onUpdate);
|
||||
yDoc.on('update', onUpdate);
|
||||
|
||||
return () => {
|
||||
doc.off('update', onUpdate);
|
||||
yDoc.off('update', onUpdate);
|
||||
};
|
||||
}, [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]);
|
||||
}, [yDoc]);
|
||||
|
||||
const saveDoc = useCallback(() => {
|
||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||
setInitialDoc(newDoc);
|
||||
if (!canSave || !isLocalChange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: newDoc,
|
||||
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
|
||||
});
|
||||
}, [doc, docId, updateDoc]);
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
return true;
|
||||
}, [canSave, yDoc, docId, isLocalChange, updateDoc]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
const onSave = (e?: Event) => {
|
||||
if (!shouldSave()) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveDoc();
|
||||
const isSaving = saveDoc();
|
||||
|
||||
/**
|
||||
* Firefox does not trigger the request everytime the user leaves the page.
|
||||
@@ -88,27 +67,30 @@ const useSaveDoc = (docId: string, doc: 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 (typeof e !== 'undefined' && e.preventDefault && isFirefox()) {
|
||||
if (
|
||||
isSaving &&
|
||||
typeof e !== 'undefined' &&
|
||||
e.preventDefault &&
|
||||
isFirefox()
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Save every minute
|
||||
timeout.current = setInterval(onSave, 60000);
|
||||
const timeout = setInterval(onSave, SAVE_INTERVAL);
|
||||
// Save when the user leaves the page
|
||||
addEventListener('beforeunload', onSave);
|
||||
// Save when the user navigates to another page
|
||||
router.events.on('routeChangeStart', onSave);
|
||||
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
clearInterval(timeout);
|
||||
|
||||
removeEventListener('beforeunload', onSave);
|
||||
router.events.off('routeChangeStart', onSave);
|
||||
};
|
||||
}, [router.events, saveDoc, shouldSave]);
|
||||
}, [router.events, saveDoc]);
|
||||
};
|
||||
|
||||
export default useSaveDoc;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import _ from 'lodash';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { DocsBlockNoteEditor, HeadingBlock } from '../types';
|
||||
@@ -24,7 +25,7 @@ export interface UseHeadingStore {
|
||||
resetHeadings: () => void;
|
||||
}
|
||||
|
||||
export const useHeadingStore = create<UseHeadingStore>((set) => ({
|
||||
export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
|
||||
headings: [],
|
||||
setHeadings: (editor) => {
|
||||
const headingBlocks = editor?.document
|
||||
@@ -36,7 +37,9 @@ export const useHeadingStore = create<UseHeadingStore>((set) => ({
|
||||
),
|
||||
})) as unknown as HeadingBlock[];
|
||||
|
||||
set(() => ({ headings: headingBlocks }));
|
||||
if (!_.isEqual(get().headings, headingBlocks)) {
|
||||
set(() => ({ headings: headingBlocks }));
|
||||
}
|
||||
},
|
||||
resetHeadings: () => set(() => ({ headings: [] })),
|
||||
}));
|
||||
|
||||
@@ -105,6 +105,12 @@ 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 {
|
||||
|
||||
@@ -155,6 +155,7 @@ 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,6 +38,7 @@ 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,6 +107,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
<Box
|
||||
as="span"
|
||||
role="textbox"
|
||||
className="--docs--doc-title-input"
|
||||
contentEditable
|
||||
defaultValue={titleDisplay || undefined}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
|
||||
@@ -176,6 +176,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
$align="center"
|
||||
$gap="0.5rem 1.5rem"
|
||||
$wrap={isSmallMobile ? 'wrap' : 'nowrap'}
|
||||
className="--docs--doc-toolbox"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
|
||||
@@ -22,6 +22,7 @@ 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,7 +84,10 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box aria-label={t('Content modal to delete document')}>
|
||||
<Box
|
||||
aria-label={t('Content modal to delete document')}
|
||||
className="--docs--modal-remove-doc"
|
||||
>
|
||||
{!isError && (
|
||||
<Text $size="sm" $variation="600">
|
||||
{t('Are you sure you want to delete this document ?')}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 293 KiB |
@@ -11,7 +11,11 @@ type DocSearchItemProps = {
|
||||
export const DocSearchItem = ({ doc }: DocSearchItemProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
return (
|
||||
<Box data-testid={`doc-search-item-${doc.id}`} $width="100%">
|
||||
<Box
|
||||
data-testid={`doc-search-item-${doc.id}`}
|
||||
$width="100%"
|
||||
className="--docs--doc-search-item"
|
||||
>
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box $direction="row" $align="center" $gap="10px" $width="100%">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, ModalProps, ModalSize } from '@openfun/cunningham-react';
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
@@ -19,7 +19,10 @@ import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
type DocSearchModalProps = ModalProps & {};
|
||||
type DocSearchModalProps = {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -68,6 +71,7 @@ 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,6 +122,7 @@ export const DocShareAddMemberList = ({
|
||||
$css={css`
|
||||
border: 1px solid ${color['greyscale-200']};
|
||||
`}
|
||||
className="--docs--doc-share-add-member-list"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
|
||||
@@ -34,6 +34,7 @@ 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,6 +84,7 @@ 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,6 +72,7 @@ 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="noPadding"
|
||||
className="--docs--doc-share-modal noPadding "
|
||||
$justify="space-between"
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -20,6 +20,7 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
|
||||
$css={css`
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
className="--docs--doc-share-modal-footer"
|
||||
>
|
||||
<HorizontalSeparator $withPadding={true} />
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ type Props = {
|
||||
export const DocShareModalInviteUserRow = ({ user }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box $width="100%" data-testid={`search-user-row-${user.email}`}>
|
||||
<Box
|
||||
$width="100%"
|
||||
data-testid={`search-user-row-${user.email}`}
|
||||
className="--docs--doc-share-modal-invite-user-row"
|
||||
>
|
||||
<SearchUserRow
|
||||
user={user}
|
||||
right={
|
||||
|
||||
@@ -91,6 +91,7 @@ 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,7 +31,12 @@ export const SearchUserRow = ({
|
||||
right={right}
|
||||
alwaysShowRight={alwaysShowRight}
|
||||
left={
|
||||
<Box $direction="row" $align="center" $gap={spacings['xs']}>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacings['xs']}
|
||||
className="--docs--search-user-row"
|
||||
>
|
||||
<UserAvatar
|
||||
user={user}
|
||||
background={isInvitation ? colors['greyscale-400'] : undefined}
|
||||
|
||||
@@ -49,6 +49,7 @@ 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,6 +60,7 @@ export const Heading = ({
|
||||
$radius="4px"
|
||||
$background={isActive ? `${colorsTokens()['greyscale-100']}` : 'none'}
|
||||
$css="text-align: left;"
|
||||
className="--docs--table-content-heading"
|
||||
>
|
||||
<Text
|
||||
$width="100%"
|
||||
|
||||
@@ -122,6 +122,7 @@ export const TableContent = () => {
|
||||
gap: var(--c--theme--spacings--2xs);
|
||||
`}
|
||||
`}
|
||||
className="--docs--table-content"
|
||||
>
|
||||
{!isHover && (
|
||||
<BoxButton onClick={onOpen} $justify="center" $align="center">
|
||||
|
||||
@@ -107,7 +107,10 @@ export const ModalConfirmationVersion = ({
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box aria-label={t('Modal confirmation to restore the version')}>
|
||||
<Box
|
||||
aria-label={t('Modal confirmation to restore the version')}
|
||||
className="--docs--modal-confirmation-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="noPadding"
|
||||
className="--docs--modal-select-version noPadding"
|
||||
$direction="row"
|
||||
$height="100%"
|
||||
$maxHeight="calc(100vh - 2em - 12px)"
|
||||
|
||||
@@ -44,6 +44,7 @@ export const VersionItem = ({
|
||||
`}
|
||||
$hasTransition
|
||||
$minWidth="13rem"
|
||||
className="--docs--version-item"
|
||||
>
|
||||
<Box
|
||||
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
|
||||
|
||||
@@ -3,7 +3,14 @@ import { DateTime } from 'luxon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { Box, BoxButton, InfiniteScroll, Text, TextErrors } from '@/components';
|
||||
import {
|
||||
Box,
|
||||
BoxButton,
|
||||
Icon,
|
||||
InfiniteScroll,
|
||||
Text,
|
||||
TextErrors,
|
||||
} from '@/components';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { useDate } from '@/hook';
|
||||
|
||||
@@ -68,9 +75,7 @@ const VersionListState = ({
|
||||
causes={error.cause}
|
||||
icon={
|
||||
error.status === 502 ? (
|
||||
<Text $isMaterialIcon $theme="danger">
|
||||
wifi_off
|
||||
</Text>
|
||||
<Icon iconName="wifi_off" $theme="danger" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
@@ -109,7 +114,10 @@ export const VersionList = ({
|
||||
}, [] as Versions[]);
|
||||
|
||||
return (
|
||||
<Box $css="overflow-y: auto; overflow-x: hidden;">
|
||||
<Box
|
||||
$css="overflow-y: auto; overflow-x: hidden;"
|
||||
className="--docs--version-list"
|
||||
>
|
||||
<InfiniteScroll
|
||||
hasMore={hasNextPage}
|
||||
isLoading={isFetchingNextPage}
|
||||
|
||||
@@ -60,6 +60,7 @@ export const DocsGrid = ({
|
||||
$maxWidth="960px"
|
||||
$maxHeight="calc(100vh - 52px - 2rem)"
|
||||
$align="center"
|
||||
className="--docs--doc-grid"
|
||||
>
|
||||
<DocsGridLoader isLoading={isRefetching || loading} />
|
||||
<Card
|
||||
|
||||
@@ -49,6 +49,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
}
|
||||
`}
|
||||
className="--docs--doc-grid-item"
|
||||
>
|
||||
<StyledLink
|
||||
$css={css`
|
||||
|
||||
@@ -26,6 +26,7 @@ export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
|
||||
</Text>
|
||||
}
|
||||
placement="top"
|
||||
className="--docs--doc-tooltip-grid-item-shared-button"
|
||||
>
|
||||
<Button
|
||||
style={{ minWidth: '50px', justifyContent: 'center' }}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const DocsGridLoader = ({ isLoading }: DocsGridLoaderProps) => {
|
||||
$background="rgba(255, 255, 255, 0.5)"
|
||||
$zIndex={998}
|
||||
$position="absolute"
|
||||
className="--docs--doc-grid-loader"
|
||||
>
|
||||
<Loader />
|
||||
</Box>
|
||||
|
||||
@@ -38,7 +38,12 @@ export const SimpleDocItem = ({
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
return (
|
||||
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap={spacings.sm}
|
||||
$overflow="auto"
|
||||
className="--docs--simple-doc-item"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Footer = () => {
|
||||
const logo = themeTokens().logo;
|
||||
|
||||
return (
|
||||
<Box $position="relative" as="footer">
|
||||
<Box $position="relative" as="footer" className="--docs--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>
|
||||
<Box className="--docs--footer-logo">
|
||||
<Box $align="center" $gap="6rem" $direction="row">
|
||||
{logo && (
|
||||
<Image
|
||||
@@ -52,6 +52,7 @@ export const Footer = () => {
|
||||
row-gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
`}
|
||||
className="--docs--footer-external-links"
|
||||
>
|
||||
{[
|
||||
{
|
||||
@@ -99,6 +100,7 @@ export const Footer = () => {
|
||||
column-gap: 1rem;
|
||||
row-gap: .5rem;
|
||||
`}
|
||||
className="--docs--footer-internal-links"
|
||||
>
|
||||
{[
|
||||
{
|
||||
@@ -145,6 +147,7 @@ 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,6 +21,7 @@ export const ButtonTogglePanel = () => {
|
||||
iconName={isPanelOpen ? 'close' : 'menu'}
|
||||
/>
|
||||
}
|
||||
className="--docs--button-toggle-panel"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ export const Header = () => {
|
||||
background-color: ${colors['greyscale-000']};
|
||||
border-bottom: 1px solid ${colors['greyscale-200']};
|
||||
`}
|
||||
className="--docs--header"
|
||||
>
|
||||
{!isDesktop && <ButtonTogglePanel />}
|
||||
<StyledLink href="/">
|
||||
|
||||
@@ -10,7 +10,12 @@ export const Title = () => {
|
||||
const spacings = theme.spacingsTokens();
|
||||
|
||||
return (
|
||||
<Box $direction="row" $align="center" $gap={spacings['2xs']}>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacings['2xs']}
|
||||
className="--docs--title"
|
||||
>
|
||||
<Text
|
||||
$margin="none"
|
||||
as="h2"
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function HomeBanner() {
|
||||
$height="100vh"
|
||||
$margin={{ top: `-${getHeaderHeight(isSmallMobile)}px` }}
|
||||
$position="relative"
|
||||
className="--docs--home-banner"
|
||||
>
|
||||
<Box
|
||||
$width="100%"
|
||||
@@ -75,11 +76,7 @@ export default function HomeBanner() {
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => gotoLogin()}
|
||||
icon={
|
||||
<Text $isMaterialIcon $color="white">
|
||||
bolt
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="bolt" $color="white" />}
|
||||
>
|
||||
{t('Start Writing')}
|
||||
</Button>
|
||||
|
||||
@@ -30,6 +30,7 @@ function HomeProConnect() {
|
||||
<Box
|
||||
$justify="center"
|
||||
$height={!isMobile ? `calc(100vh - ${parentGap})` : 'auto'}
|
||||
className="--docs--home-proconnect"
|
||||
>
|
||||
<Box
|
||||
$gap={spacings['md']}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button } from '@openfun/cunningham-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Box, Icon, 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">
|
||||
<Box as="main" className="--docs--home-content">
|
||||
<HomeHeader />
|
||||
{isSmallMobile && (
|
||||
<Box $css="& .panel-header{display: none;}">
|
||||
<Box $css="& .--docs--left-panel-header{display: none;}">
|
||||
<LeftPanel />
|
||||
</Box>
|
||||
)}
|
||||
@@ -155,11 +155,7 @@ export function HomeContent() {
|
||||
$margin={{ top: 'small' }}
|
||||
>
|
||||
<Button
|
||||
icon={
|
||||
<Text $isMaterialIcon $color="white">
|
||||
chat
|
||||
</Text>
|
||||
}
|
||||
icon={<Icon iconName="chat" $color="white" />}
|
||||
href="https://matrix.to/#/#docs-official:matrix.org"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -31,6 +31,7 @@ export const HomeHeader = () => {
|
||||
$width="100%"
|
||||
$padding={{ horizontal: 'small' }}
|
||||
$height={`${isSmallMobile ? HEADER_HEIGHT_MOBILE : HEADER_HEIGHT}px`}
|
||||
className="--docs--home-header"
|
||||
>
|
||||
<Box
|
||||
$align="center"
|
||||
|
||||
@@ -93,6 +93,7 @@ export const HomeSection = ({
|
||||
opacity: ${isVisible ? 1 : 0};
|
||||
${$css}
|
||||
`}
|
||||
className="--docs--home-section"
|
||||
>
|
||||
<Box
|
||||
$direction={direction}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { DropdownMenu, Text } from '@/components/';
|
||||
import { DropdownMenu, Icon, Text } from '@/components/';
|
||||
import { useConfig } from '@/core';
|
||||
|
||||
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
|
||||
@@ -70,10 +70,9 @@ export const LanguagePicker = () => {
|
||||
aria-label={t('Language')}
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
className="--docs--language-picker-text"
|
||||
>
|
||||
<Text $isMaterialIcon $color="inherit" $size="xl">
|
||||
translate
|
||||
</Text>
|
||||
<Icon iconName="translate" $color="inherit" $size="xl" />
|
||||
{currentLanguageLabel}
|
||||
</Text>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const LeftPanelTargetFilters = () => {
|
||||
const onSelectQuery = (query: DocDefaultFilter) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('target', query);
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
togglePanel();
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ 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 { useCallback, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
import { Box, SeparatedSection } from '@/components';
|
||||
@@ -30,13 +30,9 @@ export const LeftPanel = () => {
|
||||
const colors = theme.colorsTokens();
|
||||
const spacings = theme.spacingsTokens();
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
togglePanel(false);
|
||||
}, [togglePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
toggle();
|
||||
}, [pathname, toggle]);
|
||||
togglePanel(false);
|
||||
}, [pathname, togglePanel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -49,7 +45,8 @@ export const LeftPanel = () => {
|
||||
min-width: 300px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid ${colors['greyscale-200']};
|
||||
`}
|
||||
`}
|
||||
className="--docs--left-panel-desktop"
|
||||
>
|
||||
<Box
|
||||
$css={css`
|
||||
@@ -76,6 +73,7 @@ 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,6 +21,7 @@ export const LeftPanelContent = () => {
|
||||
$css={css`
|
||||
flex: 0 0 auto;
|
||||
`}
|
||||
className="--docs--home-left-panel-content"
|
||||
>
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<LeftPanelTargetFilters />
|
||||
|
||||
@@ -18,6 +18,7 @@ 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' }}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user