Compare commits

..

6 Commits

Author SHA1 Message Date
virgile-deville
c924706aa8 📝(doc) change documentation folder name
It was called docs which is confusing as it contain the code of the app

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-04-17 10:44:54 +02:00
Anthony LC
ecd06560c6 🚚(frontend) Display homepage on /home url
The homepage is now accessible at the /home URL.
Before the homepage was accessible on the /login URL.
We still keep the /login URL for backward compatibility.
2025-04-13 13:25:40 +02:00
Anthony LC
e9ab099ce0 🚩(frontend) integrate homepage feature flag
If the homepage feature flag is enabled,
the homepage will be displayed.
2025-04-13 13:25:40 +02:00
Anthony LC
67b69d05f7 🚩(backend) add homepage feature flag
Add a homepage feature flag that we will
propagate to the frontend.
It will be used to enable or disable the
homepage at runtime.
2025-04-13 13:25:40 +02:00
virgile-deville
f429eb053a 📝(readme) remove preprod account
Having a preprod account using a yopmail account was a security bad practice

Co-authored-by: Samuel Paccoud <sampaccoud@users.noreply.github.com>
2025-04-11 22:29:22 +02:00
Olivier Laurendeau
ad11b7f554 📝(docs) init architecture documentation
- Add docs about architecture
- Add ADR about the CRDT choice
2025-04-10 15:15:15 +02:00
30 changed files with 313 additions and 44 deletions

View File

@@ -7,6 +7,12 @@ 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

View File

@@ -1,6 +1,6 @@
<p align="center">
<a href="https://github.com/suitenumerique/docs">
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
<img alt="Docs" src="/documentation/assets/docs-logo-black-white.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="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
<img src="/documentation/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
## Why use Docs ❓
@@ -48,12 +48,7 @@ Docs is a collaborative text editor designed to address common challenges in kno
### Test it
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
```
Test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
### Run it locally
@@ -198,5 +193,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="/docs/assets/europe_opensource.png" width="50%"/>
<img src="/documentation/assets/europe_opensource.png" width="50%"/>
</p>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,193 @@
## 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

View File

@@ -0,0 +1,19 @@
## 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 5.8 MiB

View File

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -47,6 +47,11 @@ 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 | |

View File

@@ -64,5 +64,6 @@ 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

View File

@@ -1692,6 +1692,7 @@ class ConfigView(drf.views.APIView):
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_FOOTER_FEATURE_ENABLED",
"FRONTEND_THEME",
"MEDIA_BASE_URL",

View File

@@ -19,6 +19,7 @@ pytestmark = pytest.mark.django_db
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/",
@@ -41,6 +42,7 @@ def test_api_config(is_authenticated):
"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": [

View File

@@ -410,6 +410,11 @@ 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
)

View File

@@ -1,5 +1,26 @@
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,

View File

@@ -2,27 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
const config = {
AI_FEATURE_ENABLED: true,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_FOOTER_FEATURE_ENABLED: true,
FRONTEND_THEME: 'default',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'Français'],
['de-de', 'Deutsch'],
['nl-nl', 'Nederlands'],
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
SENTRY_DSN: null,
};
import { CONFIG, createDoc } from './common';
test.describe('Config', () => {
test('it checks the config api is called', async ({ page }) => {
@@ -36,7 +16,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 ({
@@ -47,7 +27,7 @@ test.describe('Config', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
...CONFIG,
SENTRY_DSN: 'https://sentry.io/123',
},
});
@@ -120,7 +100,7 @@ test.describe('Config', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
...CONFIG,
AI_FEATURE_ENABLED: false,
},
});
@@ -151,7 +131,7 @@ test.describe('Config', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
...CONFIG,
CRISP_WEBSITE_ID: '1234',
},
});
@@ -173,7 +153,7 @@ test.describe('Config', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
...CONFIG,
FRONTEND_CSS_URL: 'http://localhost:123465/css/style.css',
},
});

View File

@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { CONFIG } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/docs/');
});
@@ -50,4 +52,27 @@ 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();
});
});

View File

@@ -5,17 +5,18 @@ import { Theme } from '@/cunningham/';
import { PostHogConf } from '@/services';
interface ConfigResponse {
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
ENVIRONMENT: string;
AI_FEATURE_ENABLED?: boolean;
COLLABORATION_WS_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
ENVIRONMENT: string;
FRONTEND_CSS_URL?: string;
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
FRONTEND_THEME?: Theme;
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
MEDIA_BASE_URL?: string;
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
AI_FEATURE_ENABLED?: boolean;
}
export const getConfig = async (): Promise<ConfigResponse> => {

View File

@@ -3,14 +3,16 @@ import { useRouter } from 'next/router';
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { useConfig } from '@/core';
import { useAuth } from '../hooks';
import { getAuthUrl } from '../utils';
import { getAuthUrl, gotoLogin } 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 (
@@ -40,7 +42,11 @@ 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) {
void replace('/login');
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
void replace('/home');
} else {
gotoLogin();
}
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
@@ -49,9 +55,9 @@ export const Auth = ({ children }: PropsWithChildren) => {
}
/**
* If the user is authenticated and the path is the login page, we redirect to the home page.
* If the user is authenticated and the path is the home page, we redirect to the index.
*/
if (pathname === '/login' && authenticated) {
if (pathname === '/home' && authenticated) {
void replace('/');
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">

View File

@@ -0,0 +1,8 @@
import { HomeContent } from '@/features/home';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
return <HomeContent />;
};
export default Page;

View File

@@ -50,6 +50,7 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true
FRONTEND_FOOTER_FEATURE_ENABLED: true
FRONTEND_URL_JSON_FOOTER: https://impress.127.0.0.1.nip.io/contents/footer-demo.json
POSTGRES_DB: impress