Compare commits
1 Commits
rename-doc
...
feature/cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17e28c487c |
78
.cursor/rules/django-python.mdc
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
description: Rules for writing Python with Django
|
||||
globs: src/backend/**/*.py
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are an expert in Python, Django, and scalable web application development.
|
||||
|
||||
Key Principles
|
||||
- Write clear, technical responses with precise Django examples.
|
||||
- Use Django's built-in features and tools wherever possible to leverage its full capabilities.
|
||||
- Prioritize readability and maintainability; follow Django's coding style guide (PEP 8 compliance for the most part, with the one exception being 100 characters per line instead of 79).
|
||||
- Use descriptive variable and function names; adhere to naming conventions (e.g., lowercase with underscores for functions and variables).
|
||||
|
||||
Django/Python
|
||||
- Use Django REST Framework viewsets for API endpoints.
|
||||
- Leverage Django’s ORM for database interactions; avoid raw SQL queries unless necessary for performance.
|
||||
- Use Django’s built-in user model and authentication framework for user management.
|
||||
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
|
||||
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
|
||||
|
||||
Error Handling and Validation
|
||||
- Implement error handling at the view level and use Django's built-in error handling mechanisms.
|
||||
- Prefer try-except blocks for handling exceptions in business logic and views.
|
||||
|
||||
Dependencies
|
||||
- Django
|
||||
- Django REST Framework (for API development)
|
||||
- Celery (for background tasks)
|
||||
- Redis (for caching and task queues)
|
||||
- PostgreSQL (preferred databases for production)
|
||||
- Minio (file storage for production)
|
||||
- OIDC prodiver (for managing authentication)
|
||||
|
||||
Django-Specific Guidelines
|
||||
- Use Django templates for rendering HTML and DRF serializers for JSON responses.
|
||||
- Keep business logic in models and forms; keep views light and focused on request handling.
|
||||
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
|
||||
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
|
||||
- Use Django’s built-in tools for testing (pytest-django) to ensure code quality and reliability.
|
||||
- Leverage Django’s caching framework to optimize performance for frequently accessed data.
|
||||
- Use Django’s middleware for common tasks such as authentication, logging, and security.
|
||||
|
||||
Performance Optimization
|
||||
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
|
||||
- Use Django’s cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
|
||||
- Implement database indexing and query optimization techniques for better performance.
|
||||
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
|
||||
- Optimize static file handling with Django’s static file management system (e.g., WhiteNoise).
|
||||
|
||||
Logging
|
||||
- As a general rule, we should have logs for every expected and unexpected actions of the application, using the appropriate log level.
|
||||
- We should also be logging these exceptions to Sentry with the Sentry Python SDK. Python exceptions should almost always be captured automatically without extra instrumentation, but custom ones (such as failed requests to external services, query errors, or Celery task failures) can be tracked using capture_exception().
|
||||
|
||||
Log Levels
|
||||
- A log level or log severity is a piece of information telling how important a given log message is:
|
||||
- DEBUG: should be used for information that may be needed for diagnosing issues and troubleshooting or when running application in the test environment for the purpose of making sure everything is running correctly
|
||||
- INFO: should be used as standard log level, indicating that something happened
|
||||
- WARN: should be used when something unexpected happened but the code can continue the work
|
||||
- ERROR: should be used when the application hits an issue preventing one or more functionalities from properly functioning
|
||||
|
||||
Security
|
||||
- Don’t log sensitive information. Make sure you never log:
|
||||
- authorization tokens
|
||||
- passwords
|
||||
- financial data
|
||||
- health data
|
||||
- PII (Personal Identifiable Information)
|
||||
|
||||
Testing
|
||||
- All new packages and most new significant functionality should come with unit tests
|
||||
|
||||
Unit tests
|
||||
- A good unit test should:
|
||||
- focus on a single use-case at a time
|
||||
- have a minimal set of assertions per test
|
||||
- demonstrate every use case. The rule of thumb is: if it can happen, it should be covered
|
||||
Refer to Django documentation for best practices in views, models, forms, and security considerations.
|
||||
6
.github/workflows/docker-hub.yml
vendored
@@ -11,7 +11,6 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'ci/trivy-fails'
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
@@ -39,6 +38,7 @@ jobs:
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -72,6 +72,7 @@ jobs:
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -105,7 +106,8 @@ jobs:
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
|
||||
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
23
CHANGELOG.md
@@ -7,30 +7,10 @@ and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
- 📝(doc) change documentation folder name to "documentation" #876
|
||||
|
||||
## Added
|
||||
|
||||
- 🚩 add homepage feature flag #861
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
@@ -530,8 +510,7 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.1.0...main
|
||||
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.0.0...main
|
||||
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
|
||||
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
||||
|
||||
13
README.md
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/docs">
|
||||
<img alt="Docs" src="/documentation/assets/docs-logo-black-white.png" width="300" />
|
||||
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@ Welcome to Docs! The open source document editor where your notes can become kno
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<img src="/documentation/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||
|
||||
## Why use Docs ❓
|
||||
|
||||
@@ -48,7 +48,12 @@ Docs is a collaborative text editor designed to address common challenges in kno
|
||||
|
||||
### Test it
|
||||
|
||||
Test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/)
|
||||
|
||||
```
|
||||
email: test.docs@yopmail.com
|
||||
password: I'd<3ToTestDocs
|
||||
```
|
||||
|
||||
### Run it locally
|
||||
|
||||
@@ -193,5 +198,5 @@ We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](h
|
||||
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
|
||||
|
||||
<p align="center">
|
||||
<img src="/documentation/assets/europe_opensource.png" width="50%"/>
|
||||
<img src="/docs/assets/europe_opensource.png" width="50%"/>
|
||||
</p>
|
||||
|
||||
BIN
docs/assets/docs-logo.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
BIN
docs/assets/logo.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
@@ -47,11 +47,6 @@ These are the environmental variables you can set for the impress-backend contai
|
||||
| COLLABORATION_API_URL | collaboration api host | |
|
||||
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
|
||||
| COLLABORATION_WS_URL | collaboration websocket url | |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_FOOTER_FEATURE_ENABLED | frontend feature flag to display the footer | false |
|
||||
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
|
||||
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
|
||||
| FRONTEND_THEME | frontend theme to use | |
|
||||
| POSTHOG_KEY | posthog key for analytics | |
|
||||
| CRISP_WEBSITE_ID | crisp website id for support | |
|
||||
@@ -1,20 +1,20 @@
|
||||
# Installation on a k8s cluster
|
||||
|
||||
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it works. It needs to be adapted for a production environment.
|
||||
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it's work. It needs to be adapt for production environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we provide an example)
|
||||
- a Memcached server (if you don't have one, we provide an example)
|
||||
- a S3 bucket (if you don't have one, we provide an example)
|
||||
- an OIDC provider (if you don't have one, we will provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we will provide an example)
|
||||
- a Memcached server (if you don't have one, we will provide an example)
|
||||
- a S3 bucket (if you don't have one, we will provide an example)
|
||||
|
||||
### Test cluster
|
||||
|
||||
If you do not have a test cluster, you can install everything on a local Kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||
|
||||
To be able to use the script, you need to install:
|
||||
To be able to use the script, you will need to install:
|
||||
|
||||
- Docker (https://docs.docker.com/desktop/)
|
||||
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||
@@ -96,13 +96,13 @@ ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
|
||||
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
|
||||
```
|
||||
|
||||
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the `*.127.0.0.1.nip.io` domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the \*.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||
|
||||
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
|
||||
## Preparation
|
||||
|
||||
### What do you use to authenticate your users?
|
||||
### What will you use to authenticate your users ?
|
||||
|
||||
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||
|
||||
@@ -117,9 +117,9 @@ keycloak-0 1/1 Running 0 6m48s
|
||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
From here the important informations you will need are :
|
||||
|
||||
```yaml
|
||||
```
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||
@@ -135,7 +135,7 @@ You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### Find redis server connexion values
|
||||
|
||||
Docs needs a redis so we start by deploying one:
|
||||
Impress need a redis so we will start by deploying a redis :
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
@@ -148,7 +148,7 @@ redis-master-0 1/1 Running 0 35s
|
||||
|
||||
### Find postgresql connexion values
|
||||
|
||||
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
|
||||
Impress uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follow:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
@@ -160,9 +160,9 @@ postgresql-0 1/1 Running 0 14m
|
||||
redis-master-0 1/1 Running 0 42s
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
From here important informations you will need are :
|
||||
|
||||
```yaml
|
||||
```
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
@@ -175,7 +175,7 @@ POSTGRES_PASSWORD: pass
|
||||
|
||||
### Find s3 bucket connexion values
|
||||
|
||||
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
|
||||
Impress uses a s3 bucket to store documents so if you have a provider obtain the necessary information to use it. If you do not have, you can install a local minio testing environment as follow:
|
||||
|
||||
```
|
||||
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
|
||||
@@ -191,7 +191,7 @@ redis-master-0 1/1 Running 0 10m
|
||||
|
||||
## Deployment
|
||||
|
||||
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous informations to the helm chart.
|
||||
Now you are ready to deploy Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
|
||||
|
||||
```
|
||||
$ helm repo add impress https://suitenumerique.github.io/docs/
|
||||
@@ -214,7 +214,7 @@ redis-master-0 1/1 Running 0 20m
|
||||
|
||||
## Test your deployment
|
||||
|
||||
In order to test your deployment you have to log into your instance. If you exclusively use our examples you can do:
|
||||
In order to test your deployment you have to login to your instance. If you use exclusively our examples you can do :
|
||||
|
||||
```
|
||||
$ kubectl get ingress
|
||||
@@ -227,4 +227,4 @@ impress-docs-ws <none> impress.127.0.0.1.nip.io localhost
|
||||
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
|
||||
```
|
||||
|
||||
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||
@@ -1,193 +0,0 @@
|
||||
## Decision TLDR;
|
||||
|
||||
We will use Yjs a CRDT-based library for the collaborative editing of the documents.
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
We need to implement a collaborative editing feature for the documents that supports real-time collaboration, offline capabilities, and seamless integration with our Django backend.
|
||||
|
||||
## Considered alternatives
|
||||
|
||||
### ProseMirror
|
||||
|
||||
A robust toolkit for building rich-text editors with collaboration capabilities.
|
||||
|
||||
| Pros | Cons |
|
||||
| --- | --- |
|
||||
| Mature ecosystem | Complex integration with Django |
|
||||
| Rich text editing features | Steeper learning curve |
|
||||
| Used by major companies | More complex to implement offline support |
|
||||
| Large community | |
|
||||
|
||||
### ShareDB
|
||||
|
||||
Real-time database backend based on Operational Transformation.
|
||||
|
||||
| Pros | Cons |
|
||||
| --- | --- |
|
||||
| Battle-tested in production | Complex setup required |
|
||||
| Strong consistency model | Requires specific backend architecture |
|
||||
| Good documentation | Less flexible with different backends |
|
||||
| | Higher latency compared to CRDTs |
|
||||
|
||||
### Convergence
|
||||
|
||||
Complete enterprise solution for real-time collaboration.
|
||||
|
||||
| Pros | Cons |
|
||||
| --- | --- |
|
||||
| Full-featured solution | Commercial licensing |
|
||||
| Built-in presence features | Less community support |
|
||||
| Enterprise support | More expensive |
|
||||
| Good offline support | Overkill for basic needs |
|
||||
|
||||
### CRDT-based Solutions Comparison
|
||||
|
||||
A CRDT-based library specifically designed for real-time collaboration.
|
||||
|
||||
| Category | Pros | Cons |
|
||||
|----------|------|------|
|
||||
| Technical Implementation | • Native real-time collaboration<br>• No central conflict resolution needed<br>• Works well with Django backend<br>• Automatic state synchronization | • Learning curve for CRDT concepts<br>• More complex initial setup<br>• Additional metadata overhead |
|
||||
| User Experience | • Instant local updates<br>• Works offline by default<br>• Low latency<br>• Smooth concurrent editing | • Eventual consistency might cause brief inconsistencies<br>• UI must handle temporary conflicts |
|
||||
| Performance | • Excellent scaling with multiple users<br>• Reduced server load<br>• Efficient network usage<br>• Good memory optimization (especially Yjs) | • Slightly higher memory usage<br>• Initial state sync can be larger |
|
||||
| Development | • No need to build conflict resolution<br>• Simple integration with text editors<br>• Future-proof architecture | • Team needs to learn new concepts<br>• Fewer ready-made solutions<br>• May need to build some features from scratch |
|
||||
| Maintenance | • Less server infrastructure<br>• Simpler deployment<br>• Fewer points of failure | • Debugging can be more complex<br>• State management requires careful handling |
|
||||
| Business Impact | • Better offline support for users<br>• Scales well as user base grows<br>• No licensing costs (with Yjs) | • Initial development time might be longer<br>• Team training required |
|
||||
|
||||
#### Yjs
|
||||
- **Type**: State-based CRDT
|
||||
- **Implementation**: JavaScript/TypeScript
|
||||
- **Features**:
|
||||
- Rich text collaboration
|
||||
- Shared types (Array, Map, XML)
|
||||
- Binary encoding
|
||||
- P2P support
|
||||
- **Performance**: Excellent for text editing
|
||||
- **Memory Usage**: Optimized
|
||||
- **License**: MIT
|
||||
|
||||
#### Automerge
|
||||
- **Type**: Operation-based CRDT
|
||||
- **Implementation**: JavaScript/Rust
|
||||
- **Features**:
|
||||
- JSON-like data structures
|
||||
- Change history
|
||||
- Undo/Redo
|
||||
- Binary format
|
||||
- **Performance**: Good, with Rust backend
|
||||
- **Memory Usage**: Higher than Yjs
|
||||
- **License**: MIT
|
||||
|
||||
#### Legion
|
||||
- **Type**: State-based CRDT
|
||||
- **Implementation**: Rust with JS bindings
|
||||
- **Features**:
|
||||
- High performance
|
||||
- Memory efficient
|
||||
- Binary protocol
|
||||
- **Performance**: Excellent
|
||||
- **Memory Usage**: Very efficient
|
||||
- **License**: Apache 2.0
|
||||
|
||||
#### Diamond Types
|
||||
- **Type**: Operation-based CRDT
|
||||
- **Implementation**: TypeScript
|
||||
- **Features**:
|
||||
- Specialized for text
|
||||
- Small memory footprint
|
||||
- Simple API
|
||||
- **Performance**: Good for text
|
||||
- **Memory Usage**: Efficient
|
||||
- **License**: MIT
|
||||
|
||||
Comparison Table:
|
||||
|
||||
| Feature | Yjs | Automerge | Legion | Diamond Types |
|
||||
|---------|-----|-----------|--------|---------------|
|
||||
| Text Editing | ✅ Excellent | ✅ Good | ⚠️ Basic | ✅ Excellent |
|
||||
| Structured Data | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| Memory Efficiency | ✅ High | ⚠️ Medium | ✅ Very High | ✅ High |
|
||||
| Network Efficiency | ✅ | ⚠️ | ✅ | ✅ |
|
||||
| Maturity | ✅ | ✅ | ⚠️ | ⚠️ |
|
||||
| Community Size | ✅ Large | ✅ Large | ⚠️ Small | ⚠️ Small |
|
||||
| Documentation | ✅ | ✅ | ⚠️ | ⚠️ |
|
||||
| Backend Options | ✅ Many | ✅ Many | ⚠️ Limited | ⚠️ Limited |
|
||||
|
||||
Key Differences:
|
||||
1. **Implementation Approach**:
|
||||
- Yjs: Optimized for text and rich-text editing
|
||||
- Automerge: General-purpose JSON CRDT
|
||||
- Legion: Performance-focused with Rust
|
||||
- Diamond Types: Specialized for text collaboration
|
||||
|
||||
2. **Performance Characteristics**:
|
||||
- Yjs: Best for text editing scenarios
|
||||
- Automerge: Good all-around performance
|
||||
- Legion: Excellent raw performance
|
||||
- Diamond Types: Optimized for text
|
||||
|
||||
3. **Ecosystem Integration**:
|
||||
- Yjs: Wide range of integrations
|
||||
- Automerge: Good JavaScript ecosystem
|
||||
- Legion: Limited but growing
|
||||
- Diamond Types: Focused on text editors
|
||||
|
||||
This analysis reinforces our choice of Yjs for the CRDT-based option as it provides:
|
||||
- Best-in-class text editing performance
|
||||
- Mature ecosystem
|
||||
- Active community
|
||||
- Excellent documentation
|
||||
- Wide range of backend options
|
||||
|
||||
## Decision
|
||||
|
||||
After evaluating the alternatives, we choose Yjs for the following reasons:
|
||||
|
||||
1. **Technical Fit:**
|
||||
- Native CRDT support ensures reliable collaboration
|
||||
- Excellent offline capabilities
|
||||
- Good performance characteristics
|
||||
- Flexible backend integration options
|
||||
|
||||
2. **Project Requirements Match:**
|
||||
- Easy integration with our Django backend
|
||||
- Supports our core collaborative features
|
||||
- Manageable learning curve for the team
|
||||
|
||||
3. **Community & Support:**
|
||||
- Active development
|
||||
- Growing community
|
||||
- Good documentation
|
||||
- Open source with MIT license
|
||||
|
||||
### Comparison of Key Features:
|
||||
|
||||
| Feature | Yjs (CRDT) | ProseMirror | ShareDB | Convergence |
|
||||
|---------|-----|-------------|----------|-------------|
|
||||
| Real-time Collaboration | ✅ | ✅ | ✅ | ✅ |
|
||||
| Offline Support | ✅ | ⚠️ | ⚠️ | ✅ |
|
||||
| Django Integration | Easy | Complex | Complex | Moderate |
|
||||
| Learning Curve | Medium | High | High | Medium |
|
||||
| Cost | Free | Free | Free | Paid |
|
||||
| Community Size | Growing | Large | Medium | Small |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Simplified implementation of real-time collaboration
|
||||
- Good developer experience
|
||||
- Future-proof technology choice
|
||||
- No licensing costs
|
||||
|
||||
### Negative
|
||||
- Team needs to learn CRDT concepts
|
||||
- Newer technology compared to alternatives
|
||||
- May need to build some features available out-of-the-box in other solutions
|
||||
|
||||
### Risks
|
||||
- Community support might not grow as expected
|
||||
- May discover limitations as we scale
|
||||
@@ -1,19 +0,0 @@
|
||||
## Architecture
|
||||
|
||||
### Global system architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User -- HTTP --> Front("Frontend (NextJS SPA)")
|
||||
Front -- REST API --> Back("Backend (Django)")
|
||||
Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back
|
||||
Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
|
||||
Back -- REST API --> Yserver
|
||||
Back --> DB("Database (PostgreSQL)")
|
||||
Back <--> Celery --> DB
|
||||
Back ----> S3("Minio (S3)")
|
||||
```
|
||||
|
||||
### Architecture decision records
|
||||
|
||||
- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md)
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -1,33 +0,0 @@
|
||||
# Runtime Theming 🎨
|
||||
|
||||
### How to Use
|
||||
|
||||
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||
|
||||
```javascript
|
||||
FRONTEND_CSS_URL=http://anything/custom-style.css
|
||||
```
|
||||
|
||||
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
|
||||
|
||||
### Benefits
|
||||
|
||||
This feature provides several benefits, including:
|
||||
|
||||
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
|
||||
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
|
||||
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
|
||||
|
||||
### Example Use Case
|
||||
|
||||
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
|
||||
|
||||
```css
|
||||
body {
|
||||
background-color: #3498db;
|
||||
}
|
||||
```
|
||||
|
||||
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
AI_API_KEY=password
|
||||
AI_MODEL=llama
|
||||
@@ -64,6 +63,3 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=default
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED=True
|
||||
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json
|
||||
|
||||
@@ -16,10 +16,8 @@ from django.db import transaction
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
import requests
|
||||
import rest_framework as drf
|
||||
@@ -32,7 +30,6 @@ from rest_framework.throttling import UserRateThrottle
|
||||
from core import authentication, enums, models
|
||||
from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
from core.services.config_services import get_footer_json
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
@@ -1687,13 +1684,9 @@ class ConfigView(drf.views.APIView):
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = [
|
||||
"AI_FEATURE_ENABLED",
|
||||
"COLLABORATION_WS_URL",
|
||||
"CRISP_WEBSITE_ID",
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
"FRONTEND_FOOTER_FEATURE_ENABLED",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
@@ -1707,22 +1700,3 @@ class ConfigView(drf.views.APIView):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
|
||||
class FooterView(drf.views.APIView):
|
||||
"""API ViewSet for sharing the footer JSON."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@method_decorator(cache_page(settings.FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT))
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/footer/
|
||||
Return the footer JSON.
|
||||
"""
|
||||
json_footer = (
|
||||
get_footer_json(settings.FRONTEND_URL_JSON_FOOTER)
|
||||
if settings.FRONTEND_URL_JSON_FOOTER
|
||||
else {}
|
||||
)
|
||||
return drf.response.Response(json_footer)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Config services."""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_footer_json(footer_json_url: str) -> dict:
|
||||
"""
|
||||
Fetches the footer JSON from the given URL."
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
footer_json_url, timeout=5, headers={"User-Agent": "Docs-Application"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
footer_json = response.json()
|
||||
|
||||
return footer_json
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
logger.error("Failed to fetch footer JSON: %s", e)
|
||||
return {}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
USER = "user"
|
||||
@@ -11,12 +9,6 @@ TEAM = "team"
|
||||
VIA = [USER, TEAM]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_teams():
|
||||
"""Mock for the "teams" property on the User model."""
|
||||
|
||||
@@ -5,6 +5,7 @@ Test AI transform API endpoint for users in impress's core app.
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +17,12 @@ from core.tests.conftest import TEAM, USER, VIA
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_settings():
|
||||
"""Fixture to set AI settings."""
|
||||
|
||||
@@ -5,6 +5,7 @@ Test AI translate API endpoint for users in impress's core app.
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +17,12 @@ from core.tests.conftest import TEAM, USER, VIA
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_settings():
|
||||
"""Fixture to set AI settings."""
|
||||
|
||||
@@ -18,9 +18,6 @@ pytestmark = pytest.mark.django_db
|
||||
@override_settings(
|
||||
COLLABORATION_WS_URL="http://testcollab/",
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_CSS_URL="http://testcss/",
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True,
|
||||
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"},
|
||||
@@ -41,9 +38,6 @@ def test_api_config(is_authenticated):
|
||||
"COLLABORATION_WS_URL": "http://testcollab/",
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_FOOTER_FEATURE_ENABLED": True,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [
|
||||
["en-us", "English"],
|
||||
@@ -55,5 +49,4 @@ def test_api_config(is_authenticated):
|
||||
"MEDIA_BASE_URL": "http://testserver/",
|
||||
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
"SENTRY_DSN": "https://sentry.test/123",
|
||||
"AI_FEATURE_ENABLED": False,
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Test the footer API."""
|
||||
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
def test_api_footer_without_settings_configured(settings):
|
||||
"""Test the footer API without settings configured."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = None
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_footer_with_invalid_request(settings):
|
||||
"""Test the footer API with an invalid request."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = "https://invalid-request.com"
|
||||
|
||||
footer_response = responses.get(settings.FRONTEND_URL_JSON_FOOTER, status=404)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
assert footer_response.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_footer_with_invalid_json(settings):
|
||||
"""Test the footer API with an invalid JSON response."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
|
||||
|
||||
footer_response = responses.get(
|
||||
settings.FRONTEND_URL_JSON_FOOTER, status=200, body="invalid json"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
assert footer_response.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_footer_with_valid_json(settings):
|
||||
"""Test the footer API with an invalid JSON response."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
|
||||
|
||||
footer_response = responses.get(
|
||||
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": "bar"}
|
||||
assert footer_response.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_footer_with_valid_json_and_cache(settings):
|
||||
"""Test the footer API with an invalid JSON response."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
|
||||
|
||||
footer_response = responses.get(
|
||||
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": "bar"}
|
||||
assert footer_response.call_count == 1
|
||||
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": "bar"}
|
||||
# The cache should have been used
|
||||
assert footer_response.call_count == 1
|
||||
@@ -4,8 +4,10 @@ Test throttling on documents for the AI endpoint.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.views import APIView
|
||||
@@ -23,6 +25,12 @@ class DocumentAPIView(APIView):
|
||||
return Response({"message": "Success"})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@patch("time.time")
|
||||
def test_api_utils_ai_document_rate_throttle_minute_limit(mock_time):
|
||||
|
||||
@@ -5,6 +5,7 @@ Test throttling on users for the AI endpoint.
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
@@ -28,6 +29,12 @@ class DocumentAPIView(APIView):
|
||||
return Response({"message": "Success"})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@patch("time.time")
|
||||
def test_api_utils_ai_user_rate_throttle_minute_limit(mock_time):
|
||||
|
||||
@@ -56,5 +56,4 @@ urlpatterns = [
|
||||
),
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
path(f"api/{settings.API_VERSION}/footer/", viewsets.FooterView.as_view()),
|
||||
]
|
||||
|
||||
@@ -410,27 +410,6 @@ class Base(Configuration):
|
||||
FRONTEND_THEME = values.Value(
|
||||
None, environ_name="FRONTEND_THEME", environ_prefix=None
|
||||
)
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
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(
|
||||
@@ -549,9 +528,6 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
# AI service
|
||||
AI_FEATURE_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
|
||||
)
|
||||
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
|
||||
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
|
||||
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "3.1.0"
|
||||
version = "3.0.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,10 +25,10 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"beautifulsoup4==4.13.3",
|
||||
"boto3==1.37.24",
|
||||
"beautifulsoup4==4.12.3",
|
||||
"boto3==1.37.18",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.5.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
@@ -38,9 +38,9 @@ dependencies = [
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.8",
|
||||
"django==5.1.7",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.0",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
@@ -51,13 +51,14 @@ dependencies = [
|
||||
"markdown==3.7",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.70.0",
|
||||
"openai==1.68.2",
|
||||
"psycopg[binary]==3.2.6",
|
||||
"pycrdt==0.12.10",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.25.0",
|
||||
"sentry-sdk==2.24.0",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.9.0",
|
||||
]
|
||||
|
||||
@@ -85,7 +86,7 @@ dev = [
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.11.2",
|
||||
"types-requests==2.32.0.20250328",
|
||||
"types-requests==2.32.0.20250306",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export 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_HOMEPAGE_FEATURE_ENABLED: true,
|
||||
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,
|
||||
};
|
||||
|
||||
export const keyCloakSignIn = async (
|
||||
page: Page,
|
||||
browserName: string,
|
||||
|
||||
@@ -2,7 +2,24 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { CONFIG, createDoc } from './common';
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
const config = {
|
||||
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,
|
||||
};
|
||||
|
||||
test.describe('Config', () => {
|
||||
test('it checks the config api is called', async ({ page }) => {
|
||||
@@ -16,7 +33,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 +44,7 @@ test.describe('Config', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...CONFIG,
|
||||
...config,
|
||||
SENTRY_DSN: 'https://sentry.io/123',
|
||||
},
|
||||
});
|
||||
@@ -81,46 +98,23 @@ test.describe('Config', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
|
||||
void createDoc(page, 'doc-collaboration', browserName, 1);
|
||||
|
||||
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
|
||||
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket.url().includes('ws://localhost:4444/collaboration/ws/');
|
||||
});
|
||||
expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/');
|
||||
});
|
||||
|
||||
test('it checks the AI feature flag from config endpoint', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...CONFIG,
|
||||
AI_FEATURE_ENABLED: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await createDoc(page, 'doc-ai-feature', browserName, 1);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Anything');
|
||||
await page.getByText('Anything').selectText();
|
||||
expect(
|
||||
await page.locator('button[data-test="convertMarkdown"]').count(),
|
||||
).toBe(1);
|
||||
expect(await page.locator('button[data-test="ai-actions"]').count()).toBe(
|
||||
0,
|
||||
const randomDoc = await createDoc(
|
||||
page,
|
||||
'doc-collaboration',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
const webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/');
|
||||
});
|
||||
|
||||
test('it checks that Crisp is trying to init from config endpoint', async ({
|
||||
@@ -131,7 +125,7 @@ test.describe('Config', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...CONFIG,
|
||||
...config,
|
||||
CRISP_WEBSITE_ID: '1234',
|
||||
},
|
||||
});
|
||||
@@ -146,30 +140,6 @@ test.describe('Config', () => {
|
||||
page.locator('#crisp-chatbox').getByText('Invalid website'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks FRONTEND_CSS_URL config', async ({ page }) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...CONFIG,
|
||||
FRONTEND_CSS_URL: 'http://localhost:123465/css/style.css',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('head link[href="http://localhost:123465/css/style.css"]')
|
||||
.first(),
|
||||
).toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Config: Not loggued', () => {
|
||||
|
||||
@@ -25,11 +25,7 @@ test.describe('Doc Editor', () => {
|
||||
await editor.click();
|
||||
await editor.fill('test content');
|
||||
|
||||
await editor
|
||||
.getByText('test content', {
|
||||
exact: true,
|
||||
})
|
||||
.selectText();
|
||||
await editor.getByText('test content').dblclick();
|
||||
|
||||
const toolbar = page.locator('.bn-formatting-toolbar');
|
||||
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
|
||||
@@ -62,18 +58,18 @@ test.describe('Doc Editor', () => {
|
||||
* - signal of the backend to the collaborative server (connection should close)
|
||||
* - reconnection to the collaborative server
|
||||
*/
|
||||
test('checks the connection with collaborative server', async ({ page }) => {
|
||||
test('checks the connection with collaborative server', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:4444/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
let webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain(
|
||||
@@ -103,7 +99,7 @@ test.describe('Doc Editor', () => {
|
||||
const wsClose = await wsClosePromise;
|
||||
expect(wsClose.isClosed()).toBeTruthy();
|
||||
|
||||
// Check the ws is connected again
|
||||
// Checkt the ws is connected again
|
||||
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
@@ -130,7 +126,7 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor.getByText('[test markdown]')).toBeVisible();
|
||||
|
||||
await editor.getByText('[test markdown]').selectText();
|
||||
await editor.getByText('[test markdown]').dblclick();
|
||||
await page.locator('button[data-test="convertMarkdown"]').click();
|
||||
|
||||
await expect(editor.getByText('[test markdown]')).toBeHidden();
|
||||
@@ -223,8 +219,11 @@ test.describe('Doc Editor', () => {
|
||||
await editor.fill('Hello World Doc persisted 2');
|
||||
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
|
||||
const urlDoc = page.url();
|
||||
await page.goto(urlDoc);
|
||||
await page.goto('/');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: doc,
|
||||
});
|
||||
|
||||
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
});
|
||||
@@ -298,7 +297,7 @@ test.describe('Doc Editor', () => {
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').selectText();
|
||||
await editor.getByText('Hello').dblclick();
|
||||
|
||||
await page.getByRole('button', { name: 'AI' }).click();
|
||||
|
||||
@@ -339,7 +338,6 @@ test.describe('Doc Editor', () => {
|
||||
].forEach(({ ai_transform, ai_translate }) => {
|
||||
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await mockedDocument(page, {
|
||||
accesses: [
|
||||
@@ -366,22 +364,16 @@ test.describe('Doc Editor', () => {
|
||||
link_reach: 'public',
|
||||
link_role: 'editor',
|
||||
created_at: '2021-09-01T09:00:00Z',
|
||||
title: '',
|
||||
});
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-editor-ai',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await goToGridDoc(page);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').selectText();
|
||||
await editor.getByText('Hello').dblclick();
|
||||
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
|
||||
@@ -190,7 +190,7 @@ test.describe('Document grid item options', () => {
|
||||
|
||||
test.describe('Documents filters', () => {
|
||||
test('it checks the prebuild left panel filters', async ({ page }) => {
|
||||
void page.goto('/');
|
||||
await page.goto('/');
|
||||
|
||||
// All Docs
|
||||
const response = await page.waitForResponse(
|
||||
@@ -263,7 +263,7 @@ test.describe('Documents filters', () => {
|
||||
|
||||
test.describe('Documents Grid', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
void page.goto('/');
|
||||
await page.goto('/');
|
||||
|
||||
let docs: SmallDoc[] = [];
|
||||
const response = await page.waitForResponse(
|
||||
|
||||
@@ -86,7 +86,7 @@ test.describe('Document search', () => {
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello world');
|
||||
await editor.getByText('Hello world').selectText();
|
||||
await editor.getByText('Hello world').dblclick();
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(page.getByRole('textbox', { name: 'Edit URL' })).toBeVisible();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { CONFIG } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/docs/');
|
||||
});
|
||||
@@ -52,27 +50,4 @@ test.describe('Home page', () => {
|
||||
|
||||
await expect(footer).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks the homepage feature flag', 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_HOMEPAGE_FEATURE_ENABLED: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Keyclock login page
|
||||
await expect(
|
||||
page.locator('.login-pf-page-header').getByText('impress'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "3.1.0",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -9,7 +9,6 @@ const createJestConfig = nextJest({
|
||||
const config: Config = {
|
||||
coverageProvider: 'v8',
|
||||
moduleNameMapper: {
|
||||
'^@/docs/(.*)$': '<rootDir>/src/features/docs/$1',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "3.1.0",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -30,7 +30,6 @@
|
||||
"@sentry/nextjs": "9.3.0",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.1.1",
|
||||
@@ -39,7 +38,7 @@
|
||||
"idb": "8.0.2",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "15.2.4",
|
||||
"next": "15.2.3",
|
||||
"posthog-js": "1.227.0",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.6.0",
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
{
|
||||
"default": {
|
||||
"externalLinks": [
|
||||
{
|
||||
"label": "Github",
|
||||
"href": "https://github.com/suitenumerique/docs/"
|
||||
},
|
||||
{
|
||||
"label": "DINUM",
|
||||
"href": "https://www.numerique.gouv.fr/dinum/"
|
||||
},
|
||||
{
|
||||
"label": "ZenDiS",
|
||||
"href": "https://zendis.de/"
|
||||
},
|
||||
{
|
||||
"label": "BlockNote.js",
|
||||
"href": "https://www.blocknotejs.org/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Legal Notice",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Personal data and cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Mentions légales",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Données personnelles et cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Accessibilité",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Impressum",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Personenbezogene Daten und Cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Barrierefreiheit",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Wettelijke bepalingen",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Persoonlijke gegevens en cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Toegankelijkheid",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
{
|
||||
"default": {
|
||||
"externalLinks": [
|
||||
{
|
||||
"label": "legifrance.gouv.fr",
|
||||
"href": "https://legifrance.gouv.fr/"
|
||||
},
|
||||
{
|
||||
"label": "info.gouv.fr",
|
||||
"href": "https://info.gouv.fr/"
|
||||
},
|
||||
{
|
||||
"label": "service-public.fr",
|
||||
"href": "https://service-public.fr/"
|
||||
},
|
||||
{
|
||||
"label": "data.gouv.fr",
|
||||
"href": "https://data.gouv.fr/"
|
||||
}
|
||||
],
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Legal Notice",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Personal data and cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Legal Notice",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Personal data and cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Mentions légales",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Données personnelles et cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Accessibilité",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Impressum",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Personenbezogene Daten und Cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Barrierefreiheit",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Wettelijke bepalingen",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Persoonlijke gegevens en cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Toegankelijkheid",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 992 B |
@@ -24,7 +24,6 @@ export interface BoxProps {
|
||||
$hasTransition?: boolean | 'slow';
|
||||
$height?: CSSProperties['height'];
|
||||
$justify?: CSSProperties['justifyContent'];
|
||||
$opacity?: CSSProperties['opacity'];
|
||||
$overflow?: CSSProperties['overflow'];
|
||||
$margin?: MarginPadding;
|
||||
$maxHeight?: CSSProperties['maxHeight'];
|
||||
@@ -66,7 +65,6 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight};`}
|
||||
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
|
||||
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
|
||||
${({ $opacity }) => $opacity && `opacity: ${$opacity};`}
|
||||
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
|
||||
${({ $padding }) => $padding && stylesPadding($padding)}
|
||||
${({ $position }) => $position && `position: ${$position};`}
|
||||
|
||||
@@ -44,7 +44,6 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
||||
${$css || ''}
|
||||
`}
|
||||
{...props}
|
||||
className={`--docs--box-button ${props.className || ''}`}
|
||||
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
|
||||
@@ -14,7 +14,6 @@ export const Card = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={`--docs--card ${props.className || ''}`}
|
||||
$background="white"
|
||||
$radius="4px"
|
||||
$css={css`
|
||||
|
||||
@@ -71,7 +71,6 @@ export const DropButton = ({
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
aria-label={label}
|
||||
$css={buttonCss}
|
||||
className="--docs--drop-button"
|
||||
>
|
||||
{button}
|
||||
</StyledButton>
|
||||
@@ -80,7 +79,6 @@ export const DropButton = ({
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
className="--docs--drop-button-popover"
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
import clsx from 'clsx';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Text, TextType } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
type IconProps = TextType & {
|
||||
iconName: string;
|
||||
variant?: 'filled' | 'outlined';
|
||||
};
|
||||
export const Icon = ({
|
||||
iconName,
|
||||
variant = 'outlined',
|
||||
...textProps
|
||||
}: IconProps) => {
|
||||
export const Icon = ({ iconName, ...textProps }: IconProps) => {
|
||||
return (
|
||||
<Text $isMaterialIcon {...textProps}>
|
||||
{iconName}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface IconBGProps extends TextType {
|
||||
iconName: string;
|
||||
}
|
||||
|
||||
export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Text
|
||||
$isMaterialIcon
|
||||
$size="36px"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$background={colorsTokens()['primary-bg']}
|
||||
$css={`
|
||||
border: 1px solid ${colorsTokens()['primary-200']};
|
||||
user-select: none;
|
||||
`}
|
||||
$radius="12px"
|
||||
$padding="4px"
|
||||
$margin="auto"
|
||||
{...textProps}
|
||||
className={clsx('--docs--icon-bg', textProps.className, {
|
||||
'material-icons-filled': variant === 'filled',
|
||||
'material-icons': variant === 'outlined',
|
||||
})}
|
||||
>
|
||||
{iconName}
|
||||
</Text>
|
||||
@@ -31,13 +48,15 @@ type IconOptionsProps = TextType & {
|
||||
|
||||
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
|
||||
return (
|
||||
<Icon
|
||||
<Text
|
||||
{...props}
|
||||
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
|
||||
$isMaterialIcon
|
||||
$css={css`
|
||||
user-select: none;
|
||||
${props.$css}
|
||||
`}
|
||||
/>
|
||||
>
|
||||
{isHorizontal ? 'more_horiz' : 'more_vert'}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,10 +30,7 @@ export const InfiniteScroll = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
className={`--docs--infinite-scroll ${boxProps.className || ''}`}
|
||||
>
|
||||
<Box {...boxProps}>
|
||||
{children}
|
||||
<InView onChange={loadMore}>
|
||||
{!isLoading && hasMore && (
|
||||
|
||||
@@ -20,7 +20,6 @@ export const LoadMoreText = ({
|
||||
$align="center"
|
||||
$gap="0.4rem"
|
||||
$padding={{ horizontal: '2xs', vertical: 'sm' }}
|
||||
className="--docs--load-more"
|
||||
>
|
||||
<Icon
|
||||
$theme="primary"
|
||||
|
||||
@@ -11,6 +11,7 @@ type TextSizes = keyof typeof sizes;
|
||||
export interface TextProps extends BoxProps {
|
||||
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
$elipsis?: boolean;
|
||||
$isMaterialIcon?: boolean;
|
||||
$weight?: CSSProperties['fontWeight'];
|
||||
$textAlign?: CSSProperties['textAlign'];
|
||||
$size?: TextSizes | (string & {});
|
||||
@@ -56,14 +57,14 @@ export const TextStyled = styled(Box)<TextProps>`
|
||||
`;
|
||||
|
||||
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
|
||||
({ className, ...props }, ref) => {
|
||||
({ className, $isMaterialIcon, ...props }, ref) => {
|
||||
return (
|
||||
<TextStyled
|
||||
ref={ref}
|
||||
as="span"
|
||||
$theme="greyscale"
|
||||
$variation="text"
|
||||
className={className}
|
||||
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,12 +28,7 @@ export const TextErrors = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AlertStyled
|
||||
canClose={canClose}
|
||||
type={VariantType.ERROR}
|
||||
icon={icon}
|
||||
className="--docs--text-errors"
|
||||
>
|
||||
<AlertStyled canClose={canClose} type={VariantType.ERROR} icon={icon}>
|
||||
<Box $direction="column" $gap="0.2rem">
|
||||
{causes &&
|
||||
causes.map((cause, i) => (
|
||||
|
||||
@@ -119,6 +119,8 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.c__modal__scroller:has(.quick-search-container),
|
||||
.c__modal__scroller:has(.noPadding) {
|
||||
padding: 0 !important;
|
||||
@@ -136,4 +138,6 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
`;
|
||||
|
||||
@@ -28,7 +28,6 @@ export const HorizontalSeparator = ({
|
||||
? '#e5e5e533'
|
||||
: colorsTokens()['greyscale-100']
|
||||
}
|
||||
className="--docs--horizontal-separator"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import { PropsWithChildren, useEffect } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
@@ -55,17 +54,10 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{conf?.FRONTEND_CSS_URL && (
|
||||
<Head>
|
||||
<link rel="stylesheet" href={conf?.FRONTEND_CSS_URL} />
|
||||
</Head>
|
||||
)}
|
||||
<AnalyticsProvider>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
</CrispProvider>
|
||||
</AnalyticsProvider>
|
||||
</>
|
||||
<AnalyticsProvider>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
</CrispProvider>
|
||||
</AnalyticsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,15 +5,12 @@ import { Theme } from '@/cunningham/';
|
||||
import { PostHogConf } from '@/services';
|
||||
|
||||
interface ConfigResponse {
|
||||
AI_FEATURE_ENABLED?: boolean;
|
||||
COLLABORATION_WS_URL?: string;
|
||||
CRISP_WEBSITE_ID?: string;
|
||||
ENVIRONMENT: string;
|
||||
FRONTEND_CSS_URL?: string;
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
|
||||
FRONTEND_THEME?: Theme;
|
||||
LANGUAGES: [string, string][];
|
||||
LANGUAGE_CODE: string;
|
||||
ENVIRONMENT: string;
|
||||
COLLABORATION_WS_URL?: string;
|
||||
CRISP_WEBSITE_ID?: string;
|
||||
FRONTEND_THEME?: Theme;
|
||||
MEDIA_BASE_URL?: string;
|
||||
POSTHOG_KEY?: PostHogConf;
|
||||
SENTRY_DSN?: string;
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
@import url('@gouvfr-lasuite/ui-kit/style');
|
||||
@import url('./cunningham-tokens.css');
|
||||
|
||||
:root {
|
||||
/**
|
||||
* Input
|
||||
@@ -36,10 +33,3 @@
|
||||
--c--components--button--border-radius
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip
|
||||
*/
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
@@ -3,16 +3,14 @@ import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
|
||||
import { useAuth } from '../hooks';
|
||||
import { getAuthUrl, gotoLogin } from '../utils';
|
||||
import { getAuthUrl } from '../utils';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
|
||||
useAuth();
|
||||
const { replace, pathname } = useRouter();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
if (isLoading && !isFetchedAfterMount) {
|
||||
return (
|
||||
@@ -42,11 +40,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
|
||||
*/
|
||||
if (!authenticated && !pathAllowed) {
|
||||
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
|
||||
void replace('/home');
|
||||
} else {
|
||||
gotoLogin();
|
||||
}
|
||||
void replace('/login');
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
@@ -55,9 +49,9 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is authenticated and the path is the home page, we redirect to the index.
|
||||
* If the user is authenticated and the path is the login page, we redirect to the home page.
|
||||
*/
|
||||
if (pathname === '/home' && authenticated) {
|
||||
if (pathname === '/login' && authenticated) {
|
||||
void replace('/');
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
|
||||
@@ -18,7 +18,6 @@ export const ButtonLogin = () => {
|
||||
onClick={() => gotoLogin()}
|
||||
color="primary-text"
|
||||
aria-label={t('Login')}
|
||||
className="--docs--button-login"
|
||||
>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
@@ -26,12 +25,7 @@ export const ButtonLogin = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={gotoLogout}
|
||||
color="primary-text"
|
||||
aria-label={t('Logout')}
|
||||
className="--docs--button-logout"
|
||||
>
|
||||
<Button onClick={gotoLogout} color="primary-text" aria-label={t('Logout')}>
|
||||
{t('Logout')}
|
||||
</Button>
|
||||
);
|
||||
@@ -51,7 +45,6 @@ export const ProConnectButton = () => {
|
||||
}
|
||||
`}
|
||||
$radius="4px"
|
||||
className="--docs--proconnect-button"
|
||||
>
|
||||
<ProConnectImg />
|
||||
</BoxButton>
|
||||
|
||||
@@ -133,7 +133,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
$padding={{ top: 'md' }}
|
||||
$background="white"
|
||||
$css={cssEditor(readOnly)}
|
||||
className="--docs--editor-container"
|
||||
>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
@@ -193,7 +192,7 @@ export const BlockNoteEditorVersion = ({
|
||||
}, [setEditor, editor]);
|
||||
|
||||
return (
|
||||
<Box $css={cssEditor(readOnly)} className="--docs--editor-container">
|
||||
<Box $css={cssEditor(readOnly)}>
|
||||
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isAPIError } from '@/api';
|
||||
import { Box, Icon } from '@/components';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useDocOptions, useDocStore } from '@/docs/doc-management/';
|
||||
|
||||
import {
|
||||
@@ -104,15 +104,19 @@ export function AIGroupButton() {
|
||||
<Components.Generic.Menu.Root>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button bn-menu-item --docs--ai-actions-menu-trigger"
|
||||
className="bn-button bn-menu-item"
|
||||
data-test="ai-actions"
|
||||
label="AI"
|
||||
mainTooltip={t('AI Actions')}
|
||||
icon={<Icon iconName="auto_awesome" $size="l" />}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="l">
|
||||
auto_awesome
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
className="bn-menu-dropdown bn-drag-handle-menu --docs--ai-actions-menu"
|
||||
className="bn-menu-dropdown bn-drag-handle-menu"
|
||||
sub={true}
|
||||
>
|
||||
{canAITransform && (
|
||||
@@ -120,42 +124,66 @@ export function AIGroupButton() {
|
||||
<AIMenuItemTransform
|
||||
action="prompt"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="text_fields" $size="s" />}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
text_fields
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Use as prompt')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="rephrase"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="refresh" $size="s" />}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
refresh
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Rephrase')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="summarize"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="summarize" $size="s" />}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
summarize
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Summarize')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="correct"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="check" $size="s" />}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
check
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Correct')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="beautify"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="draw" $size="s" />}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
draw
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Beautify')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="emojify"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="emoji_emotions" $size="s" />}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
emoji_emotions
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Emojify')}
|
||||
</AIMenuItemTransform>
|
||||
@@ -165,18 +193,20 @@ export function AIGroupButton() {
|
||||
<Components.Generic.Menu.Root position="right" sub={true}>
|
||||
<Components.Generic.Menu.Trigger sub={false}>
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item --docs--ai-translate-menu-trigger"
|
||||
className="bn-menu-item"
|
||||
subTrigger={true}
|
||||
>
|
||||
<Box $direction="row" $gap="0.6rem">
|
||||
<Icon iconName="translate" $size="s" />
|
||||
<Text $isMaterialIcon $size="s">
|
||||
translate
|
||||
</Text>
|
||||
{t('Language')}
|
||||
</Box>
|
||||
</Components.Generic.Menu.Item>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
sub={true}
|
||||
className="bn-menu-dropdown --docs--ai-translate-menu"
|
||||
className="bn-menu-dropdown"
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<AIMenuItemTranslate
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
import React, { JSX, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConfig } from '@/core/config/api';
|
||||
|
||||
import { getQuoteFormattingToolbarItems } from '../custom-blocks';
|
||||
|
||||
import { AIGroupButton } from './AIButton';
|
||||
@@ -22,7 +20,6 @@ export const BlockNoteToolbar = () => {
|
||||
const [confirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
|
||||
const { t } = useTranslation();
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
const toolbarItems = useMemo(() => {
|
||||
const toolbarItems = getFormattingToolbarItems([
|
||||
@@ -59,13 +56,13 @@ export const BlockNoteToolbar = () => {
|
||||
{toolbarItems}
|
||||
|
||||
{/* Extra button to do some AI powered actions */}
|
||||
{conf?.AI_FEATURE_ENABLED && <AIGroupButton key="AIButton" />}
|
||||
<AIGroupButton key="AIButton" />
|
||||
|
||||
{/* Extra button to convert from markdown to json */}
|
||||
<MarkdownButton key="customButton" />
|
||||
</FormattingToolbar>
|
||||
);
|
||||
}, [toolbarItems, conf?.AI_FEATURE_ENABLED]);
|
||||
}, [toolbarItems]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -94,7 +94,7 @@ export const FileDownloadButton = ({
|
||||
return (
|
||||
<>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button --docs--editor-file-download-button"
|
||||
className="bn-button"
|
||||
label={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
|
||||
@@ -82,7 +82,6 @@ export function MarkdownButton() {
|
||||
<Components.FormattingToolbar.Button
|
||||
mainTooltip={t('Convert Markdown')}
|
||||
onClick={handleConvertMarkdown}
|
||||
className="--docs--editor-markdown-button"
|
||||
>
|
||||
M
|
||||
</Components.FormattingToolbar.Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { Box, Text } from '@/components';
|
||||
|
||||
interface ModalConfirmDownloadUnsafeProps {
|
||||
onClose: () => void;
|
||||
@@ -52,15 +52,14 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
$variation="1000"
|
||||
$direction="row"
|
||||
>
|
||||
<Icon iconName="warning" $theme="warning" />
|
||||
<Text $isMaterialIcon $theme="warning">
|
||||
warning
|
||||
</Text>
|
||||
{t('Warning')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Modal confirmation to download the attachment')}
|
||||
className="--docs--modal-confirm-download-unsafe"
|
||||
>
|
||||
<Box aria-label={t('Modal confirmation to download the attachment')}>
|
||||
<Box>
|
||||
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
|
||||
<Text $variation="700">{t('This file is flagged as unsafe.')}</Text>
|
||||
|
||||
@@ -49,16 +49,8 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
<TableContent />
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$maxWidth="868px"
|
||||
$width="100%"
|
||||
$height="100%"
|
||||
className="--docs--doc-editor"
|
||||
>
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
|
||||
className="--docs--doc-editor-header"
|
||||
>
|
||||
<Box $maxWidth="868px" $width="100%" $height="100%">
|
||||
<Box $padding={{ horizontal: isDesktop ? '54px' : 'base' }}>
|
||||
{isVersion ? (
|
||||
<DocVersionHeader title={doc.title} />
|
||||
) : (
|
||||
@@ -72,7 +64,6 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
$width="100%"
|
||||
$css="overflow-x: clip; flex: 1;"
|
||||
$position="relative"
|
||||
className="--docs--doc-editor-content"
|
||||
>
|
||||
<Box $css="flex:1;" $position="relative" $width="100%">
|
||||
{isVersion ? (
|
||||
@@ -124,7 +115,7 @@ export const DocVersionEditor = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box $margin="large" className="--docs--doc-version-editor-error">
|
||||
<Box $margin="large">
|
||||
<TextErrors
|
||||
causes={error.cause}
|
||||
icon={
|
||||
|
||||
@@ -2,7 +2,7 @@ import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
@@ -45,7 +45,11 @@ export const getDividerReactSlashMenuItems = (
|
||||
},
|
||||
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
|
||||
group,
|
||||
icon: <Icon iconName="remove" $size="18px" />,
|
||||
icon: (
|
||||
<Text $isMaterialIcon $size="18px">
|
||||
remove
|
||||
</Text>
|
||||
),
|
||||
subtext: t('Add a horizontal line'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
@@ -53,7 +54,11 @@ export const getQuoteReactSlashMenuItems = (
|
||||
},
|
||||
aliases: ['quote', 'blockquote', 'citation'],
|
||||
group,
|
||||
icon: <Icon iconName="format_quote" $size="18px" />,
|
||||
icon: (
|
||||
<Text $isMaterialIcon $size="18px">
|
||||
format_quote
|
||||
</Text>
|
||||
),
|
||||
subtext: t('Add a quote block'),
|
||||
},
|
||||
];
|
||||
@@ -63,6 +68,10 @@ export const getQuoteFormattingToolbarItems = (
|
||||
): BlockTypeSelectItem => ({
|
||||
name: t('Quote'),
|
||||
type: 'quote',
|
||||
icon: () => <Icon iconName="format_quote" $size="16px" />,
|
||||
icon: () => (
|
||||
<Text $isMaterialIcon $size="16px">
|
||||
format_quote
|
||||
</Text>
|
||||
),
|
||||
isSelected: (block) => block.type === 'quote',
|
||||
});
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { useRouter } from 'next/router';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import useSaveDoc from '../useSaveDoc';
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/docs/doc-versioning', () => ({
|
||||
KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions',
|
||||
}));
|
||||
|
||||
jest.mock('@/docs/doc-management', () => ({
|
||||
useUpdateDoc: jest.requireActual('@/docs/doc-management/api/useUpdateDoc')
|
||||
.useUpdateDoc,
|
||||
}));
|
||||
|
||||
describe('useSaveDoc', () => {
|
||||
const mockRouterEvents = {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
|
||||
(useRouter as jest.Mock).mockReturnValue({
|
||||
events: mockRouterEvents,
|
||||
});
|
||||
});
|
||||
|
||||
it('should setup event listeners on mount', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const docId = 'test-doc-id';
|
||||
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
// Verify router event listeners are set up
|
||||
expect(mockRouterEvents.on).toHaveBeenCalledWith(
|
||||
'routeChangeStart',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// Verify window event listener is set up
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
addEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not save when canSave is false', async () => {
|
||||
jest.useFakeTimers();
|
||||
const yDoc = new Y.Doc();
|
||||
const docId = 'test-doc-id';
|
||||
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
content: 'test-content',
|
||||
title: 'test-title',
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, false), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Trigger a local update
|
||||
yDoc.getMap('test').set('key', 'value');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Now advance timers after state has updated
|
||||
jest.advanceTimersByTime(61000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(0);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should save when there are local changes', async () => {
|
||||
jest.useFakeTimers();
|
||||
const yDoc = new Y.Doc();
|
||||
const docId = 'test-doc-id';
|
||||
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
content: 'test-content',
|
||||
title: 'test-title',
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Trigger a local update
|
||||
yDoc.getMap('test').set('key', 'value');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Now advance timers after state has updated
|
||||
jest.advanceTimersByTime(61000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.lastCall()?.[0]).toBe(
|
||||
'http://test.jest/api/v1.0/documents/test-doc-id/',
|
||||
);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not save when there are no local changes', async () => {
|
||||
jest.useFakeTimers();
|
||||
const yDoc = new Y.Doc();
|
||||
const docId = 'test-doc-id';
|
||||
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
content: 'test-content',
|
||||
title: 'test-title',
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Now advance timers after state has updated
|
||||
jest.advanceTimersByTime(61000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(0);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should cleanup event listeners on unmount', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const docId = 'test-doc-id';
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
// Verify router event listeners are cleaned up
|
||||
expect(mockRouterEvents.off).toHaveBeenCalledWith(
|
||||
'routeChangeStart',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// Verify window event listener is cleaned up
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||
@@ -8,16 +8,17 @@ import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
import { toBase64 } from '../utils';
|
||||
|
||||
const SAVE_INTERVAL = 60000;
|
||||
|
||||
const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
|
||||
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
onSuccess: () => {
|
||||
setIsLocalChange(false);
|
||||
},
|
||||
});
|
||||
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
|
||||
const [initialDoc, setInitialDoc] = useState<string>(
|
||||
toBase64(Y.encodeStateAsUpdate(doc)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInitialDoc(toBase64(Y.encodeStateAsUpdate(doc)));
|
||||
}, [doc]);
|
||||
|
||||
/**
|
||||
* Update initial doc when doc is updated by other users,
|
||||
@@ -28,37 +29,57 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
|
||||
const onUpdate = (
|
||||
_uintArray: Uint8Array,
|
||||
_pluginKey: string,
|
||||
_updatedDoc: Y.Doc,
|
||||
updatedDoc: Y.Doc,
|
||||
transaction: Y.Transaction,
|
||||
) => {
|
||||
setIsLocalChange(transaction.local ? true : false);
|
||||
if (!transaction.local) {
|
||||
setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc)));
|
||||
}
|
||||
};
|
||||
|
||||
yDoc.on('update', onUpdate);
|
||||
doc.on('update', onUpdate);
|
||||
|
||||
return () => {
|
||||
yDoc.off('update', onUpdate);
|
||||
doc.off('update', onUpdate);
|
||||
};
|
||||
}, [yDoc]);
|
||||
}, [doc]);
|
||||
|
||||
/**
|
||||
* Check if the doc has been updated and can be saved.
|
||||
*/
|
||||
const hasChanged = useCallback(() => {
|
||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||
return initialDoc !== newDoc;
|
||||
}, [doc, initialDoc]);
|
||||
|
||||
const shouldSave = useCallback(() => {
|
||||
return hasChanged() && canSave;
|
||||
}, [canSave, hasChanged]);
|
||||
|
||||
const saveDoc = useCallback(() => {
|
||||
if (!canSave || !isLocalChange) {
|
||||
return false;
|
||||
}
|
||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||
setInitialDoc(newDoc);
|
||||
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
|
||||
content: newDoc,
|
||||
});
|
||||
}, [doc, docId, updateDoc]);
|
||||
|
||||
return true;
|
||||
}, [canSave, yDoc, docId, isLocalChange, updateDoc]);
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
const onSave = (e?: Event) => {
|
||||
const isSaving = saveDoc();
|
||||
if (!shouldSave()) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveDoc();
|
||||
|
||||
/**
|
||||
* Firefox does not trigger the request everytime the user leaves the page.
|
||||
@@ -67,30 +88,27 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
|
||||
* if he wants to leave the page, by adding the popup, we let the time to the
|
||||
* request to be sent, and intercepted by the service worker (for the offline part).
|
||||
*/
|
||||
if (
|
||||
isSaving &&
|
||||
typeof e !== 'undefined' &&
|
||||
e.preventDefault &&
|
||||
isFirefox()
|
||||
) {
|
||||
if (typeof e !== 'undefined' && e.preventDefault && isFirefox()) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Save every minute
|
||||
const timeout = setInterval(onSave, SAVE_INTERVAL);
|
||||
timeout.current = setInterval(onSave, 60000);
|
||||
// Save when the user leaves the page
|
||||
addEventListener('beforeunload', onSave);
|
||||
// Save when the user navigates to another page
|
||||
router.events.on('routeChangeStart', onSave);
|
||||
|
||||
return () => {
|
||||
clearInterval(timeout);
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
removeEventListener('beforeunload', onSave);
|
||||
router.events.off('routeChangeStart', onSave);
|
||||
};
|
||||
}, [router.events, saveDoc]);
|
||||
}, [router.events, saveDoc, shouldSave]);
|
||||
};
|
||||
|
||||
export default useSaveDoc;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import _ from 'lodash';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { DocsBlockNoteEditor, HeadingBlock } from '../types';
|
||||
@@ -25,7 +24,7 @@ export interface UseHeadingStore {
|
||||
resetHeadings: () => void;
|
||||
}
|
||||
|
||||
export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
|
||||
export const useHeadingStore = create<UseHeadingStore>((set) => ({
|
||||
headings: [],
|
||||
setHeadings: (editor) => {
|
||||
const headingBlocks = editor?.document
|
||||
@@ -37,9 +36,7 @@ export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
|
||||
),
|
||||
})) as unknown as HeadingBlock[];
|
||||
|
||||
if (!_.isEqual(get().headings, headingBlocks)) {
|
||||
set(() => ({ headings: headingBlocks }));
|
||||
}
|
||||
set(() => ({ headings: headingBlocks }));
|
||||
},
|
||||
resetHeadings: () => set(() => ({ headings: [] })),
|
||||
}));
|
||||
|
||||
@@ -105,12 +105,6 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
& .bn-inline-content {
|
||||
width: 100%;
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'] > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
& .bn-editor {
|
||||
|
||||
@@ -155,7 +155,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
$margin={{ bottom: 'xl' }}
|
||||
aria-label={t('Content modal to export the document')}
|
||||
$gap="1rem"
|
||||
className="--docs--modal-export-content"
|
||||
>
|
||||
<Text $variation="600" $size="sm">
|
||||
{t('Download your document in a .docx or .pdf format.')}
|
||||
|
||||
@@ -38,7 +38,6 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
$padding={{ top: isDesktop ? '4xl' : 'md' }}
|
||||
$gap={spacings['base']}
|
||||
aria-label={t('It is the card information about the document.')}
|
||||
className="--docs--doc-header"
|
||||
>
|
||||
{(docIsPublic || docIsAuth) && (
|
||||
<Box
|
||||
|
||||
@@ -107,7 +107,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
<Box
|
||||
as="span"
|
||||
role="textbox"
|
||||
className="--docs--doc-title-input"
|
||||
contentEditable
|
||||
defaultValue={titleDisplay || undefined}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
|
||||
@@ -176,7 +176,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
$align="center"
|
||||
$gap="0.5rem 1.5rem"
|
||||
$wrap={isSmallMobile ? 'wrap' : 'nowrap'}
|
||||
className="--docs--doc-toolbox"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
|
||||
@@ -22,7 +22,6 @@ export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {
|
||||
$padding={{ vertical: 'base' }}
|
||||
$gap={spacings['base']}
|
||||
aria-label={t('It is the document title')}
|
||||
className="--docs--doc-version-header"
|
||||
>
|
||||
<DocTitleText title={title} />
|
||||
<HorizontalSeparator />
|
||||
|
||||
@@ -84,10 +84,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Content modal to delete document')}
|
||||
className="--docs--modal-remove-doc"
|
||||
>
|
||||
<Box aria-label={t('Content modal to delete document')}>
|
||||
{!isError && (
|
||||
<Text $size="sm" $variation="600">
|
||||
{t('Are you sure you want to delete this document ?')}
|
||||
|
||||
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 280 KiB |
@@ -11,11 +11,7 @@ type DocSearchItemProps = {
|
||||
export const DocSearchItem = ({ doc }: DocSearchItemProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
return (
|
||||
<Box
|
||||
data-testid={`doc-search-item-${doc.id}`}
|
||||
$width="100%"
|
||||
className="--docs--doc-search-item"
|
||||
>
|
||||
<Box data-testid={`doc-search-item-${doc.id}`} $width="100%">
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box $direction="row" $align="center" $gap="10px" $width="100%">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { Modal, ModalProps, ModalSize } from '@openfun/cunningham-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
@@ -19,10 +19,7 @@ import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
type DocSearchModalProps = {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
};
|
||||
type DocSearchModalProps = ModalProps & {};
|
||||
|
||||
export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -71,7 +68,6 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
aria-label={t('Search modal')}
|
||||
$direction="column"
|
||||
$justify="space-between"
|
||||
className="--docs--doc-search-modal"
|
||||
>
|
||||
<QuickSearch
|
||||
placeholder={t('Type the name of a document')}
|
||||
|
||||
@@ -122,7 +122,6 @@ export const DocShareAddMemberList = ({
|
||||
$css={css`
|
||||
border: 1px solid ${color['greyscale-200']};
|
||||
`}
|
||||
className="--docs--doc-share-add-member-list"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
|
||||
@@ -34,7 +34,6 @@ export const DocShareAddMemberListItem = ({ user, onRemoveUser }: Props) => {
|
||||
color: ${color['greyscale-1000']};
|
||||
font-size: ${fontSize['xs']};
|
||||
`}
|
||||
className="--docs--doc-share-add-member-list-item"
|
||||
>
|
||||
<Text $variation="1000" $size="xs">
|
||||
{user.full_name || user.email}
|
||||
|
||||
@@ -84,7 +84,6 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
||||
<Box
|
||||
$width="100%"
|
||||
data-testid={`doc-share-invitation-row-${invitation.email}`}
|
||||
className="--docs--doc-share-invitation-item"
|
||||
>
|
||||
<SearchUserRow
|
||||
isInvitation={true}
|
||||
|
||||
@@ -72,7 +72,6 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
|
||||
<Box
|
||||
$width="100%"
|
||||
data-testid={`doc-share-member-row-${access.user.email}`}
|
||||
className="--docs--doc-share-member-item"
|
||||
>
|
||||
<SearchUserRow
|
||||
alwaysShowRight={true}
|
||||
|
||||
@@ -195,7 +195,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
aria-label={t('Share modal')}
|
||||
$height={canViewAccesses ? modalContentHeight : 'auto'}
|
||||
$overflow="hidden"
|
||||
className="--docs--doc-share-modal noPadding "
|
||||
className="noPadding"
|
||||
$justify="space-between"
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -20,7 +20,6 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
|
||||
$css={css`
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
className="--docs--doc-share-modal-footer"
|
||||
>
|
||||
<HorizontalSeparator $withPadding={true} />
|
||||
|
||||
|
||||
@@ -12,11 +12,7 @@ type Props = {
|
||||
export const DocShareModalInviteUserRow = ({ user }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box
|
||||
$width="100%"
|
||||
data-testid={`search-user-row-${user.email}`}
|
||||
className="--docs--doc-share-modal-invite-user-row"
|
||||
>
|
||||
<Box $width="100%" data-testid={`search-user-row-${user.email}`}>
|
||||
<SearchUserRow
|
||||
user={user}
|
||||
right={
|
||||
|
||||
@@ -91,7 +91,6 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
$padding={{ horizontal: 'base' }}
|
||||
aria-label={t('Doc visibility card')}
|
||||
$gap={spacing['base']}
|
||||
className="--docs--doc-visibility"
|
||||
>
|
||||
<Text $weight="700" $size="sm" $variation="700">
|
||||
{t('Link parameters')}
|
||||
|
||||
@@ -31,12 +31,7 @@ export const SearchUserRow = ({
|
||||
right={right}
|
||||
alwaysShowRight={alwaysShowRight}
|
||||
left={
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacings['xs']}
|
||||
className="--docs--search-user-row"
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap={spacings['xs']}>
|
||||
<UserAvatar
|
||||
user={user}
|
||||
background={isInvitation ? colors['greyscale-400'] : undefined}
|
||||
|
||||
@@ -49,7 +49,6 @@ export const UserAvatar = ({ user, background }: Props) => {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
`}
|
||||
className="--docs--user-avatar"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
|
||||
@@ -60,7 +60,6 @@ export const Heading = ({
|
||||
$radius="4px"
|
||||
$background={isActive ? `${colorsTokens()['greyscale-100']}` : 'none'}
|
||||
$css="text-align: left;"
|
||||
className="--docs--table-content-heading"
|
||||
>
|
||||
<Text
|
||||
$width="100%"
|
||||
|
||||
@@ -122,7 +122,6 @@ export const TableContent = () => {
|
||||
gap: var(--c--theme--spacings--2xs);
|
||||
`}
|
||||
`}
|
||||
className="--docs--table-content"
|
||||
>
|
||||
{!isHover && (
|
||||
<BoxButton onClick={onOpen} $justify="center" $align="center">
|
||||
|
||||
@@ -107,10 +107,7 @@ export const ModalConfirmationVersion = ({
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Modal confirmation to restore the version')}
|
||||
className="--docs--modal-confirmation-version"
|
||||
>
|
||||
<Box aria-label={t('Modal confirmation to restore the version')}>
|
||||
<Box>
|
||||
<Text $variation="600">
|
||||
{t('Your current document will revert to this version.')}
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ModalSelectVersion = ({
|
||||
<NoPaddingStyle />
|
||||
<Box
|
||||
aria-label="version history modal"
|
||||
className="--docs--modal-select-version noPadding"
|
||||
className="noPadding"
|
||||
$direction="row"
|
||||
$height="100%"
|
||||
$maxHeight="calc(100vh - 2em - 12px)"
|
||||
|
||||
@@ -44,7 +44,6 @@ export const VersionItem = ({
|
||||
`}
|
||||
$hasTransition
|
||||
$minWidth="13rem"
|
||||
className="--docs--version-item"
|
||||
>
|
||||
<Box
|
||||
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
|
||||
|
||||