Compare commits

...

52 Commits

Author SHA1 Message Date
rvveber ab74aeff5c fix: Ensure federated types are downloaded before type compiling/ type checking
- Adds a function to the webpack.config and documentation to make sure host types are downloaded to the plugin before the first type compile/ type check is happening

Avoids an error where on cold start the types were not yet downloaded but types were already checked.
2025-11-12 17:16:11 +01:00
rvveber 482975c2b5 refine documentation 2025-11-04 18:07:27 +01:00
rvveber b4afee1b8c refine documentation 2025-11-04 17:43:42 +01:00
rvveber a1cb4cee95 refine documentation 2025-11-04 17:34:11 +01:00
rvveber 17b668485d refine documentation 2025-11-04 13:57:06 +01:00
rvveber 5ce23661e6 update documentation 2025-11-04 13:46:44 +01:00
rvveber bc360f31fc Improved documentation 2025-11-04 13:32:36 +01:00
rvveber a174a00af8 improve documentation accuracy 2025-11-04 11:43:27 +01:00
rvveber f0f2ca5edd Integrates plugin system POC to test
... more fine grained commits later
2025-11-04 10:22:58 +01:00
Tom Kaltenbrunner dc25f3f39c 🚚(docker) use static path for certifi ca certificate
The certifi ca certificate is now stored under a static path
(/cert/cacert.pem) to avoid issues when python is upgraded and the path
to the certificate changes.
2025-10-08 12:50:31 +00:00
Cyril 529e7f1737 (frontend) reduce no access image size from 450 to 300
image now matches mockups (300x300px)

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-08 10:43:13 +02:00
Cyril 51c5c4ee63 (frontend) fix overlapping placeholders in multi-column layout
placeholders no longer overlap when adding 2–3 column, text wraps correctly

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-08 09:37:58 +02:00
Anthony LC 72f098c667 🐛(frontend) email case sensitive search modal
When a user was searching for an email in the share modal,
the search was case sensitive, so we were proposing
to send an invitation to a new user when in
fact the user was already registered.
The search is now case insensitive, so the only
choice is to add the existing user in the share list.
2025-10-07 14:51:46 +02:00
Manuel Raynaud 3b08ba4de1 ♻️(backend) force saving invitation email in lowercase
We want to enforce that invitation email are saved in lower case.
2025-10-07 14:51:46 +02:00
Manuel Raynaud 590b67fd71 🐛(backend) filter invitation with case insensitive email
A user can be invited and no control is made on the email case. Then,
when a new user is created, we are looking if there are pending
invitation and the lookup used is case sensitive. We change it using
__iexact which is case insensitive.
2025-10-07 14:51:45 +02:00
rouja b3980e7bf1 ♻️(ci) add feature app environment
Add a special environment to prepare feature app deployment
2025-10-07 09:42:34 +00:00
Anthony LC e3b2fdbdf5 🔥(mail) replace google font Roboto
Google fonts are not GDPR. We remove them and use
system fonts instead.
2025-10-07 09:06:39 +02:00
Anthony LC 314a7fa7b0 🍱(frontend) manage Inter font locally
We now use @fontsource-variable/inter package
instead of relying on a Google CDN.
2025-10-07 09:06:39 +02:00
Anthony LC 93227466d2 🍱(frontend) manage export emoji locally
We now use exported emoji from emoji-datasource-apple
package instead of relying on a CDN.
During a build or dev command, the emoji images
are copied from node_modules to the public assets
folder. They are not versionned.
2025-10-07 09:06:39 +02:00
Anthony LC db7ae350ec ♻️(frontend) Marianne single source of truth
We now use Marianne font from @gouvfr-lasuite/ui-kit
package instead of a versionned local copies.
This ensures we always use the latest version of
the font.
During a build or dev command, Marianne font files
are copied from node_modules to the public assets
folder. They can stop being versionned.
2025-10-06 18:54:20 +02:00
Anthony LC 236c8df5ae 🚨(eslint) add no-unnecessary-type-assertion lint rule
Add no-unnecessary-type-assertion rule to eslint
config to avoid automatically unnecessary type
assertions in the codebase.
2025-10-06 16:49:58 +02:00
Cyril ae1b05189e (frontend) fix attachment download filename
use the document title instead of the uuid when downloading attachments

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-06 16:49:58 +02:00
Cyril 431c331154 (frontend) exclude h4-h6 headings from table of contents
filters out h4-h6 so they no longer appear in the document outline

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-06 11:28:18 +02:00
Cyril 5184723862 (frontend) prevent readers from changing callout emoji
restrict callout emoji editing to maintain proper permissions

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-06 10:45:10 +02:00
virgile-dev ca10fb9a12 💬(copy) wrong permission mentionned on move doc (#1377)
## Purpose

To move a doc you have to be at least admin of the destination doc.
It was written editor in the alert banner.


## Proposal
Change editor for administrator

## External contributions

Thank you for your contribution! 🎉  

Please ensure the following items are checked before submitting your
pull request:
- [x] I have read and followed the [contributing
guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
- [x] I have read and agreed to the [Code of
Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
- [x] I have signed off my commits with `git commit --signoff` (DCO
compliance)
- [x] I have signed my commits with my SSH or GPG key (`git commit -S`)
- [x] My commit messages follow the required format: `<gitmoji>(type)
title description`
- [ ] I have added a changelog entry under `## [Unreleased]` section (if
noticeable change)
- [ ] I have added corresponding tests for new features or bug fixes (if
applicable)

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-10-03 14:16:43 +00:00
dependabot[bot] 59e875764c ⬆️(backend) bump django from 5.2.6 to 5.2.7
Upgrade to django 5.2.7
It is a security release with a fix for 2 CVE
https://docs.djangoproject.com/en/5.2/releases/5.2.7/
2025-10-03 08:29:34 +00:00
Anthony LC 7ed46ab225 🛂(frontend) block editing title when not allowed
We had a case where the title input was editable
even when the user did not have the right to
edit it because of websocket problem during
collaboration. We fixed this issue by checking
the collaboration status before allowing the
edition of the title.
2025-10-02 16:07:58 +02:00
Cyril 18f4ab880f (frontend) update labels and shared document icon accessibility
remove aria-labels from decorative icons and add sr-only text to shared doc icon

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-02 13:24:47 +02:00
Cyril e71c45077d (frontend) checked checkboxes: removing strikethrough
removing strikethrough

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-01 11:41:18 +02:00
Cyril 14c84f000e (frontend) add h1 for SR on 40X pages and remove alt texts
improves screen reader support on error pages by clarifying structure

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-30 08:57:30 +02:00
Cyril 6cc42636e5 (frontend) convert to figure/figcaption structure if caption exists
ensure  html structure by using figure/figcaption when captions are present

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-29 10:42:56 +02:00
Anthony LC cc4bed6f8e ♻️(frontend) add upload loader block
The way we were handling the antivirus upload loader
was not optimal, it didn't work well with the pdf
embed block. We created a dedicated upload loader
block, it will replace the previous implementation,
it is more Blocknote idiomatic and will work
better with any type of upload files.
2025-09-26 17:15:22 +02:00
dakshesh14 d8f90c04bd (frontend) add pdf blocks to the editor
Added pdf block in the editor.

Signed-off-by: dakshesh14 <65905942+dakshesh14@users.noreply.github.com>
2025-09-26 17:15:22 +02:00
Cyril 1fdf70bdcf (frontend) remove redundant aria-label on hidden icons and update tests
remove aria-label from aria-hidden icons and update tests with data-testid

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 13:19:08 +02:00
Cyril 8ab21ef00d (frontend) improve semantic structure and aria roles of leftpanel
use nav and appropriate aria attributes to enhance accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 12:19:47 +02:00
Cyril f337a2a8f2 (frontend) add default background to left panel for better a11y
defined a default background color to prevent issues with user stylesheets

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 11:40:49 +02:00
Cyril 3607faa475 (frontend) remove redundant aria-label to avoid over-accessibility
aria-label was removed because the visible span already provides the text

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-23 11:26:28 +02:00
Manuel Raynaud 0ea7dd727f 🔒️(frontend) update alpine packages in production image (#1425)
Force an update of installed package in the image used for the frontend
in production.
2025-09-23 09:21:01 +00:00
Anthony LC 6aca40a034 ⬆️(dependencies) Bump vite from 7.1.0 to 7.1.5
Bumps vite from 7.1.0 to 7.1.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md
- https://github.com/vitejs/vite/commits/v7.1.5/packages/vite

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-22 15:56:23 +02:00
Cyril ee3b05cb55 (frontend) improve NVDA navigation in DocShareModal
fix NVDA focus and announcement issues in search modal combobox

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-22 14:53:34 +02:00
Anthony LC c23ff546d8 🐛(frontend) scroll back to top when navigate to a document
When navigating to a new document, the scroll
position was preserved. This commit changes this
behavior to scroll back to the top of
the page when navigating to a new document.
2025-09-22 10:52:34 +02:00
Anthony LC a751f1255a ♻️(frontend) replace Arial font-family with token font
In some components, the Arial font was still used
because of a centering problem.
We removed all instances of Arial and replaced them
with the current font token, the centering problems
were fixed by adding "contain: content;" to the css.
2025-09-22 10:09:15 +02:00
Anthony LC 8ee50631f3 🍱(frontend) replace Marianne font
Some improvements has been made to the Marianne
fonts. We replace the previous one with the
newer version.
2025-09-22 10:09:14 +02:00
Cyril e5e5fba0b3 (frontend) hide decorative icons from assistive tech with aria-hidden
improves accessibility by reducing screen reader noise from icon elements

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-22 08:31:51 +02:00
Cyril 0894bcdca5 (docs) add title metadata to exported docx/pdf for accessibility
ensures document title is preserved in exports to meet accessibility needs

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-18 14:55:02 +02:00
Anthony LC 75da342058 🏷️(frontend) adapt types to link-configuration endpoint
The link-configuration endpoint has now a strict
validation schema about the combination of
link_reach and link_role.
We need to adapt our types
frontend side to reflect that.
2025-09-18 13:16:37 +02:00
Anthony LC 1ed01fd64b 🥅(backend) link role could be updated when restricted document
When a document was restricted, the link role could
be updated from "link-configuration" and gives a
200 response, but the change did not
have any effect because of a restriction in
LinkReachChoices.
We added a validation step to ensure that the
link role can only be updated if the document
is not restricted.
2025-09-18 12:17:08 +02:00
Anthony LC e4aa85be83 (e2e) fix flakiness
Some tests were getting very flaky due to previous
tests updates. This should fix it.
2025-09-18 11:28:20 +02:00
Anthony LC 2dc1e07b42 ⚗️(service-worker) remove index from cache first strategy
Some users reported that the app was giving a
blank page, it seems to happens often after a
release. It could be due to the fact that
the service worker is caching the index.html
file and not updating it properly after a new release.
We remove the index from the cache first strategy
to avoid this kind of issue. We set as well
the default handler with the "StaleWhileRevalidate"
strategy to force the cache to be updated in
the background.
2025-09-18 10:40:47 +02:00
Anthony LC fbdeb90113 🛂(frontend) invalidate doc query when lost connection
When the provider reports a lost connection,
we invalidate the doc query to refetch the document
data.
This ensures that if a user has lost is rights
to access a document, he will be redirected
to a 403 page without needing to refresh the page.
2025-09-17 17:45:26 +02:00
Anthony LC b773f09792 🥅(frontend) improve meta 401 page
Add better meta for the 401 page.
2025-09-17 17:45:26 +02:00
Anthony LC d8c9283dd1 🐛(frontend) fix 404 page when reload 403 page
When users were reloading a 403 page, they were
redirected to the 404 page because of Nextjs
routing mechanism. This commit fixes this issue by
removing the 403 page from the pages directory
and creating a component that is used directly
in the layout when a 403 error is detected.
2025-09-17 17:45:26 +02:00
135 changed files with 13190 additions and 844 deletions
+7 -7
View File
@@ -31,7 +31,7 @@ jobs:
images: lasuite/impress-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -49,7 +49,7 @@ jobs:
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -67,7 +67,7 @@ jobs:
images: lasuite/impress-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -88,7 +88,7 @@ jobs:
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -106,7 +106,7 @@ jobs:
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
@@ -122,7 +122,7 @@ jobs:
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -131,7 +131,7 @@ jobs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
+1
View File
@@ -79,6 +79,7 @@ jobs:
--check-filenames \
--ignore-words-list "Dokument,afterAll,excpt,statics" \
--skip "./git/" \
--skip "**/*.pdf" \
--skip "**/*.po" \
--skip "**/*.pot" \
--skip "**/*.json" \
+32 -14
View File
@@ -1,5 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
@@ -8,25 +6,46 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) add pdf block to the editor #1293
### Changed
- ♻️(frontend) replace Arial font-family with token font #1411
- ♿(frontend) improve accessibility:
- #1354
- ♿(frontend) enable enter key to open documentss #1354
- ♿(frontend) improve modal a11y: structure, labels, title #1349
- ♿improve NVDA navigation in DocShareModal #1396
- ♿ improve accessibility by adding landmark roles to layout #1394
- ♿ add document visible in list and openable via enter key #1365
- ♿ add pdf outline property to enable bookmarks display #1368
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
- ♿ remove redundant aria-label to avoid over-accessibility #1420
- ♿ remove redundant aria-label on hidden icons and update tests #1432
- ♿ improve semantic structure and aria roles of leftpanel #1431
- ♿ add default background to left panel for better accessibility #1423
- ♿ restyle checked checkboxes: removing strikethrough #1439
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
- ♿ update labels and shared document icon accessibility #1442
- 🍱(frontend) Fonts GDPR compliants #1453
### Fixed
- 🐛(backend) duplicate sub docs as root for reader users
### Changed
- (frontend) improve accessibility:
- ✨ add document visible in list and openable via enter key #1365
### Changed
- (frontend) improve accessibility:
- ♿ add pdf outline property to enable bookmarks display #1368
- ⚗️(service-worker) remove index from cache first strategy #1395
- 🐛(frontend) fix 404 page when reload 403 page #1402
- 🐛(frontend) fix legacy role computation #1376
- 🛂(frontend) block editing title when not allowed #1412
- 🐛(frontend) scroll back to top when navigate to a document #1406
- 🐛(frontend) fix export pdf emoji problem #1453
- 🐛(frontend) fix attachment download filename #1447
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
- 🔒(frontend) prevent readers from changing callout emoji #1449
- 🐛(frontend) fix overlapping placeholders in multi-column layout #1455
- 🐛(backend) filter invitation with case insensitive email
- 🐛(frontend) reduce no access image size from 450 to 300 #1463
## [3.7.0] - 2025-09-12
@@ -54,7 +73,6 @@ and this project adheres to
### Fixed
- 🐛(frontend) fix callout emoji list #1366
- 🐛(frontend) fix legacy role computation #1376
## [3.6.0] - 2025-09-04
+8
View File
@@ -94,6 +94,14 @@ RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
# when python is upgraded and the path to the certificate changes.
# The space between print and the ( is intended otherwise the git lint is failing
RUN mkdir /cert && \
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
+2
View File
@@ -99,6 +99,8 @@ These are the environment variables you can set for the `impress-backend` contai
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| PLUGINS_CONFIG_FILE_PATH | Full path to the JSON file containing the plugins configuration loaded by the backend. Example: src/backend/impress/configuration/plugins/default.json | BASE_DIR/impress/configuration/plugins/default.json |
| PLUGINS_CONFIG_CACHE_TIMEOUT | Time in seconds the plugins configuration file is cached by the backend before being reloaded. Default is 2 hours (7200 seconds). | 7200 |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
+1 -1
View File
@@ -82,7 +82,7 @@ backend:
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
mountPath: /cert/cacert.pem
subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
+589
View File
@@ -0,0 +1,589 @@
# Frontend Plugin System
## Table of Contents
- [Overview](#overview "Go to Overview section")
- [Getting Started: Building Your First Plugin](#getting-started-building-your-first-plugin "Go to the Getting Started guide")
- [1. Prepare the Host Environment](#1-prepare-the-host-environment "Step 1: Prepare the host")
- [2. Scaffolding a New Plugin Project](#2-scaffolding-a-new-plugin-project "Step 2: Scaffold the plugin")
- [3. Creating a Plugin Component](#3-creating-a-plugin-component "Step 3: Create the React component")
- [4. Federation Configuration](#4-federation-configuration "Step 4: Configure module federation")
- [5. Enabling Type-Sharing for Intellisense](#5-enabling-type-sharing-for-intellisense "Step 5: Enable type-sharing")
- [6. Running and Configuring Your Plugin](#6-running-and-configuring-your-plugin "Step 6: Run and configure")
- [Host-Plugin Interaction](#host-plugin-interaction "How the host and plugin interact")
- [Host Exports](#host-exports "What the host exports")
- [Choosing Shared Dependencies](#choosing-shared-dependencies "Learn about shared dependencies")
- [Development Workflow](#development-workflow "Go to Development Workflow section")
- [Test and Debug](#test-and-debug "How to test and debug")
- [Best Practices](#best-practices "View best practices")
- [Plugin Configuration File Reference](#plugin-configuration-file-reference "Go to the Configuration File reference")
- [Configuration Structure](#configuration-structure "See the config file structure")
- [Injection Position Examples](#injection-position-examples "See examples of injection positions")
- [Releasing a Plugin](#releasing-a-plugin "How to release a plugin")
- [Deploying Docs with Plugins](#deploying-docs-with-plugins "How to deploy plugins in production")
## Overview
The plugin system allows developers to extend the application's functionality and appearance without modifying the core.
It's ideal for teams or third parties to add custom features.
<br>
### Glossary
- **Remote**: An application exposing components via module federation.
- **Host**: The main entry point application. This is Docs itself ("impress").
- **Plugin**: A remote module integrated into the host to provide UI components.
- **Module Federation**: The technology that enables runtime module sharing between separate applications.
<br>
### Features and Limitations
**Features:**
- Add new UI components.
- Reuse host UI components.
- Dynamically inject components via CSS selectors and a [configuration file](#plugin-configuration-file-reference "See the configuration file reference").
- Integrate without rebuilding or redeploying the host application.
- Build and version plugins independently.
<br>
**Limitations:**
- Focused on DOM/UI customisations; you cannot add Next.js routes or other server-side features.
- Runs client-side without direct host state access. <br>
Shared caches (e.g., React Query) only work if the dependency is also [shared as a singleton](#choosing-shared-dependencies "Learn about shared dependencies").
- Host upgrades may require tweaking CSS selectors <br>
and matching versions for shared libraries.
<br>
## Getting Started: Building Your First Plugin
A plugin is a standalone React application bundled with Webpack <br>
that exposes one or more components via [Module Federation](#4-federation-configuration "See the federation configuration").
This guide walks you through creating your first plugin.
<br>
### 1. Prepare the Host Environment
Developing a plugin requires running the host application (Docs) in parallel.
This live integration is essential for rendering your plugin, enabling hot-reloading, sharing types for Intellisense, <br>
and discovering the exact versions of [shared dependencies](#choosing-shared-dependencies "Learn about shared dependencies").
<br>
1. **Clone the repository locally**:<br>
If you haven't already, clone the Docs repository to your local machine and follow the initial setup instructions.
2. **Set the development flag**:<br>
In the host application's `.env.development` file, set `NEXT_PUBLIC_DEVELOP_PLUGINS=true`.
3. **Stop conflicting services**:<br>
If you are using the project's Docker setup, make sure <br>
the frontend service is stopped (`docker compose stop frontend-development`), as we will run the Docs frontend locally.
4. **Run the host**:<br>
Navigate to `src/frontend/apps/impress`, run `yarn install`, and then `yarn dev`.
5. **Check the logs**:<br>
On startup, the Next.js dev server will print the versions of all shared singleton libraries (e.g., React, styled-components). <br>
You will need these exact versions for your plugin's `package.json`.
<br>
### 2. Scaffolding a New Plugin Project
Create a new, simple React project. <br>
Your project should have a [`webpack.config.js`](#4-federation-configuration "See the federation configuration") and include dependencies for React, Webpack, and TypeScript.
<br>
A minimal `package.json` would look like this:<br>
```json
{
"name": "my-plugin",
"version": "1.0.0",
"scripts": {
"dev": "webpack serve --mode=development",
"build": "webpack --mode=production"
},
"dependencies": {
"react": "<same as host>",
"react-dom": "<same as host>",
"styled-components": "<same as host>",
"@openfun/cunningham-react": "<same as host>",
"@tanstack/react-query": "<same as host>"
},
"devDependencies": {
"webpack": "^5.0.0",
"webpack-cli": "^5.0.0",
"webpack-dev-server": "^4.0.0",
"ts-loader": "^9.0.0",
"typescript": "^5.0.0",
"@types/react": "^18.0.0",
"@module-federation/native-federation-typescript": "^0.2.1"
}
}
```
> Replace `<same as host>` with the versions found in the [host's dev startup log](#1-prepare-the-host-environment "See how to prepare the host").
<br>
### 3\. Creating a Plugin Component
This is a React component that your `webpack.config.js` file exposes. <br>
This minimal example shows how to accept `props`, which can be passed from the [plugin configuration file](#plugin-configuration-file-reference "See the configuration file reference").
<br>
```typescript
// src/MyCustomComponent.tsx
import React from 'react';
// A simple component with inline prop types
const MyCustomComponent = ({ message }: { message?: string }) => {
return (
<div>
This is the plugin component.
{message && <p>Message from props: {message}</p>}
</div>
);
};
export default MyCustomComponent;
```
<br>
### 4\. Federation Configuration
The core of the plugin is its Webpack configuration. <br>
All plugins should use this `webpack.config.js` as a base.
Disclaimer:
We try to not change this file.<br>
But in the future, it may evolve as the plugin system matures.
<br>
```javascript
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const {
NativeFederationTypeScriptHost,
} = require('@module-federation/native-federation-typescript/webpack');
const {
NativeFederationTypeScriptHost: NativeFederationTypeScriptHostCore,
} = require('@module-federation/native-federation-typescript');
module.exports = (env, argv) => {
const dev = argv.mode !== 'production';
const moduleFederationConfig = {
name: 'my_plugin', // A unique name for your plugin
filename: 'remoteEntry.js',
exposes: {
// Maps a public name to a component file
'./MyCustomComponent': './src/MyCustomComponent.tsx',
},
remotes: {
// Allows importing from the host application. The URL is switched automatically.
impress: dev
? 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js' // Development
: 'impress@/_next/static/chunks/remoteEntry.js', // Production
},
shared: {
// Defines shared libraries to avoid duplication
react: { singleton: true },
'react-dom': { singleton: true },
'styled-components': { singleton: true },
'@openfun/cunningham-react': { singleton: true },
'@tanstack/react-query': { singleton: true },
},
};
let mfTypesReady;
const ensureFederatedTypesPlugin = {
apply(compiler) {
compiler.hooks.beforeCompile.tapPromise(
'EnsureFederatedTypes',
async () => {
if (!mfTypesReady) {
const downloader = NativeFederationTypeScriptHostCore.raw({
moduleFederationConfig,
});
mfTypesReady = downloader.writeBundle();
}
await mfTypesReady;
},
);
},
};
return {
devServer: {
// The port should match the one in your plugin's configuration file
port: 8080,
},
entry: './src/index.tsx', // Your plugin's entry point; can be an empty file as modules are exposed directly.
plugins: [
new ModuleFederationPlugin(moduleFederationConfig),
// This plugin enables type-sharing for intellisense
...(dev
? [
ensureFederatedTypesPlugin, // ensures the zip is ready before the first compile
NativeFederationTypeScriptHost({ moduleFederationConfig }),
]
: []),
],
// ... other webpack config (output, module rules, etc.)
};
};
```
> Don't change `remotes.impress` if you want your [released plugin](#releasing-a-plugin "Learn how to release a plugin") to be [deployable by others](#deploying-docs-with-plugins "Learn about deployment").
<br>
### 5\. Enabling Type-Sharing for Intellisense
To get autocompletion for components and hooks exposed by the host, <br>
configure your plugin's `tsconfig.json` to find the host's types.
<br>
In your plugin's `tsconfig.json`:
```json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["./@mf-types/*"]
}
}
}
```
<br>
When you run the host application with `NEXT_PUBLIC_DEVELOP_PLUGINS=true`, it generates a `@mf-types.zip` file. <br>
The `NativeFederationTypeScriptHost` plugin in your webpack config will automatically download and unpack it ahead of the first compile, <br>
making the host's types available to your plugin and IDE. In development with this flag enabled, route changes may be slower because type generation and automatic exposure run during rebuilds; this does not affect production where navigations are instant.
<br>
### 6\. Running and Configuring Your Plugin
With the host application already running (from step 1), <br>
you can now start your plugin's development server and configure the host to load it.
<br>
1. **Start the plugin**:<br>
In your plugin's project directory, run `yarn dev`.
2. **Configure the host**:<br>
Tell the host to load your plugin by editing its configuration file. <br>
When running Docs locally, this file is located at `src/backend/impress/configuration/plugins/default.json`. <br>
Update it to point to your local plugin's `remoteEntry.js`.
<br>
```json
{
"id": "my-custom-component",
"remote": {
"url": "http://localhost:8080/remoteEntry.js",
"name": "my_plugin",
"module": "./MyCustomComponent"
},
"injection": {
"target": "#some-element-in-the-host"
},
"props": {
"message": "Hello from the configuration!"
}
}
```
<br>
After changing the `target` to a valid CSS selector in the host's DOM, save the file. <br>
The host application will automatically detect the change and inject your component, passing the `props` object along.
Your component should appear in the running host application after a reload.
<br>
## Host-Plugin Interaction
### Host Exports
The host automatically exposes many of its components and hooks. <br>
You can import them in the plugin as if they were local modules, thanks to the [`remotes` configuration](#4-federation-configuration "See the remotes config in Webpack") in the `webpack.config.js`.
<br>
```typescript
// In the plugin's code
import { Icon } from 'impress/components';
import { useAuthQuery } from 'impress/features/auth/api';
```
<br>
### Choosing Shared Dependencies
Sharing dependencies is critical for performance and stability.
<br>
- **Minimal Shared Libraries**:<br>
Always share **`react`**, **`react-dom`**, **`styled-components`**, and **`@openfun/cunningham-react`** to use the same instances as the host.
- **Sharing State**:<br>
Libraries that rely on a global context (like `@tanstack/react-query`) **must** be shared to access the host's state and cache.
- **Discovering More Shared Libraries**: With `NEXT_PUBLIC_DEVELOP_PLUGINS=true`, <br>
[the host prints its shared dependency map to the Next.js dev server logs on startup](#1-prepare-the-host-environment "See how to prepare the host"). <br>
You can use this to align versions and add more shared libraries to your plugin.
<br>
> **Important**: Both the host and the plugin must declare a dependency in [`moduleFederationConfig.shared`](#4-federation-configuration "See the federation configuration") for it to become a true singleton. <br>
> If a shared dependency is omitted from the plugin's config, Webpack will bundle a separate copy, breaking the singleton pattern.
<br>
## Development Workflow
### Test and Debug
- Use the `[PluginSystem]` logs in the browser console to see if the plugin is loading correctly.
- Errors in the plugin are caught by an `ErrorBoundary` and will not crash the host.
<br>
Common Errors:
| Issue | Cause/Fix |
| :--- | :--- |
| Unreachable `remoteEntry.js` | Check the `url` in the [plugin configuration](#6-running-and-configuring-your-plugin "See how to configure the plugin"). |
| Library version conflicts | Ensure `shared` library versions in `package.json` match the [host's versions](#1-prepare-the-host-environment "See how to check host versions"). |
| Invalid CSS selectors | Validate the `target` selector against the host's DOM. |
<br>
### Best Practices
- Build modular components with well-typed props.
- Prefer using the host's exposed types and components over implementing new ones.
- Keep shared dependency versions aligned with the host <br>
and re-test after host upgrades.
- Treat plugin bundles as untrusted: vet dependencies and avoid unsafe scripts.
<br>
## Plugin Configuration File Reference
This section provides a detailed reference for all fields in the plugin configuration JSON.
For deployment details, see [Deploying Docs with Plugins](#deploying-docs-with-plugins "Learn about production deployment").
<br>
### Configuration Structure
| Field | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `id` | String | Yes | Unique component identifier (e.g., "my-widget"). |
| `remote` | Object | Yes | Remote module details. |
| - `url` | String | Yes | Path to `remoteEntry.js` (absolute/relative). |
| - `name` | String | Yes | Federation remote name (e.g., "myPlugin"). |
| - `module` | String | Yes | Exposed module (e.g., "./Widget"). |
| `injection`| Object | Yes | Integration control. |
| - `target` | String | Yes | CSS selector for insertion point. |
| - `position` | String | No (default: "append") | Insertion position (`before`, `after`, `replace`, `prepend`, `append`). See [examples](#injection-position-examples "See injection examples"). |
| - `observerRoots` | String/Boolean | No | DOM observation: CSS selector, `true` (observe whole document), or `false` (default; disable observers). |
| `props` | Object | No | Props passed to the [plugin component](#3-creating-a-plugin-component "See how to create a component with props"). |
| `visibility` | Object | No | Visibility controls. |
| - `routes` | Array | No | Path globs (e.g., `["/docs/*", "!/docs/secret*"]`); supports `*` and `?` wildcards plus negation (`!`). |
<br>
### Injection Position Examples
The `injection.position` property controls how the plugin is inserted relative to the `target` element.
<details>
<summary>View injection examples</summary>
<br>
**before**
```json
{
"id": "my-custom-component-0",
"injection": {
"target": "#item2",
"position": "before"
}
}
```
```html
<ul id="some-element-in-the-host">
<li id="item1"></li>
<div id="plugin-container-my-custom-component-0"></div>
<li id="item2"></li>
</ul>
```
<br>
**after**
```json
{
"id": "my-custom-component-0",
"injection": {
"target": "#item1",
"position": "after"
}
}
```
```html
<ul id="some-element-in-the-host">
<li id="item1"></li>
<div id="plugin-container-my-custom-component-0"></div>
<li id="item2"></li>
</ul>
```
<br>
**prepend**
```json
{
"id": "my-custom-component-0",
"injection": {
"target": "#some-element-in-the-host",
"position": "prepend"
}
}
```
```html
<ul id="some-element-in-the-host">
<div id="plugin-container-my-custom-component-0"></div>
<li id="item1"></li>
<li id="item2"></li>
</ul>
```
<br>
**append** (default)
```json
{
"id": "my-custom-component-0",
"injection": {
"target": "#some-element-in-the-host",
"position": "append"
}
}
```
```html
<ul id="some-element-in-the-host">
<li id="item1"></li>
<li id="item2"></li>
<div id="plugin-container-my-custom-component-0"></div>
</ul>
```
<br>
**replace**
```json
{
"id": "my-custom-component-0",
"injection": {
"target": "#item1",
"position": "replace"
}
}
```
```html
<ul id="some-element-in-the-host">
<div id="plugin-container-my-custom-component-0"></div>
<li id="item1" data-pluginsystem-hidden="true"></li>
<li id="item2"></li>
</ul>
```
</details>
<br>
## Releasing a Plugin
When you are ready to release your plugin, you need to create a production build.
<br>
Run the build command in your plugin's directory:<br>
```bash
yarn build
```
This command bundles your code for production. <br>
Webpack will generate a **`dist`** folder (or similar) containing the **`remoteEntry.js`** file and other JavaScript chunks. <br>
The `remoteEntry.js` is the manifest that tells other applications what modules your plugin exposes. <br>
These are the files you will need for deployment.
<br>
The [`webpack.config.js` provided](#4-federation-configuration "See the federation configuration") is already configured to switch the `remotes` URL to the correct production path automatically, <br>
so no code changes are needed before building.
<br>
## Deploying Docs with Plugins
To use plugins in a production environment, you need to deploy both the plugin assets and the configuration file. <br>
The recommended approach is to serve the plugin's static files from the same webserver that serves the host (docs frontend).
<br>
1. **Deploy Plugin Assets**: <br>
Copy the contents of your plugin's build output directory (e.g., `dist/`) <br>
into the frontend container's `/usr/share/nginx/html/assets` directory at a chosen path.<br>
E.g.: Placing assets in `/usr/share/nginx/html/assets/plugins/my-plugin/` <br>
would make the plugin's **`remoteEntry.js`** available at `https://production.domain/assets/plugins/my-plugin/remoteEntry.js`.
<br>
2. **Deploy Plugin Configuration**: The host's [plugin configuration file](#plugin-configuration-file-reference "See the configuration file reference") must be updated to point to the deployed assets. <br>
This file is typically managed via infrastructure methods <br>
(e.g., a Kubernetes configmap replacing `/app/impress/configuration/plugins/default.json` in the backend container).
<br>
Update the **`remote.url`** to the public-facing path that matches where you deployed the assets:
```json
{
"id": "my-custom-component",
"remote": {
"url": "/assets/plugins/my-plugin/remoteEntry.js",
"name": "my_plugin",
"module": "./MyCustomComponent"
},
"injection": {
"target": "#some-element-in-the-host"
},
"props": {
"message": "Hello from production!"
}
}
```
+4
View File
@@ -66,3 +66,7 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
# Cache
PLUGINS_CONFIG_CACHE_TIMEOUT=0
THEME_CUSTOMIZATION_CACHE_TIMEOUT=0
+58
View File
@@ -506,6 +506,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
We expose it separately from document in order to simplify and secure access control.
"""
link_reach = serializers.ChoiceField(
choices=models.LinkReachChoices.choices, required=True
)
class Meta:
model = models.Document
fields = [
@@ -513,6 +517,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
"link_reach",
]
def validate(self, attrs):
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")
if not link_reach:
raise serializers.ValidationError(
{"link_reach": _("This field is required.")}
)
# Get available options based on ancestors' link definition
available_options = models.LinkReachChoices.get_select_options(
**self.instance.ancestors_link_definition
)
# Validate link_reach is allowed
if link_reach not in available_options:
msg = _(
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
)
raise serializers.ValidationError(
{"link_reach": msg % {"link_reach": link_reach}}
)
# Validate link_role is compatible with link_reach
allowed_roles = available_options[link_reach]
# Restricted reach: link_role must be None
if link_reach == models.LinkReachChoices.RESTRICTED:
if link_role is not None:
raise serializers.ValidationError(
{
"link_role": (
"Cannot set link_role when link_reach is 'restricted'. "
"Link role must be null for restricted reach."
)
}
)
return attrs
# Non-restricted: link_role must be in allowed roles
if link_role not in allowed_roles:
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
raise serializers.ValidationError(
{
"link_role": (
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
f"Allowed roles: {allowed_roles_str}"
)
}
)
return attrs
class DocumentDuplicationSerializer(serializers.Serializer):
"""
@@ -684,6 +740,8 @@ class InvitationSerializer(serializers.ModelSerializer):
if self.instance is None:
attrs["issuer"] = user
attrs["email"] = attrs["email"].lower()
return attrs
def validate_role(self, role):
+42
View File
@@ -2164,6 +2164,7 @@ class ConfigView(drf.views.APIView):
dict_settings[setting] = getattr(settings, setting)
dict_settings["theme_customization"] = self._load_theme_customization()
dict_settings["plugins"] = self._load_plugins_config()
return drf.response.Response(dict_settings)
@@ -2201,3 +2202,44 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
def _load_plugins_config(self):
if not settings.PLUGINS_CONFIG_FILE_PATH:
return []
cache_key = (
f"plugins_config_{slugify(settings.PLUGINS_CONFIG_FILE_PATH)}"
)
plugins_config = cache.get(cache_key)
if plugins_config is not None:
return plugins_config
plugins_config = []
try:
with open(
settings.PLUGINS_CONFIG_FILE_PATH, "r", encoding="utf-8"
) as f:
data = json.load(f)
# Support both array format and object with "plugins" key
if isinstance(data, list):
plugins_config = data
elif isinstance(data, dict):
plugins_config = data.get("plugins", [])
except FileNotFoundError:
logger.error(
"Plugins configuration file not found: %s",
settings.PLUGINS_CONFIG_FILE_PATH,
)
except json.JSONDecodeError:
logger.error(
"Plugins configuration file is not a valid JSON: %s",
settings.PLUGINS_CONFIG_FILE_PATH,
)
else:
cache.set(
cache_key,
plugins_config,
settings.PLUGINS_CONFIG_CACHE_TIMEOUT,
)
return plugins_config
+1 -1
View File
@@ -221,7 +221,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
Expired invitations are ignored.
"""
valid_invitations = Invitation.objects.filter(
email=self.email,
email__iexact=self.email,
created_at__gte=(
timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
@@ -596,6 +596,32 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
}
def test_api_document_invitations_create_lower_email():
"""
No matter the case, the email should be converted to lowercase.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
# Build an invitation to the email of an existing identity in the db
invitation_values = {
"email": "GuEst@example.com",
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
# Update
@@ -133,7 +133,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED,
link_role=models.LinkRoleChoices.READER,
)
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
@@ -143,7 +146,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
)
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
instance=factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.EDITOR,
)
).data
with mock_reset_connections(document.id):
@@ -158,3 +164,240 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
def test_api_documents_link_configuration_update_role_restricted_forbidden():
"""
Test that trying to set link_role on a document with restricted link_reach
returns a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Try to set a meaningful role on a restricted document
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
"link_role": models.LinkRoleChoices.EDITOR,
}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_role" in response.json()
assert (
"Cannot set link_role when link_reach is 'restricted'"
in response.json()["link_role"][0]
)
def test_api_documents_link_configuration_update_link_reach_required():
"""
Test that link_reach is required when updating link configuration.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Try to update without providing link_reach
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_reach" in response.json()
assert "This field is required" in response.json()["link_reach"][0]
def test_api_documents_link_configuration_update_restricted_without_role_success(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting link_reach to restricted without specifying link_role succeeds.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Only specify link_reach, not link_role
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.RESTRICTED
@pytest.mark.parametrize(
"reach", [models.LinkReachChoices.PUBLIC, models.LinkReachChoices.AUTHENTICATED]
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
reach,
role,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting non-restricted link_reach with valid link_role succeeds.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
new_data = {
"link_reach": reach,
"link_role": role,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == reach
assert document.link_role == role
def test_api_documents_link_configuration_update_with_ancestor_constraints():
"""
Test that link configuration respects ancestor constraints using get_select_options.
This test may need adjustment based on the actual get_select_options implementation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
child_document = factories.DocumentFactory(
parent=parent_document,
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=child_document, user=user, role=models.RoleChoices.OWNER
)
# Try to set child to PUBLIC when parent is RESTRICTED
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
"link_role": models.LinkRoleChoices.READER,
}
response = client.put(
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_reach" in response.json()
assert (
"Link reach 'restricted' is not allowed based on parent"
in response.json()["link_reach"][0]
)
def test_api_documents_link_configuration_update_invalid_role_for_reach_validation():
"""
Test the specific validation logic that checks if link_role is allowed for link_reach.
This tests the code section that validates allowed_roles from get_select_options.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED,
link_role=models.LinkRoleChoices.EDITOR,
)
child_document = factories.DocumentFactory(
parent=parent_document,
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=child_document, user=user, role=models.RoleChoices.OWNER
)
new_data = {
"link_reach": models.LinkReachChoices.AUTHENTICATED,
"link_role": models.LinkRoleChoices.READER, # This should be rejected
}
response = client.put(
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_role" in response.json()
error_message = response.json()["link_role"][0]
assert (
"Link role 'reader' is not allowed for link reach 'authenticated'"
in error_message
)
assert "Allowed roles: editor" in error_message
+31 -1
View File
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
import pytest
from core import factories
from core import factories, models
pytestmark = pytest.mark.django_db
@@ -66,3 +66,33 @@ def test_models_users_sub_validator(sub, is_valid):
match=("Enter a valid sub. This value should be ASCII only."),
):
user.full_clean()
def test_modes_users_convert_valid_invitations():
"""
The "convert_valid_invitations" method should convert valid invitations to document accesses.
"""
email = "test@example.com"
document = factories.DocumentFactory()
other_document = factories.DocumentFactory()
invitation_document = factories.InvitationFactory(email=email, document=document)
invitation_other_document = factories.InvitationFactory(
email="Test@example.coM", document=other_document
)
other_email_invitation = factories.InvitationFactory(
email="pre_test@example.com", document=document
)
assert document.accesses.count() == 0
assert other_document.accesses.count() == 0
user = factories.UserFactory(email=email)
assert document.accesses.filter(user=user).count() == 1
assert other_document.accesses.filter(user=user).count() == 1
assert not models.Invitation.objects.filter(id=invitation_document.id).exists()
assert not models.Invitation.objects.filter(
id=invitation_other_document.id
).exists()
assert models.Invitation.objects.filter(id=other_email_invitation.id).exists()
@@ -0,0 +1,54 @@
[
{
"id": "my-custom-component-in-header",
"remote": {
"url": "http://localhost:3002/remoteEntry.js",
"name": "plugin_frontend",
"module": "MyCustomComponent"
},
"injection": {
"target": "body header [data-testid=\"header-logo-link\"]",
"position": "after",
"observerRoots": "body header"
},
"props": {
"customMessage": "Plugin Demo",
"showDebugInfo": true
},
"visibility": {
"routes": [
"/docs/*"
]
}
},
{
"id": "central-header-menu",
"remote": {
"url": "http://localhost:3002/remoteEntry.js",
"name": "plugin_frontend",
"module": "./MyCustomHeaderMenu"
},
"injection": {
"target": "body header [data-testid=\"header-logo-link\"]",
"position": "after",
"observerRoots": "body header"
},
"props": {
"icsBaseUrl": "http://localhost:8000",
"portalBaseUrl": "http://localhost:8001"
}
},
{
"id": "theme-demo-panel",
"remote": {
"url": "http://localhost:3002/remoteEntry.js",
"name": "plugin_frontend",
"module": "./ThemingDemo"
},
"injection": {
"target": "body",
"position": "append",
"observerRoots": false
}
}
]
+12
View File
@@ -496,6 +496,18 @@ class Base(Configuration):
environ_prefix=None,
)
PLUGINS_CONFIG_FILE_PATH = values.Value(
os.path.join(BASE_DIR, "impress/configuration/plugins/default.json"),
environ_name="PLUGINS_CONFIG_FILE_PATH",
environ_prefix=None,
)
PLUGINS_CONFIG_CACHE_TIMEOUT = values.Value(
60 * 60 * 2,
environ_name="PLUGINS_CONFIG_CACHE_TIMEOUT",
environ_prefix=None,
)
# Posthog
POSTHOG_KEY = values.DictValue(
None, environ_name="POSTHOG_KEY", environ_prefix=None
+1 -1
View File
@@ -39,7 +39,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.6",
"django==5.2.7",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
+7 -1
View File
@@ -50,7 +50,13 @@ ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
# Upgrade system packages to install security updates
USER root
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
# Un-privileged user running the application
ARG DOCKER_USER
@@ -1,7 +1,7 @@
/* eslint-disable playwright/no-conditional-expect */
import path from 'path';
import { chromium, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import {
@@ -11,7 +11,9 @@ import {
overrideConfig,
verifyDocName,
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -86,8 +88,7 @@ test.describe('Doc Editor', () => {
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
@@ -100,7 +101,7 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
@@ -505,10 +506,7 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, randomDoc);
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.locator('.bn-block-outer').last().fill('/');
const editor = await openSuggestionMenu({ page });
await page.getByText('Embedded file').click();
await page.getByText('Upload file').click();
@@ -563,20 +561,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-visibility').click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await updateShareLink(page, 'Public', 'Editing');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
@@ -600,17 +585,12 @@ test.describe('Doc Editor', () => {
* We open another browser that will connect to the collaborative server
* and will block the current browser to edit the doc.
*/
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
locale: 'en-US',
timezoneId: 'Europe/Paris',
permissions: [],
storageState: {
cookies: [],
origins: [],
},
const { otherPage } = await connectOtherUserToDoc({
browserName,
docUrl: urlChildDoc,
docTitle: childTitle,
withoutSignIn: true,
});
const otherPage = await otherContext.newPage();
const webSocketPromise = otherPage.waitForEvent(
'websocket',
@@ -651,6 +631,11 @@ test.describe('Doc Editor', () => {
await expect(editor).toHaveAttribute('contenteditable', 'false');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toBeHidden();
await expect(page.getByRole('heading', { name: childTitle })).toBeVisible();
await page.goto(urlParentDoc);
await verifyDocName(page, parentTitle);
@@ -667,6 +652,11 @@ test.describe('Doc Editor', () => {
await expect(editor).toHaveAttribute('contenteditable', 'true');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(childTitle);
await expect(page.getByRole('heading', { name: childTitle })).toBeHidden();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
@@ -675,9 +665,7 @@ test.describe('Doc Editor', () => {
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
await openSuggestionMenu({ page });
await page.getByText('Add a callout block').click();
const calloutBlock = page
@@ -762,15 +750,21 @@ test.describe('Doc Editor', () => {
await expect(searchContainer.getByText(docChild2)).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeHidden();
// use keydown to select the second result
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
const interlink = page.getByRole('link', {
name: 'child-2',
// Wait for the search container to disappear, indicating selection was made
await expect(searchContainer).toBeHidden();
// Wait for the interlink to be created and rendered
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('link', {
name: docChild2,
});
await expect(interlink).toBeVisible();
await expect(interlink).toBeVisible({ timeout: 10000 });
await interlink.click();
await verifyDocName(page, docChild2);
@@ -791,4 +785,94 @@ test.describe('Doc Editor', () => {
),
).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-scroll', browserName, 1);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Enter');
await writeInEditor({ page, text: 'Hello Parent ' + i });
}
const editor = await getEditor({ page });
await expect(
editor.getByText('Hello Parent 1', { exact: true }),
).not.toBeInViewport();
await expect(editor.getByText('Hello Parent 14')).toBeInViewport();
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-scroll-child',
);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Enter');
await writeInEditor({ page, text: 'Hello Child ' + i });
}
await expect(
editor.getByText('Hello Child 1', { exact: true }),
).not.toBeInViewport();
await expect(editor.getByText('Hello Child 14')).toBeInViewport();
await navigateToPageFromTree({ page, title: randomDoc });
await expect(
editor.getByText('Hello Parent 1', { exact: true }),
).toBeInViewport();
await expect(editor.getByText('Hello Parent 14')).not.toBeInViewport();
await navigateToPageFromTree({ page, title: docChild });
await expect(
editor.getByText('Hello Child 1', { exact: true }),
).toBeInViewport();
await expect(editor.getByText('Hello Child 14')).not.toBeInViewport();
});
test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
await expect(pdfBlock).toBeVisible();
await page.getByText('Add PDF').click();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download');
await page.getByText('Upload file').click();
const fileChooser = await fileChooserPromise;
console.log(path.join(__dirname, 'assets/test-pdf.pdf'));
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
const pdfEmbed = page
.locator('.--docs--editor-container embed.bn-visual-media')
.first();
// Check src of pdf
expect(await pdfEmbed.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
);
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
// Check download with original filename
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
await page.locator('[data-test="downloadfile"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
});
});
@@ -11,6 +11,7 @@ import {
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -93,6 +94,7 @@ test.describe('Doc Export', () => {
expect(pdfData.numpages).toBe(2);
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
expect(pdfData.info.Title).toBe(randomDoc);
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -152,11 +154,13 @@ test.describe('Doc Export', () => {
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await writeInEditor({
page,
text: 'Hello World 😃🎉🚀🙋‍♀️🧑🏿‍❤️‍💋‍🧑🏾',
});
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await openSuggestionMenu({ page });
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
@@ -393,7 +397,7 @@ test.describe('Doc Export', () => {
})
.click();
const input = page.locator('.--docs--doc-title-input[role="textbox"]');
const input = page.getByRole('textbox', { name: 'Titre du document' });
await expect(input).toBeVisible();
await expect(input).toHaveText('', { timeout: 10000 });
await input.click();
@@ -410,6 +414,10 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDocFrench}.pdf`);
});
@@ -106,7 +106,7 @@ test.describe('Doc grid dnd', () => {
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(
'You must be at least the editor of the target document',
'You must be at least the administrator of the target document',
);
await page.mouse.up();
@@ -117,7 +117,7 @@ test.describe('Document grid item options', () => {
await page.getByText('push_pin').click();
// Check is pinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
@@ -126,7 +126,7 @@ test.describe('Document grid item options', () => {
await page.getByText('Unpin').click();
// Check is unpinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
@@ -75,22 +75,22 @@ test.describe('Doc Header', () => {
// Check the tree
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
await expect(docTree.getByLabel('Document emoji icon')).toBeVisible();
await expect(docTree.getByLabel('Simple document icon')).toBeHidden();
await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible();
await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden();
await page.getByTestId('home-button').click();
// Check the documents grid
const gridRow = await getGridRow(page, 'Hello Emoji World');
await expect(gridRow.getByLabel('Document emoji icon')).toBeVisible();
await expect(gridRow.getByLabel('Simple document icon')).toBeHidden();
await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible();
await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Delete document').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -142,13 +142,19 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText('Mocked document');
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -216,12 +222,18 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText('Mocked document');
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -282,12 +294,18 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('heading', { name: 'Mocked document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -343,7 +361,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByLabel('Copy as Markdown').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -377,7 +395,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByLabel('Copy as HTML').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -434,11 +452,15 @@ test.describe('Doc Header', () => {
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Pin doc`, browserName);
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
// Pin
await page.getByText('push_pin').click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByText('Unpin')).toBeVisible();
await page.goto('/');
@@ -446,22 +468,26 @@ test.describe('Doc Header', () => {
const row = await getGridRow(page, docTitle);
// Check is pinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
await row.getByText(docTitle).click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
// Unpin
await page.getByText('Unpin').click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByText('push_pin')).toBeVisible();
await page.goto('/');
// Check is unpinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
@@ -560,7 +586,7 @@ test.describe('Documents Header mobile', () => {
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByLabel('Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -583,7 +609,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',
@@ -7,6 +7,7 @@ import {
randomName,
verifyDocName,
} from './utils-common';
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Document create member', () => {
@@ -25,9 +26,8 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
await expect(inputSearch).toBeVisible();
// Select user 1 and verify tag
@@ -74,13 +74,15 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByLabel('Reader')).toBeVisible();
await expect(page.getByLabel('Editor')).toBeVisible();
await expect(page.getByLabel('Owner')).toBeVisible();
await expect(page.getByLabel('Administrator')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -117,9 +119,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
@@ -128,7 +128,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Owner').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -146,7 +146,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Owner').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -167,18 +167,23 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
const email = randomName('test@test.fr', browserName, 1)[0];
let email = 'user.test21@example.COM';
await inputSearch.fill(email);
// Check email is found in search (case insensitive)
await expect(page.getByRole('option').getByText(email)).toHaveCount(1);
email = email + 'M';
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -193,26 +198,22 @@ test.describe('Document create member', () => {
const listInvitation = page.getByTestId('doc-share-quick-search');
const userInvitation = listInvitation.getByTestId(
`doc-share-invitation-row-${email}`,
`doc-share-invitation-row-${email.toLowerCase()}`,
);
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Reader').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'Open invitation actions menu',
});
await moreActions.click();
await page.getByLabel('Delete').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It creates a member from a request coming from a 403 page', async ({
page,
@@ -220,9 +221,6 @@ test.describe('Document create member: Multiple login', () => {
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Member access request',
@@ -232,67 +230,67 @@ test.describe('Document create member: Multiple login', () => {
await verifyDocName(page, docTitle);
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
.locator('.ProseMirror')
.locator('.bn-block-outer')
.last()
.fill('Hello World');
const otherBrowser = BROWSERS.find((b) => b !== browserName);
const docUrl = page.url();
await keyCloakSignIn(page, otherBrowser!);
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.goto(urlDoc);
// Other user will request access
const { otherPage, otherBrowserName, cleanup } =
await connectOtherUserToDoc({ browserName, docUrl });
await expect(
page.getByText('Insufficient access rights to view the document.'),
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: 'Request access' }).click();
await otherPage.getByRole('button', { name: 'Request access' }).click();
await expect(
page.getByText('Your access request for this document is pending.'),
otherPage.getByText('Your access request for this document is pending.'),
).toBeVisible();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await page.goto('/');
await keyCloakSignIn(page, browserName);
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
// First user approves the request
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByText('Access Requests')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
const emailRequest = `user.test@${otherBrowser}.test`;
const emailRequest = `user.test@${otherBrowserName}.test`;
await expect(page.getByText(emailRequest)).toBeVisible();
const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();
await expect(page.getByText('Share with 2 users')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
// Other user verifies he has access
await otherPage.reload();
await verifyDocName(otherPage, docTitle);
await expect(otherPage.getByText('Hello World')).toBeVisible();
// Revoke access
await updateRoleUser(page, 'Remove access', emailRequest);
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
// Cleanup: other user logout
await cleanup();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It cannot request member access on child doc on a 403 page', async ({
page,
@@ -171,12 +171,12 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByLabel('Reader').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -93,6 +93,12 @@ test.describe('Doc Routing', () => {
await expect(page.getByText('Log in to access the document.')).toBeVisible({
timeout: 10000,
});
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex',
);
await expect(page).toHaveTitle(/401 Unauthorized - Docs/);
});
});
@@ -8,8 +8,6 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
test.setTimeout(60000);
const [randomDoc] = await createDoc(
page,
'doc-table-content',
@@ -224,7 +224,7 @@ test.describe('Doc Tree', () => {
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -18,7 +18,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, randomDoc);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
@@ -54,7 +54,7 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -82,7 +82,9 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Version history')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -109,7 +111,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
@@ -7,6 +7,7 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Visibility', () => {
@@ -44,17 +45,21 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Can read and edit' }),
).toBeHidden();
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByLabel('Public', { exact: true }).click();
await page.getByRole('menuitem', { name: 'Public' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -146,47 +151,31 @@ test.describe('Doc Visibility: Restricted', () => {
await verifyDocName(page, docTitle);
await page
.locator('.ProseMirror')
.locator('.bn-block-outer')
.last()
.fill('Hello World');
const docUrl = page.url();
const { otherBrowserName, otherPage } = await connectOtherUserToDoc({
browserName,
docUrl,
});
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await addNewMember(page, 0, 'Reader', otherBrowserName);
const otherBrowser = BROWSERS.find((b) => b !== browserName);
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
const username = `user.test@${otherBrowser}.test`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Reader').click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await keyCloakSignIn(page, otherBrowser);
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.getByLabel('Share button')).toBeVisible();
await otherPage.reload();
await expect(otherPage.getByText('Hello World')).toBeVisible();
});
});
@@ -308,7 +297,7 @@ test.describe('Doc Visibility: Public', () => {
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -531,7 +520,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -47,7 +47,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByLabel('Français').click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
page.locator('footer').getByText('Mentions légales'),
@@ -132,7 +132,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByLabel('Français').click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
page
@@ -1,6 +1,7 @@
import { Page, expect } from '@playwright/test';
export const BROWSERS = ['chromium', 'webkit', 'firefox'];
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_FEATURE_ENABLED: true,
@@ -322,5 +323,5 @@ export async function waitForLanguageSwitch(
await languagePicker.click();
await page.getByLabel(lang.label).click();
await page.getByRole('menuitem', { name: lang.label }).click();
}
@@ -0,0 +1,27 @@
import { Page } from '@playwright/test';
export const getEditor = async ({ page }: { page: Page }) => {
const editor = page.locator('.ProseMirror');
await editor.click();
return editor;
};
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
const editor = await getEditor({ page });
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
return editor;
};
export const writeInEditor = async ({
page,
text,
}: {
page: Page;
text: string;
}) => {
const editor = await getEditor({ page });
editor.locator('.bn-block-outer').last().fill(text);
return editor;
};
@@ -1,8 +1,15 @@
import { Page, expect } from '@playwright/test';
import { Page, chromium, expect } from '@playwright/test';
import {
BROWSERS,
BrowserName,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public';
export type LinkRole = 'Reading' | 'Edition';
export type LinkRole = 'Reading' | 'Editing';
export const addNewMember = async (
page: Page,
@@ -16,9 +23,7 @@ export const addNewMember = async (
response.status() === 200,
);
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
// Select a new user
await inputSearch.fill(fillText);
@@ -34,7 +39,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByLabel(role).click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -61,6 +66,80 @@ export const updateShareLink = async (
}
};
export const updateRoleUser = async (
page: Page,
role: Role | 'Remove access',
email: string,
) => {
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: role }).click();
await list.click();
};
/**
* Connects another user to a document.
* Useful to test real-time collaboration features.
* @param browserName The name of the browser to use.
* @param docUrl The URL of the document to connect to.
* @param docTitle The title of the document (optional).
* @returns An object containing the other browser, context, and page.
*/
export const connectOtherUserToDoc = async ({
browserName,
docUrl,
docTitle,
withoutSignIn,
}: {
browserName: BrowserName;
docUrl: string;
docTitle?: string;
withoutSignIn?: boolean;
}) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
locale: 'en-US',
timezoneId: 'Europe/Paris',
permissions: [],
storageState: {
cookies: [],
origins: [],
},
});
const otherPage = await otherContext.newPage();
await otherPage.goto(docUrl);
if (!withoutSignIn) {
await otherPage
.getByRole('main', { name: 'Main content' })
.getByLabel('Login')
.click({
timeout: 15000,
});
await keyCloakSignIn(otherPage, otherBrowserName, false);
}
if (docTitle) {
await verifyDocName(otherPage, docTitle);
}
const cleanup = async () => {
await otherPage.close();
await otherContext.close();
await otherBrowser.close();
};
return { otherBrowser, otherContext, otherPage, otherBrowserName, cleanup };
};
export const mockedInvitations = async (page: Page, json?: object) => {
let result = [
{
@@ -3,6 +3,7 @@ import { Page, expect } from '@playwright/test';
import {
randomName,
updateDocTitle,
verifyDocName,
waitForResponseCreateDoc,
} from './utils-common';
@@ -63,5 +64,17 @@ export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
await rootItem.hover();
await rootItem.getByRole('button', { name: 'add_box' }).click();
await rootItem.getByTestId('doc-tree-item-actions-add-child').click();
};
export const navigateToPageFromTree = async ({
page,
title,
}: {
page: Page;
title: string;
}) => {
const docTree = page.getByTestId('doc-tree');
await docTree.getByText(title).click();
await verifyDocName(page, title);
};
@@ -0,0 +1,2 @@
@mf-types/
node_modules/
@@ -0,0 +1,14 @@
# Impress Plugin
Minimal Module Federation Plugin for Impress
## Development
```bash
yarn install
yarn dev
```
Server runs on http://localhost:3002
Remote entry point: http://localhost:3002/remoteEntry.js
@@ -0,0 +1,37 @@
{
"name": "app-impress-plugin",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "webpack serve --mode development",
"build": "webpack --mode production"
},
"devDependencies": {
"@module-federation/native-federation-typescript": "0.6.2",
"@openfun/cunningham-react": "3.2.3",
"@types/react": "19.1.1",
"@types/react-dom": "19.1.1",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.3",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.1",
"typescript": "*",
"webpack": "^5.101.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.2.0"
},
"dependencies": {
"react": "19.1.1",
"react-dom": "19.1.1",
"styled-components": "6.1.19",
"@openfun/cunningham-react": "3.2.3",
"react-i18next": "15.7.3",
"react-aria-components": "1.12.1",
"@gouvfr-lasuite/ui-kit": "0.16.1",
"yjs": "13.6.27",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"react-intersection-observer": "9.16.0",
"@tanstack/react-query": "5.87.4"
}
}
@@ -0,0 +1,489 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button, Popover } from 'react-aria-components';
import styled from 'styled-components';
import { useTranslation } from 'react-i18next';
import { useAuthQuery } from 'impress/features/auth/api';
import { Icon, Loading, Box, Text } from 'impress/components';
// Styled Components showcasing styled-components integration
const StyledButton = styled(Button)`
cursor: pointer;
border: 1px solid #0066cc;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 8px;
font-family: Marianne, Arial, serif;
font-weight: 500;
font-size: 0.875rem;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
gap: 8px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid #667eea;
outline-offset: 2px;
}
`;
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
border: 1px solid #e0e0e0;
min-width: 400px;
max-width: 500px;
max-height: 600px;
overflow: hidden;
display: flex;
flex-direction: column;
`;
const TabContainer = styled.div`
display: flex;
border-bottom: 2px solid #f0f0f0;
background-color: #fafafa;
`;
const Tab = styled.button<{ $active: boolean }>`
flex: 1;
padding: 12px 16px;
border: none;
background: ${props => props.$active ? 'white' : 'transparent'};
color: ${props => props.$active ? '#667eea' : '#666'};
font-weight: ${props => props.$active ? '600' : '400'};
font-size: 0.875rem;
cursor: pointer;
border-bottom: 2px solid ${props => props.$active ? '#667eea' : 'transparent'};
margin-bottom: -2px;
transition: all 0.2s ease-in-out;
&:hover {
background-color: ${props => props.$active ? 'white' : '#f5f5f5'};
color: ${props => props.$active ? '#667eea' : '#333'};
}
`;
const TabContent = styled.div`
padding: 20px;
overflow-y: auto;
max-height: 500px;
`;
const InfoCard = styled.div`
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
border-left: 4px solid #667eea;
`;
const InfoRow = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
&:last-child {
border-bottom: none;
}
`;
const Label = styled.span`
font-weight: 600;
color: #333;
font-size: 0.875rem;
`;
const Value = styled.span`
color: #666;
font-size: 0.875rem;
font-family: 'Courier New', monospace;
background-color: rgba(0, 0, 0, 0.05);
padding: 2px 8px;
border-radius: 4px;
`;
const FeatureList = styled.ul`
list-style: none;
padding: 0;
margin: 0;
`;
const FeatureItem = styled.li`
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
margin-bottom: 8px;
background-color: #f9f9f9;
border-radius: 6px;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: #f0f0f0;
}
`;
interface ComponentProps {
customMessage?: string;
showDebugInfo?: boolean;
}
const MyCustomComponent: React.FC<ComponentProps> = ({
customMessage = 'Plugin Showcase',
showDebugInfo = true
}) => {
const { t, i18n } = useTranslation();
const { data: authData, isLoading: authLoading } = useAuthQuery();
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'user' | 'features' | 'system' | 'routing'>('user');
const [loadingDemo, setLoadingDemo] = useState(false);
const [currentPath, setCurrentPath] = useState<string>('');
const triggerRef = useRef<HTMLButtonElement>(null);
// Get current pathname from window.location (works in plugins)
useEffect(() => {
setCurrentPath(window.location.pathname);
// Listen for route changes via popstate
const handleRouteChange = () => {
setCurrentPath(window.location.pathname);
};
window.addEventListener('popstate', handleRouteChange);
return () => window.removeEventListener('popstate', handleRouteChange);
}, []);
const handleButtonPress = () => {
if (!isOpen) {
// Simulate a loading state when opening
setLoadingDemo(true);
setTimeout(() => {
setLoadingDemo(false);
}, 800);
}
setIsOpen(!isOpen);
};
// Derive authenticated from authData
const authenticated = !!authData?.id;
// Example of accessing host features
const userFeatures = [
{ icon: 'person', label: 'Authentication', value: authenticated ? 'Active' : 'Inactive' },
{ icon: 'language', label: 'Language', value: i18n.language || 'en' },
{ icon: 'email', label: 'User Email', value: authData?.email || 'N/A' },
{ icon: 'badge', label: 'User ID', value: authData?.id || 'N/A' },
];
const systemFeatures = [
{ icon: 'check_circle', label: 'React Hook (useAuthQuery)', enabled: true },
{ icon: 'check_circle', label: 'Window Location API', enabled: true },
{ icon: 'check_circle', label: 'PopState Events', enabled: true },
{ icon: 'check_circle', label: 'i18next Integration', enabled: true },
{ icon: 'check_circle', label: 'styled-components', enabled: true },
{ icon: 'check_circle', label: 'react-aria-components', enabled: true },
{ icon: 'check_circle', label: 'Host UI Components', enabled: true },
{ icon: 'check_circle', label: '@tanstack/react-query', enabled: true },
];
const pluginCapabilities = [
'Access authentication state from host',
'Use host UI components (Icon, Box, Text, Loading)',
'Leverage host hooks and utilities (useAuthQuery)',
'Read current route via window.location',
'Listen to route changes via popstate events',
'Integrate with i18n for translations',
'Use styled-components for styling',
'Implement accessible UI with react-aria',
'Receive props from plugin configuration',
'React to route changes via visibility config',
];
const renderUserTab = () => (
<TabContent>
<InfoCard>
<Text $weight="bold" $size="l" style={{ marginBottom: '12px', display: 'block' }}>
{t('User Information')}
</Text>
{authLoading ? (
<Box $align="center" $justify="center" $padding="medium">
<Loading />
</Box>
) : (
<>
{userFeatures.map((feature, idx) => (
<InfoRow key={idx}>
<Label>
<Icon iconName={feature.icon} $size="s" style={{ marginRight: '8px' }} />
{feature.label}
</Label>
<Value>{feature.value}</Value>
</InfoRow>
))}
</>
)}
</InfoCard>
{showDebugInfo && authData && (
<InfoCard>
<Text $weight="bold" $size="m" style={{ marginBottom: '8px', display: 'block' }}>
Raw Auth Data
</Text>
<pre style={{
fontSize: '0.75rem',
overflow: 'auto',
maxHeight: '200px',
backgroundColor: 'rgba(0,0,0,0.05)',
padding: '12px',
borderRadius: '4px'
}}>
{JSON.stringify(authData, null, 2)}
</pre>
</InfoCard>
)}
</TabContent>
);
const renderFeaturesTab = () => (
<TabContent>
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
{t('Plugin Capabilities')}
</Text>
<FeatureList>
{pluginCapabilities.map((capability, idx) => (
<FeatureItem key={idx}>
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
<Text $size="s">{capability}</Text>
</FeatureItem>
))}
</FeatureList>
<InfoCard style={{ marginTop: '20px' }}>
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
Props from Config
</Text>
<InfoRow>
<Label>Custom Message</Label>
<Value>{customMessage}</Value>
</InfoRow>
<InfoRow>
<Label>Debug Mode</Label>
<Value>{showDebugInfo ? 'Enabled' : 'Disabled'}</Value>
</InfoRow>
</InfoCard>
</TabContent>
);
const renderSystemTab = () => (
<TabContent>
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
{t('Integrated Host Features')}
</Text>
<FeatureList>
{systemFeatures.map((feature, idx) => (
<FeatureItem key={idx}>
<Icon
iconName={feature.enabled ? 'check_circle' : 'cancel'}
variant="filled"
$color={feature.enabled ? '#4caf50' : '#f44336'}
/>
<Text $size="s">{feature.label}</Text>
</FeatureItem>
))}
</FeatureList>
<InfoCard style={{ marginTop: '20px' }}>
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
Available Host Components
</Text>
<Text $size="xs" style={{ lineHeight: '1.6' }}>
Icon, Loading, Box, Text, Link, Card, Modal, DropButton, DropdownMenu,
InfiniteScroll, QuickSearch, Separators, TextErrors, and more...
</Text>
</InfoCard>
</TabContent>
);
const renderRoutingTab = () => (
<TabContent>
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
{t('Route Information (Plugin-Safe)')}
</Text>
<InfoCard>
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
Current Route Information
</Text>
<InfoRow>
<Label>
<Icon iconName="route" $size="s" style={{ marginRight: '8px' }} />
Current Path
</Label>
<Value>{currentPath || '/'}</Value>
</InfoRow>
<InfoRow>
<Label>
<Icon iconName="link" $size="s" style={{ marginRight: '8px' }} />
Full URL
</Label>
<Value style={{ fontSize: '0.75rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{typeof window !== 'undefined' ? window.location.href : 'N/A'}
</Value>
</InfoRow>
<InfoRow>
<Label>
<Icon iconName="tag" $size="s" style={{ marginRight: '8px' }} />
URL Hash
</Label>
<Value>{typeof window !== 'undefined' ? (window.location.hash || 'none') : 'N/A'}</Value>
</InfoRow>
</InfoCard>
<InfoCard>
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
Plugin Routing Capabilities
</Text>
<FeatureList>
<FeatureItem>
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
<Text $size="s">Read pathname via window.location</Text>
</FeatureItem>
<FeatureItem>
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
<Text $size="s">Listen to popstate events for route changes</Text>
</FeatureItem>
<FeatureItem>
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
<Text $size="s">Use visibility.routes in config for conditional rendering</Text>
</FeatureItem>
<FeatureItem>
<Icon iconName="info" variant="filled" $color="#2196f3" />
<Text $size="s">Navigate using standard anchor tags or window APIs</Text>
</FeatureItem>
<FeatureItem>
<Icon iconName="warning" variant="filled" $color="#ff9800" />
<Text $size="s">Next.js router hooks not available (outside RouterContext)</Text>
</FeatureItem>
</FeatureList>
</InfoCard>
<InfoCard style={{ background: 'linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%)', borderLeft: '4px solid #ff9800' }}>
<Text $weight="bold" $size="s" style={{ marginBottom: '8px', display: 'block' }}>
Important Note
</Text>
<Text $size="xs" style={{ lineHeight: '1.6', marginBottom: '8px' }}>
Plugins render outside the Next.js RouterContext, so useRouter() and usePathname()
are not available. Instead, use:
</Text>
<ul style={{ fontSize: '0.75rem', lineHeight: '1.8', marginLeft: '20px' }}>
<li><code>window.location.pathname</code> - Get current path</li>
<li><code>window.addEventListener('popstate')</code> - Detect route changes</li>
<li>Plugin config <code>visibility.routes</code> - Control when plugin appears</li>
</ul>
</InfoCard>
<InfoCard style={{ background: 'linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%)', borderLeft: '4px solid #4caf50' }}>
<Text $weight="bold" $size="s" style={{ marginBottom: '8px', display: 'block' }}>
Best Practice
</Text>
<Text $size="xs" style={{ lineHeight: '1.6' }}>
For route-aware plugins, use the <code>visibility.routes</code> config option with
glob patterns (e.g., <code>["/docs/*", "!/docs/secret"]</code>). The plugin system
automatically shows/hides your plugin based on the current route!
</Text>
</InfoCard>
</TabContent>
);
if (authLoading) {
return (
<Box $padding="small">
<Loading />
</Box>
);
}
return (
<>
<StyledButton
ref={triggerRef}
onPress={handleButtonPress}
aria-label="Open Plugin Showcase"
aria-expanded={isOpen}
>
<Icon iconName="extension" variant="filled" />
{customMessage}
</StyledButton>
<StyledPopover
triggerRef={triggerRef}
isOpen={isOpen}
onOpenChange={setIsOpen}
offset={10}
>
{loadingDemo ? (
<Box
$padding="large"
$align="center"
$justify="center"
$height="300px"
>
<Loading />
<Text $size="s" style={{ marginTop: '16px', color: '#666' }}>
Fake Loading...
</Text>
</Box>
) : (
<>
<TabContainer>
<Tab
$active={activeTab === 'user'}
onClick={() => setActiveTab('user')}
>
<Icon iconName="person" $size="s" /> User
</Tab>
<Tab
$active={activeTab === 'features'}
onClick={() => setActiveTab('features')}
>
<Icon iconName="settings" $size="s" /> Features
</Tab>
<Tab
$active={activeTab === 'system'}
onClick={() => setActiveTab('system')}
>
<Icon iconName="integration_instructions" $size="s" /> System
</Tab>
<Tab
$active={activeTab === 'routing'}
onClick={() => setActiveTab('routing')}
>
<Icon iconName="route" $size="s" /> Routing
</Tab>
</TabContainer>
{activeTab === 'user' && renderUserTab()}
{activeTab === 'features' && renderFeaturesTab()}
{activeTab === 'system' && renderSystemTab()}
{activeTab === 'routing' && renderRoutingTab()}
</>
)}
</StyledPopover>
</>
);
};
export default MyCustomComponent;
@@ -0,0 +1,109 @@
#central-menu-wrapper {
flex-direction: row;
align-self: stretch;
align-items: stretch;
gap: 25px;
}
#central-menu-wrapper > * {
display: flex;
align-items: center;
height: auto;
}
#central-menu-wrapper > a > div {
height: 100%;
margin: unset;
}
#central-menu-wrapper > a > div > svg {
width: 82px;
}
#central-menu-wrapper + div > button {
display: none;
}
#central-menu-wrapper #central-menu {
position: relative;
color: var(--c--theme--colors--greyscale-text);
display: inline-block;
}
#central-menu-wrapper #nav-button {
background: none;
border: none;
cursor: pointer;
height: 100%;
padding: 0 22px;
outline: none;
}
#central-menu-wrapper #nav-button:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
#central-menu-wrapper #nav-button.active {
background-color: var(--c--components--button--primary--background--color);
color: var(--c--theme--colors--greyscale-000);
}
[data-testid="od-menu-popover"] {
background: unset !important;
border: unset !important;
}
#nav-content {
position: absolute;
width: max-content;
background: var(--c--theme--colors--greyscale-000);
border-radius: 8px;
border: 1px solid var(--c--theme--colors--card-border);
border-top: 4px solid var(--c--components--button--primary--background--color);
max-width: 280px;
left: 50%;
transform: translateX(-50%);
padding: 4px 0 20px;
z-index: 1000;
}
#nav-content .menu-list {
list-style: none;
margin: 0;
padding: 0;
}
#nav-content .menu-category {
font-weight: bold;
display: block;
margin: 20px 24px 8px;
}
#nav-content .menu-entries {
list-style: none;
padding: 0;
margin: 0;
}
#nav-content .menu-link {
display: flex;
padding: 4px 24px;
align-items: center;
text-decoration: none;
color: inherit;
}
#nav-content .menu-link:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
#nav-content .menu-icon {
width: 24px;
height: 24px;
margin-right: 8px;
}
@@ -0,0 +1,283 @@
import './MyCustomHeaderMenu.css';
import React, { useState, useRef, useEffect } from 'react';
import { Button, Popover } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { useAuthQuery } from 'impress/features/auth/api';
import { Icon, Loading } from 'impress/components';
interface NavigationCategory {
identifier: string;
display_name: string;
entries: NavigationEntry[];
}
interface NavigationEntry {
identifier: string;
link: string;
target: string;
display_name: string;
icon_url: string;
}
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd;
transition: opacity 0.2s ease-in-out;
`;
const StyledButton = styled(Button)`
cursor: pointer;
border: none;
background: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: Marianne, Arial, serif;
font-weight: 500;
font-size: 0.938rem;
padding: 0;
text-wrap: nowrap;
`;
// Fake navigation response for development/debugging
const fakeNavigationData = {
categories: [
{
identifier: 'fake-cat',
display_name: 'Dummy Category',
entries: [
{
identifier: 'fake-entry-1',
link: 'https://www.google.com',
target: '_blank',
display_name: 'Google',
icon_url: 'https://placehold.co/24',
},
{
identifier: 'fake-entry-2',
link: 'https://www.example.com',
target: '_blank',
display_name: 'Example',
icon_url: 'https://placehold.co/24',
},
],
},
],
};
const formatLanguage = (language: string): string => {
const [lang, region] = language.split('-');
return region
? `${lang}-${lang.toUpperCase()}`
: `${language}-${language.toUpperCase()}`;
};
const fetchNavigation = async (
language: string,
baseUrl: string,
): Promise<NavigationCategory[] | null> => {
// Uncomment below for development/debugging with fake data
return fakeNavigationData.categories;
try {
if (!baseUrl) {
console.warn('[CentralMenu] ICS_BASE_URL not configured');
return null;
}
const response = await fetch(
`${baseUrl}/navigation.json?language=${language}`,
{
method: 'GET',
credentials: 'include',
redirect: 'follow',
},
);
if (response.ok) {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const jsonData = await response.json() as Record<string, unknown>;
if (
jsonData &&
typeof jsonData === 'object' &&
'categories' in jsonData &&
Array.isArray(jsonData.categories)
) {
return jsonData.categories as NavigationCategory[];
} else {
console.warn('[CentralMenu] Invalid JSON format in navigation response.');
return null;
}
} else {
console.warn('[CentralMenu] Unexpected content type:', contentType);
return null;
}
} else {
console.warn('[CentralMenu] Navigation fetch failed. Status:', response.status);
return null;
}
} catch (error) {
console.error('[CentralMenu] Error fetching navigation:', error);
return null;
}
};
interface CentralMenuProps {
icsBaseUrl?: string;
portalBaseUrl?: string;
}
const CentralMenu: React.FC<CentralMenuProps> = ({
icsBaseUrl = '',
portalBaseUrl = '',
}) => {
const { i18n, t } = useTranslation();
const { data: auth } = useAuthQuery();
const [isOpen, setIsOpen] = useState(false);
const [navigation, setNavigation] = useState<NavigationCategory[] | null>(null);
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const iframeRef = useRef<HTMLIFrameElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleToggle = () => {
setIsOpen(!isOpen);
};
const handleIframeLoad = async () => {
const language = i18n.language ? formatLanguage(i18n.language) : 'en-US';
const navData = await fetchNavigation(language, icsBaseUrl);
if (navData) {
setNavigation(navData);
setStatus('success');
} else {
setStatus('error');
}
};
// Handle language changes - refetch navigation when language changes
useEffect(() => {
// Only refetch if iframe has already loaded (navigation exists or error occurred)
if (status !== 'loading') {
handleIframeLoad();
}
}, [i18n.language]);
if (!auth?.id) {
return null;
}
const renderNavigation = () => {
if (!navigation) {
return null;
}
return navigation.map((category) => (
<li
key={category.identifier}
data-testid="od-menu-app-category"
>
<span className="menu-category">{category.display_name}</span>
<ul className="menu-entries" data-testid="od-menu-apps">
{category.entries.map((entry) => (
<li key={entry.identifier} data-testid="od-menu-app">
<a
href={entry.link}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
target={entry.target}
className="menu-link"
role="menuitem"
>
<img
alt={entry.display_name}
src={entry.icon_url}
className="menu-icon"
width={24}
height={24}
/>
<span>{entry.display_name}</span>
</a>
</li>
))}
</ul>
</li>
));
};
return (
<>
{icsBaseUrl && (
<iframe
ref={iframeRef}
title="opendesk login"
src={`${icsBaseUrl}/silent`}
data-testid="od-menu-iframe"
hidden
onLoad={handleIframeLoad}
style={{ display: 'none', visibility: 'hidden' }}
/>
)}
<nav id="central-menu" role="navigation" aria-label="Main Navigation">
<StyledButton
id="nav-button"
className={isOpen ? 'active' : ''}
ref={triggerRef}
onPress={handleToggle}
aria-label="Toggle Central Menu"
aria-expanded={isOpen}
aria-controls="nav-content"
data-testid="od-menu-open-button"
>
<Icon iconName="apps" aria-hidden="true" />
</StyledButton>
<StyledPopover
triggerRef={triggerRef}
isOpen={isOpen}
onOpenChange={setIsOpen}
offset={0}
containerPadding={0}
data-testid="od-menu-popover"
>
<div id="nav-content">
<ul className="menu-list">
{status === 'error' ? (
<li className="menu-category" data-testid="od-menu-app-category">
<small>
{t('Navigation could not be accessed.')}
{portalBaseUrl && (
<>
<br />
<a
href={portalBaseUrl}
onClick={(e) => e.stopPropagation()}
>
{t('Try logging out and logging in again.')}
</a>
</>
)}
</small>
</li>
) : status === 'loading' ? (
<li className="menu-category" data-testid="od-menu-app-category">
<Loading />
</li>
) : (
renderNavigation()
)}
</ul>
</div>
</StyledPopover>
</nav>
</>
);
};
export default CentralMenu;
@@ -0,0 +1,220 @@
import React, { useEffect } from 'react';
interface ThemeColors {
// Primary colors
'primary-100'?: string;
'primary-200'?: string;
'primary-300'?: string;
'primary-400'?: string;
'primary-500'?: string;
'primary-600'?: string;
'primary-700'?: string;
'primary-800'?: string;
'primary-900'?: string;
'primary-text'?: string;
'primary-bg'?: string;
// Secondary colors
'secondary-100'?: string;
'secondary-200'?: string;
'secondary-300'?: string;
'secondary-400'?: string;
'secondary-500'?: string;
'secondary-600'?: string;
'secondary-700'?: string;
'secondary-800'?: string;
'secondary-900'?: string;
'secondary-text'?: string;
'secondary-bg'?: string;
// Info colors
'info-100'?: string;
'info-200'?: string;
'info-300'?: string;
'info-400'?: string;
'info-500'?: string;
'info-600'?: string;
'info-700'?: string;
'info-800'?: string;
'info-900'?: string;
'info-text'?: string;
// Success colors
'success-100'?: string;
'success-200'?: string;
'success-300'?: string;
'success-400'?: string;
'success-500'?: string;
'success-600'?: string;
// Warning colors
'warning-100'?: string;
'warning-200'?: string;
'warning-300'?: string;
'warning-400'?: string;
'warning-500'?: string;
'warning-600'?: string;
// Error colors
'error-100'?: string;
'error-200'?: string;
'error-300'?: string;
'error-400'?: string;
'error-500'?: string;
'error-600'?: string;
// Greyscale
'greyscale-000'?: string;
'greyscale-100'?: string;
'greyscale-200'?: string;
'greyscale-300'?: string;
'greyscale-400'?: string;
'greyscale-500'?: string;
'greyscale-600'?: string;
'greyscale-700'?: string;
'greyscale-800'?: string;
'greyscale-900'?: string;
}
interface ThemeSpacings {
'xxxxs'?: string;
'xxxs'?: string;
'xxs'?: string;
'xs'?: string;
's'?: string;
'b'?: string;
'st'?: string;
't'?: string;
'l'?: string;
'xl'?: string;
'xxl'?: string;
'xxxl'?: string;
}
interface ThemeFontSizes {
'xxxxs'?: string;
'xxxs'?: string;
'xxs'?: string;
'xs'?: string;
's'?: string;
'm'?: string;
'l'?: string;
'xl'?: string;
'xxl'?: string;
'xxxl'?: string;
'xxxxl'?: string;
}
interface ThemingComponentProps {
colors?: ThemeColors;
spacings?: ThemeSpacings;
fontSizes?: ThemeFontSizes;
customVars?: Record<string, string>;
}
/**
* ThemingComponent - Override Cunningham design tokens from a plugin
*
* This component allows plugins to customize the host application's theme by
* setting CSS custom properties (CSS variables).
*
* IMPORTANT: This component ONLY updates CSS variables, not the Zustand store.
* This means:
* - Components using CSS variables like `var(--c--theme--colors--primary-text)` WILL update
* - Components using JS values like `colorsTokens['primary-text']` will NOT update
*
* For host components to be themeable by plugins, they must use CSS variables instead of
* JS token values. Example:
*
* Don't use: `$color={colorsTokens['primary-text']}`
* Use instead: `$theme="primary" $variation="text"` (which generates CSS vars)
*
* @example
* ```tsx
* <ThemingComponent
* colors={{
* 'primary-500': '#FF6B6B',
* 'primary-600': '#EE5A6F',
* 'primary-text': '#FF6B6B',
* 'secondary-500': '#4ECDC4',
* }}
* spacings={{
* 's': '12px',
* 'm': '24px',
* }}
* customVars={{
* '--custom-header-height': '80px',
* }}
* />
* ```
*/
const ThemingComponent: React.FC<ThemingComponentProps> = ({
colors = {},
spacings = {},
fontSizes = {},
customVars = {},
}) => {
useEffect(() => {
const root = document.documentElement;
// Store original values for cleanup
const originalValues: Record<string, string> = {};
console.log('[ThemingComponent] Applying CSS variable overrides');
// Apply CSS custom properties (for CSS variable-based usage)
Object.entries(colors).forEach(([key, value]) => {
const cssVar = `--c--theme--colors--${key}`;
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
root.style.setProperty(cssVar, value);
});
// Apply spacing tokens
Object.entries(spacings).forEach(([key, value]) => {
const cssVar = `--c--theme--spacings--${key}`;
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
root.style.setProperty(cssVar, value);
});
// Apply font size tokens
Object.entries(fontSizes).forEach(([key, value]) => {
const cssVar = `--c--theme--font--sizes--${key}`;
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
root.style.setProperty(cssVar, value);
});
// Apply custom CSS variables
Object.entries(customVars).forEach(([key, value]) => {
originalValues[key] = root.style.getPropertyValue(key);
root.style.setProperty(key, value);
});
console.log('[ThemingComponent] Applied CSS variable overrides:', {
colors: Object.keys(colors).length,
spacings: Object.keys(spacings).length,
fontSizes: Object.keys(fontSizes).length,
customVars: Object.keys(customVars).length,
});
// Cleanup: restore original values on unmount
return () => {
console.log('[ThemingComponent] Restoring original CSS variables');
// Restore CSS variables
Object.entries(originalValues).forEach(([cssVar, originalValue]) => {
if (originalValue) {
root.style.setProperty(cssVar, originalValue);
} else {
root.style.removeProperty(cssVar);
}
});
console.log('[ThemingComponent] Restored original CSS variables');
};
}, [colors, spacings, fontSizes, customVars]);
// This component doesn't render anything visible
return null;
};
export default ThemingComponent;
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import ThemingComponent from './ThemingComponent';
/**
* ThemingDemo - Interactive demo component to test theme overrides
*
* This component demonstrates the ThemingComponent by providing
* a UI to dynamically change theme tokens and see the results.
*/
const ThemingDemo: React.FC = () => {
const [primaryColor, setPrimaryColor] = useState('#667eea');
const [secondaryColor, setSecondaryColor] = useState('#764ba2');
const [enabled, setEnabled] = useState(true);
return (
<>
{enabled && (
<ThemingComponent
colors={{
// Primary color variants
'primary-500': primaryColor,
'primary-600': secondaryColor,
'primary-text': primaryColor,
'primary-bg': primaryColor,
// Secondary color variants
'secondary-500': secondaryColor,
'secondary-text': secondaryColor,
'secondary-bg': secondaryColor,
}}
customVars={{
'--demo-gradient': `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor} 100%)`,
}}
/>
)}
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
backgroundColor: 'white',
border: '1px solid var(--c--theme--colors--greyscale-300)',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
zIndex: 9999,
minWidth: '300px',
}}>
<h3 style={{
margin: '0 0 16px 0',
fontSize: '16px',
fontWeight: 'bold',
color: 'var(--c--theme--colors--greyscale-900)',
}}>
🎨 Theme Override Demo
</h3>
<div style={{ marginBottom: '12px' }}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
cursor: 'pointer',
}}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Enable Theme Override
</label>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
marginBottom: '4px',
color: 'var(--c--theme--colors--greyscale-700)',
}}>
Primary Color
</label>
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
disabled={!enabled}
style={{
width: '100%',
height: '36px',
border: '1px solid var(--c--theme--colors--greyscale-300)',
borderRadius: '6px',
cursor: enabled ? 'pointer' : 'not-allowed',
}}
/>
<span style={{
fontSize: '11px',
color: 'var(--c--theme--colors--greyscale-500)',
}}>
{primaryColor}
</span>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
marginBottom: '4px',
color: 'var(--c--theme--colors--greyscale-700)',
}}>
Secondary Color
</label>
<input
type="color"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
disabled={!enabled}
style={{
width: '100%',
height: '36px',
border: '1px solid var(--c--theme--colors--greyscale-300)',
borderRadius: '6px',
cursor: enabled ? 'pointer' : 'not-allowed',
}}
/>
<span style={{
fontSize: '11px',
color: 'var(--c--theme--colors--greyscale-500)',
}}>
{secondaryColor}
</span>
</div>
<div style={{
marginTop: '16px',
padding: '12px',
background: 'var(--demo-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%))',
borderRadius: '8px',
color: 'white',
fontSize: '12px',
textAlign: 'center',
fontWeight: '600',
}}>
Preview: Gradient with current colors
</div>
<div style={{
marginTop: '12px',
fontSize: '11px',
color: 'var(--c--theme--colors--greyscale-600)',
lineHeight: '1.4',
}}>
💡 This demo overrides primary-500, primary-600, and secondary-500 css variables. It only works with CSS.
</div>
</div>
</>
);
};
export default ThemingDemo;
@@ -0,0 +1,2 @@
// This file is just to satisfy webpack's entry point requirement
// The actual component is exposed via Module Federation
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"*": ["./@mf-types/*"],
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,104 @@
const path = require('path');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const {
NativeFederationTypeScriptHost,
} = require('@module-federation/native-federation-typescript/webpack');
const {
NativeFederationTypeScriptHost: NativeFederationTypeScriptHostCore,
} = require('@module-federation/native-federation-typescript');
const moduleFederationConfig = {
name: 'plugin_frontend',
filename: 'remoteEntry.js',
exposes: {
'./MyCustomComponent': './src/MyCustomComponent.tsx',
'./MyCustomHeaderMenu': './src/MyCustomHeaderMenu.tsx',
'./ThemingComponent': './src/ThemingComponent.tsx',
'./ThemingDemo': './src/ThemingDemo.tsx',
},
remotes: {
impress: 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
'styled-components': { singleton: true },
'react-aria-components': { singleton: true },
'@openfun/cunningham-react': { singleton: true },
'react-i18next': { singleton: true },
'yjs': { singleton: true },
'@gouvfr-lasuite/ui-kit': { singleton: true },
'clsx': { singleton: true },
'cmdk': { singleton: true },
'react-intersection-observer': { singleton: true },
'@tanstack/react-query': { singleton: true },
'zustand': { singleton: true },
},
};
let mfTypesReady;
const ensureFederatedTypesPlugin = {
apply(compiler) {
compiler.hooks.beforeCompile.tapPromise(
'EnsureFederatedTypes',
async () => {
if (!mfTypesReady) {
const downloader = NativeFederationTypeScriptHostCore.raw({
moduleFederationConfig,
});
mfTypesReady = downloader.writeBundle();
}
await mfTypesReady;
},
);
},
};
module.exports = (env, argv) => {
const dev = argv.mode !== 'production';
return {
entry: './src/index.tsx',
mode: dev ? 'development' : 'production',
devServer: dev ? {
port: 3002,
hot: true,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
},
} : undefined,
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: dev ? 'http://localhost:3002/' : undefined,
},
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx'],
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new ModuleFederationPlugin(moduleFederationConfig),
...(dev
? [
ensureFederatedTypesPlugin,
NativeFederationTypeScriptHost({
moduleFederationConfig,
}),
]
: []),
],
};
};
+3 -3
View File
@@ -1,3 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_SW_DEACTIVATED=
NEXT_PUBLIC_PUBLISH_AS_MIT=true
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_SW_DEACTIVATED=false
NEXT_PUBLIC_PUBLISH_AS_MIT=false
@@ -1,3 +1,5 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_PUBLISH_AS_MIT=false
NEXT_PUBLIC_SW_DEACTIVATED=true
NEXT_PUBLIC_DEVELOP_PLUGINS=false
+5
View File
@@ -35,3 +35,8 @@ yarn-error.log*
*.tsbuildinfo
service-worker.js
# Font embedding
public/assets/fonts/emoji/*
!public/assets/fonts/emoji/fallback.png
public/assets/fonts/Marianne/*
@@ -1,2 +1,3 @@
next-env.d.ts
service-worker.js
public/assets/fonts/*
+305
View File
@@ -0,0 +1,305 @@
const path = require('path');
const ts = require('typescript');
/**
* @file This file configures Module Federation for the 'impress' application.
* It automatically exposes components and shares dependencies.
*/
/**
* Extracts the root name of a package from an import specifier.
* e.g., '@scope/name/foo' becomes '@scope/name', and 'next/link' becomes 'next'.
* @param {string} spec - The import specifier.
* @returns {string} The root package name.
*/
function pkgRootName(spec) {
// '@scope/name/foo' -> '@scope/name', 'next/link' -> 'next'
if (spec.startsWith('@')) {
const [scope, name] = spec.split('/');
return `${scope}/${name || ''}`;
}
return spec.split('/')[0];
}
/**
* Checks if an import specifier is a relative path or a path alias.
* @param {string} spec - The import specifier.
* @returns {boolean} True if the path is relative or an alias.
*/
function isRelativeOrAlias(spec) {
return spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('@/');
}
/**
* Builds the key for the 'exposes' object from an absolute file path.
* This creates a public path like './components/Button' from a file path.
* @param {string} absPath - The absolute path to the file.
* @param {string} root - The project root directory.
* @returns {string} The key for the 'exposes' object.
*/
function buildExposeKey(absPath, root) {
const relPath = path.relative(root, absPath).replace(/\\/g, '/'); // Convert backslashes to forward slashes
// Remove 'src/' prefix to create cleaner public paths
const pathWithoutSrc = relPath.startsWith('src/')
? relPath.substring(4)
: relPath;
const relNoExt = pathWithoutSrc.replace(/\.(t|j)sx?$/, ''); // Remove file extension
// Remove '/index' from the end of the path to allow importing the directory
const withoutIndex = relNoExt.replace(/\/index$/, '');
return './' + withoutIndex;
}
/**
* Scans specified folders for TypeScript/JavaScript files to expose via Module Federation.
* It uses the TypeScript compiler API to find all files with actual runtime exports.
* @param {string[]} folders - An array of folder paths to scan.
* @returns {{exposes: Record<string, string>, program: ts.Program}} An object containing the exposed modules and the TypeScript program instance.
*/
function makeExposes(folders) {
const projectRoot = process.cwd();
const tsconfigPath = path.resolve(projectRoot, 'tsconfig.json');
// Read and parse the tsconfig.json file
const cfg = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
if (cfg.error) {
throw new Error(formatTsError('Failed to read tsconfig', cfg.error));
}
const parsed = ts.parseJsonConfigFileContent(
cfg.config,
ts.sys,
path.dirname(tsconfigPath),
undefined,
tsconfigPath,
);
// Create a TypeScript program to analyze the source files
const program = ts.createProgram({
rootNames: parsed.fileNames,
options: parsed.options,
});
const checker = program.getTypeChecker();
const dirSet = new Set(
folders.map((f) => path.resolve(process.cwd(), f).replace(/\\/g, '/')),
);
const exposes = {};
const used = new Set(); // Tracks used expose keys to detect duplicates
for (const sf of program.getSourceFiles()) {
// Skip declaration files (.d.ts)
if (sf.isDeclarationFile) {
continue;
}
const abs = sf.fileName.replace(/\\/g, '/');
// Only include files from the specified folders
if (
![...dirSet].some((dir) =>
abs.startsWith(dir.endsWith('/') ? dir : dir + '/'),
)
) {
continue;
}
// Only include valid script files
const ext = path.extname(abs).toLowerCase();
if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
continue;
}
// Exclude node_modules, test files, and mocks
if (
abs.includes('/node_modules/') ||
/[.-](test|spec|stories)\.(t|j)sx?$/.test(abs) ||
abs.includes('/__tests__/') ||
abs.includes('/__mocks__/')
) {
continue;
}
const moduleSymbol = sf.symbol;
if (!moduleSymbol) {
continue;
}
// Check if the module has any runtime exports (values, not just types)
const hasRuntime = checker.getExportsOfModule(moduleSymbol).some((sym) => {
const tgt =
sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
return (tgt.getFlags() & ts.SymbolFlags.Value) !== 0;
});
if (!hasRuntime) {
continue;
}
const key = buildExposeKey(abs, process.cwd());
const rel = './' + path.relative(process.cwd(), abs).replace(/\\/g, '/');
// Handle duplicate keys, preferring non-index files
if (used.has(key)) {
const isNewFileIndex = path.basename(abs, path.extname(abs)) === 'index';
const existingPath = exposes[key];
console.warn(
`[makeExposes] Duplicate expose key "${key}". ` +
`${isNewFileIndex ? 'Skipping index file' : 'Keeping first occurrence'}: "${rel}". ` +
`Existing: "${existingPath}".`,
);
if (isNewFileIndex) {
continue; // Index files lose in case of conflicts
}
// Otherwise: the first file encountered keeps the key
}
exposes[key] = rel;
used.add(key);
}
return { exposes, program };
}
/**
* Resolves the installed version of a package.
* @param {string} pkgName - The name of the package.
* @param {string} projectRoot - The root directory of the project.
* @returns {string|null} The installed version, or null if not found.
*/
function resolveInstalledVersion(pkgName, projectRoot) {
try {
const pkgJsonPath = require.resolve(`${pkgName}/package.json`, {
paths: [projectRoot],
});
const { version } = require(pkgJsonPath);
return version || null; // e.g. "18.3.1"
} catch {
return null; // not installed or cannot resolve
}
}
/**
* Automatically determines which dependencies to share based on the imports
* of the exposed files.
* @param {object} options - The options for making shared dependencies.
* @param {ts.Program} options.program - The TypeScript program instance.
* @param {Record<string, string>} options.exposes - The exposed modules.
* @param {string[]} [options.include=[]] - A list of packages to include in sharing (e.g., 'react', 'styled-components').
* @param {string[]} [options.exclude=[]] - A list of packages to exclude from sharing.
* @returns {Record<string, object>} The shared dependencies configuration.
*/
function makeSharedAuto({ program, exposes, include = [], exclude = [] }) {
const projectRoot = process.cwd();
const { dependencies = {}, peerDependencies = {} } = require(
path.join(projectRoot, 'package.json'),
);
const declared = { ...dependencies, ...peerDependencies };
const wanted = new Set(include);
const exposedFiles = new Set(
Object.values(exposes).map((p) =>
path.resolve(projectRoot, p.replace(/^\.\//, '')).replace(/\\/g, '/'),
),
);
// Analyze imports of exposed files to find dependencies to share
for (const sf of program.getSourceFiles()) {
const abs = sf.fileName.replace(/\\/g, '/');
if (!exposedFiles.has(abs)) {
continue;
}
const specs = (sf.imports || []).map((n) => n.text).filter(Boolean);
for (const spec of specs) {
if (isRelativeOrAlias(spec)) {
continue;
}
wanted.add(pkgRootName(spec));
}
}
// Prune excluded packages
for (const name of [...wanted]) {
if (exclude.includes(name)) {
wanted.delete(name);
}
}
// Build the shared object for Webpack
return Object.fromEntries(
[...wanted].map((name) => {
// Prefer the actually installed version for 'requiredVersion'
const resolved = resolveInstalledVersion(name, projectRoot);
// Fallback to the version range from package.json if resolution fails
const range = declared[name] || undefined;
const requiredVersion = resolved || range; // Use concrete version when possible
return [
name,
{
singleton: true,
eager: false,
requiredVersion,
strictVersion: !!resolved, // Enforce exact match if we know the concrete version
allowNodeModulesSuffixMatch: true,
},
];
}),
);
}
/**
* Formats a TypeScript diagnostic error into a readable string.
* @param {string} context - A string describing the context of the error.
* @param {ts.Diagnostic} diag - The TypeScript diagnostic object.
* @returns {string} The formatted error message.
*/
function formatTsError(context, diag) {
const message = ts.flattenDiagnosticMessageText(diag.messageText, '\n');
return `${context}: ${message}`;
}
// ---------- Final Module Federation Config ----------
// Define which folders should be scanned for exposable modules.
const foldersToExpose = [
'./src/components',
'./src/features/auth',
'./src/cunningham',
];
const { exposes, program } = makeExposes(foldersToExpose);
// Automatically determine shared dependencies based on the imports of exposed files.
const shared = makeSharedAuto({
program,
exposes,
include: ['react', 'react-dom', 'styled-components', 'yjs'],
exclude: ['next'], // Packages to never share
});
const moduleFederationConfig = {
name: 'impress',
filename: 'static/chunks/remoteEntry.js',
extraOptions: { skipSharingNextInternals: true },
// The modules exposed by this federated module.
exposes,
// The shared dependencies for this federated module.
shared,
// TypeScript type generation for plugin development.
// This is enabled when NEXT_PUBLIC_DEVELOP_PLUGINS is 'true'.
dts:
process.env.NEXT_PUBLIC_DEVELOP_PLUGINS === 'true'
? {
generateTypes: {
extractThirdParty: true,
tsConfigPath: './tsconfig.json',
},
}
: false,
};
module.exports = { moduleFederationConfig };
+93 -14
View File
@@ -1,7 +1,15 @@
const crypto = require('crypto');
const path = require('path');
const {
NativeFederationTypeScriptRemote,
} = require('@module-federation/native-federation-typescript/webpack');
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
const CopyPlugin = require('copy-webpack-plugin');
const { InjectManifest } = require('workbox-webpack-plugin');
const { moduleFederationConfig } = require('./mf.config.js');
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
/** @type {import('next').NextConfig} */
@@ -19,7 +27,18 @@ const nextConfig = {
env: {
NEXT_PUBLIC_BUILD_ID: buildId,
},
webpack(config, { isServer }) {
webpack(config, { isServer, dev }) {
// Prevent rebuild loops by ignoring node_modules and generated types/outputs
config.watchOptions = {
ignored: [
'**/node_modules/**',
'**/.next/**',
'**/dist/**',
'**/@mf-types/**',
'**/@mf-types.zip',
],
};
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg'),
@@ -41,23 +60,83 @@ const nextConfig = {
},
);
if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
config.plugins.push(
new InjectManifest({
swSrc: './src/features/service-worker/service-worker.ts',
swDest: '../public/service-worker.js',
include: [
({ asset }) => {
return !!asset.name.match(/.*(static).*/);
},
],
}),
);
// Copy necessary fonts from node_modules to public directory during build or dev
config.plugins.push(
new CopyPlugin({
patterns: [
{
from: path.resolve(
__dirname,
'../../node_modules/emoji-datasource-apple/img/apple/64',
),
to: path.resolve(__dirname, 'public/assets/fonts/emoji'),
force: true,
},
{
from: path.resolve(
__dirname,
'../../node_modules/@gouvfr-lasuite/ui-kit/dist/assets/fonts/Marianne',
),
to: path.resolve(__dirname, 'public/assets/fonts/Marianne'),
force: true,
},
],
}),
);
if (!isServer) {
// Host configuration for Module Federation (PluginSystem)
config.plugins.push(new NextFederationPlugin(moduleFederationConfig));
if (dev && process.env.NEXT_PUBLIC_DEVELOP_PLUGINS === 'true') {
console.log('[DEBUG] moduleFederationConfig:');
console.log(moduleFederationConfig);
config.plugins.push(
// Allow the plugin to get types/intellisense from the host at development time
NativeFederationTypeScriptRemote({
moduleFederationConfig,
}),
);
// Copy the generated @mf-types.zip to .next/static/chunks so it's served at /_next/static/chunks/
const mfTypesSource = path.resolve(__dirname, '.next', '@mf-types.zip');
const mfTypesDest = path.resolve(
__dirname,
'.next',
'static',
'chunks',
'@mf-types.zip',
);
config.plugins.push(
new CopyPlugin({
patterns: [
{
from: mfTypesSource,
to: mfTypesDest,
force: true,
noErrorOnMissing: true,
},
],
}),
);
}
if (process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
config.plugins.push(
new InjectManifest({
swSrc: './src/features/service-worker/service-worker.ts',
swDest: '../public/service-worker.js',
include: [
({ asset }) => {
return !!asset.name.match(/.*(static).*/);
},
],
}),
);
}
}
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i;
return config;
},
};
+9 -3
View File
@@ -6,8 +6,8 @@
"license": "MIT",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prettier --check . && yarn stylelint && next build",
"dev": "NEXT_PRIVATE_LOCAL_WEBPACK=true next dev",
"build": "prettier --check . && yarn stylelint && NEXT_PRIVATE_LOCAL_WEBPACK=true next build",
"build:ci": "cp .env.development .env.local && yarn build",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
"start": "npx -y serve@latest out",
@@ -30,10 +30,13 @@
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.16.1",
"@hocuspocus/provider": "2.15.2",
"@module-federation/runtime": "0.19.1",
"@module-federation/runtime-core": "0.19.1",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "10.11.0",
@@ -43,6 +46,7 @@
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.5.0",
"i18next": "25.5.2",
@@ -65,6 +69,8 @@
"zustand": "5.0.8"
},
"devDependencies": {
"@module-federation/nextjs-mf": "8.8.42",
"@module-federation/native-federation-typescript": "0.6.2",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.87.4",
"@testing-library/dom": "10.4.1",
@@ -77,6 +83,7 @@
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.0.2",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.0.0",
"dotenv": "17.2.2",
"eslint-plugin-docs": "*",
@@ -90,7 +97,6 @@
"typescript": "*",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.101.3",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"
@@ -1,101 +0,0 @@
@font-face {
font-family: Marianne;
src:
url('Marianne-Thin.woff2') format('woff2'),
url('Marianne-Thin.woff') format('woff');
font-weight: 100;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Thin_Italic.woff2') format('woff2'),
url('Marianne-Thin_Italic.woff') format('woff');
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Light.woff2') format('woff2'),
url('Marianne-Light.woff') format('woff');
font-weight: 300;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Light_Italic.woff2') format('woff2'),
url('Marianne-Light_Italic.woff') format('woff');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Regular.woff2') format('woff2'),
url('Marianne-Regular.woff') format('woff');
font-weight: 400;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Regular_Italic.woff2') format('woff2'),
url('Marianne-Regular_Italic.woff') format('woff');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Medium.woff2') format('woff2'),
url('Marianne-Medium.woff') format('woff');
font-weight: 500;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Medium_Italic.woff2') format('woff2'),
url('Marianne-Medium_Italic.woff') format('woff');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Bold.woff2') format('woff2'),
url('Marianne-Bold.woff') format('woff');
font-weight: 700;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Bold_Italic.woff2') format('woff2'),
url('Marianne-Bold_Italic.woff') format('woff');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-ExtraBold.woff2') format('woff2'),
url('Marianne-ExtraBold.woff') format('woff');
font-weight: 800;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
url('Marianne-ExtraBold_Italic.woff') format('woff');
font-weight: 800;
font-style: italic;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -12,9 +12,14 @@ export const Icon = ({
variant = 'outlined',
...textProps
}: IconProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
const ariaHidden =
'aria-hidden' in textProps ? textProps['aria-hidden'] : !hasLabel;
return (
<Text
{...textProps}
aria-hidden={ariaHidden}
className={clsx('--docs--icon-bg', textProps.className, {
'material-icons-filled': variant === 'filled',
'material-icons': variant === 'outlined',
@@ -13,6 +13,7 @@ export interface TextProps extends BoxProps {
$ellipsis?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$textTransform?: CSSProperties['textTransform'];
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
@@ -43,6 +44,8 @@ export type TextType = ComponentPropsWithRef<typeof Text>;
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $textTransform }) =>
$textTransform && `text-transform: ${$textTransform};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}
${({ $size }) =>
$size &&
@@ -162,7 +162,6 @@ export const DropdownMenu = ({
menuItemRefs.current[index] = el;
}}
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
@@ -1,11 +1,5 @@
import { Command } from 'cmdk';
import {
PropsWithChildren,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useId, useRef, useState } from 'react';
import { hasChildrens } from '@/utils/children';
@@ -49,32 +43,23 @@ export const QuickSearch = ({
children,
}: PropsWithChildren<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null);
const [selectedValue, setSelectedValue] = useState<string>('');
const listId = useId();
const NO_SELECTION_VALUE = '__none__';
const [userInteracted, setUserInteracted] = useState(false);
const [selectedValue, setSelectedValue] = useState(NO_SELECTION_VALUE);
const isExpanded = userInteracted;
// Auto-select first item when children change
useEffect(() => {
if (!children) {
setSelectedValue('');
return;
const handleValueChange = (val: string) => {
if (userInteracted) {
setSelectedValue(val);
}
};
// Small delay for DOM to update
const timeoutId = setTimeout(() => {
const firstItem = ref.current?.querySelector('[cmdk-item]');
if (firstItem) {
const value =
firstItem.getAttribute('data-value') ||
firstItem.getAttribute('value') ||
firstItem.textContent?.trim() ||
'';
if (value) {
setSelectedValue(value);
}
}
}, 50);
return () => clearTimeout(timeoutId);
}, [children]);
const handleUserInteract = () => {
if (!userInteracted) {
setUserInteracted(true);
}
};
return (
<>
@@ -84,9 +69,9 @@ export const QuickSearch = ({
label={label}
shouldFilter={false}
ref={ref}
value={selectedValue}
onValueChange={setSelectedValue}
tabIndex={0}
value={selectedValue}
onValueChange={handleValueChange}
>
{showInput && (
<QuickSearchInput
@@ -95,11 +80,14 @@ export const QuickSearch = ({
inputValue={inputValue}
onFilter={onFilter}
placeholder={placeholder}
listId={listId}
isExpanded={isExpanded}
onUserInteract={handleUserInteract}
>
{inputContent}
</QuickSearchInput>
)}
<Command.List>
<Command.List id={listId} aria-label={label} role="listbox">
<Box>{children}</Box>
</Command.List>
</Command>
@@ -16,6 +16,9 @@ type Props = {
placeholder?: string;
children?: ReactNode;
withSeparator?: boolean;
listId?: string;
onUserInteract?: () => void;
isExpanded?: boolean;
};
export const QuickSearchInput = ({
loading,
@@ -24,6 +27,9 @@ export const QuickSearchInput = ({
placeholder,
children,
withSeparator: separator = true,
listId,
onUserInteract,
isExpanded,
}: Props) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
@@ -57,14 +63,19 @@ export const QuickSearchInput = ({
<Command.Input
autoFocus={true}
aria-label={t('Quick search input')}
aria-expanded={isExpanded}
aria-controls={listId}
onClick={(e) => {
e.stopPropagation();
onUserInteract?.();
}}
onKeyDown={() => onUserInteract?.()}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
data-testid="quick-search-input"
/>
</Box>
{separator && <HorizontalSeparator $withPadding={false} />}
@@ -12,6 +12,7 @@ import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
import { useResponsiveStore } from '@/stores/';
import { ConfigProvider } from './config/';
import { PluginSystemProvider } from './plugin/PluginSystemProvider';
export const DEFAULT_QUERY_RETRY = 1;
@@ -73,9 +74,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
<PluginSystemProvider>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
</PluginSystemProvider>
</CunninghamProvider>
</QueryClientProvider>
);
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { Resource } from 'i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { PluginConfig } from '@/core/plugin';
import { Theme } from '@/cunningham/';
import { FooterType } from '@/features/footer';
import { PostHogConf } from '@/services';
@@ -26,6 +27,7 @@ export interface ConfigResponse {
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
theme_customization?: ThemeCustomization;
plugins?: PluginConfig[];
}
const LOCAL_STORAGE_KEY = 'docs_config';
+1 -1
View File
@@ -1,2 +1,2 @@
export * from './AppProvider';
export * from './config';
export * from './plugin';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
export * from './PluginSystemProvider';
@@ -1,6 +1,5 @@
@import url('@gouvfr-lasuite/ui-kit/style');
@import url('./cunningham-tokens.css');
@import url('/assets/fonts/Marianne/Marianne-font.css');
:root {
/**
@@ -38,6 +37,13 @@
);
}
/**
* Button
*/
.c__button {
contain: content;
}
/**
* Modal
*/
@@ -69,29 +75,3 @@
.c__toast__container:has(.c__toast) {
z-index: 10000;
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('https://fonts.gstatic.com/s/inter/v18/UcCm3FwrK3iLTcvnUwQT9g.woff2')
format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.woff2')
format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
@@ -3,7 +3,7 @@ import { useRouter } from 'next/router';
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { useConfig } from '@/core';
import { useConfig } from '@/core/config';
import { HOME_URL } from '../conf';
import { useAuth } from '../hooks';
@@ -37,6 +37,8 @@ import {
AccessibleImageBlock,
CalloutBlock,
DividerBlock,
PdfBlock,
UploadLoaderBlock,
} from './custom-blocks';
import {
InterlinkingLinkInlineContent,
@@ -54,6 +56,8 @@ const baseBlockNoteSchema = withPageBreak(
callout: CalloutBlock,
divider: DividerBlock,
image: AccessibleImageBlock,
pdf: PdfBlock,
uploadLoader: UploadLoaderBlock,
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
@@ -18,6 +18,7 @@ import {
import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
getPdfReactSlashMenuItems,
} from './custom-blocks';
import { useGetInterlinkingMenuItems } from './custom-inline-content';
import XLMultiColumn from './xl-multi-column';
@@ -32,7 +33,10 @@ export const BlockNoteSuggestionMenu = () => {
DocsStyleSchema
>();
const { t } = useTranslation();
const basicBlocksName = useDictionary().slash_menu.page_break.group;
const dictionaryDate = useDictionary();
const basicBlocksName = dictionaryDate.slash_menu.page_break.group;
const fileBlocksName = dictionaryDate.slash_menu.file.group;
const getInterlinkingMenuItems = useGetInterlinkingMenuItems();
const getSlashMenuItems = useMemo(() => {
@@ -56,11 +60,12 @@ export const BlockNoteSuggestionMenu = () => {
getMultiColumnSlashMenuItems?.(editor) || [],
getPageBreakReactSlashMenuItems(editor),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
),
query,
),
);
}, [basicBlocksName, editor, getInterlinkingMenuItems, t]);
}, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]);
return (
<SuggestionMenuController
@@ -76,11 +76,23 @@ export const FileDownloadButton = ({
if (!url.includes('-unsafe')) {
const blob = (await exportResolveFileUrl(url)) as Blob;
downloadFile(blob, url.split('/').pop() || 'file');
downloadFile(
blob,
fileBlock.props.name || url.split('/').pop() || 'file',
);
} else {
const onConfirm = async () => {
const blob = (await exportResolveFileUrl(url)) as Blob;
downloadFile(blob, url.split('/').pop() || 'file (unsafe)');
const baseName =
fileBlock.props.name || url.split('/').pop() || 'file';
const regFindLastDot = /(\.[^/.]+)$/;
const unsafeName = baseName.includes('.')
? baseName.replace(regFindLastDot, '-unsafe$1')
: baseName + '-unsafe';
downloadFile(blob, unsafeName);
};
open(onConfirm);
@@ -100,6 +112,7 @@ export const FileDownloadButton = ({
<>
<Components.FormattingToolbar.Button
className="bn-button --docs--editor-file-download-button"
data-test="downloadfile"
label={
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
dict.formatting_toolbar.file_download.tooltip['file']
@@ -10,6 +10,7 @@ import {
imageRender,
imageToExternalHTML,
} from '@blocknote/core';
import { t } from 'i18next';
type ImageBlockConfig = typeof imageBlockConfig;
@@ -25,10 +26,73 @@ export const accessibleImageRender = (
const dom = imageRenderComputed.dom;
const imgSelector = dom.querySelector('img');
imgSelector?.setAttribute('alt', '');
imgSelector?.setAttribute('role', 'presentation');
imgSelector?.setAttribute('aria-hidden', 'true');
imgSelector?.setAttribute('tabindex', '-1');
const withCaption =
block.props.caption && dom.querySelector('.bn-file-caption');
const accessibleImageWithCaption = () => {
imgSelector?.setAttribute('alt', block.props.caption);
imgSelector?.removeAttribute('aria-hidden');
imgSelector?.setAttribute('tabindex', '0');
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
const captionElement = dom.querySelector('.bn-file-caption');
if (captionElement) {
const figureElement = document.createElement('figure');
// Copy all attributes from the original div
figureElement.className = dom.className;
const styleAttr = dom.getAttribute('style');
if (styleAttr) {
figureElement.setAttribute('style', styleAttr);
}
figureElement.style.setProperty('margin', '0');
Array.from(dom.children).forEach((child) => {
figureElement.appendChild(child.cloneNode(true));
});
// Replace the <p> caption with <figcaption>
const figcaptionElement = document.createElement('figcaption');
const originalCaption = figureElement.querySelector('.bn-file-caption');
if (originalCaption) {
figcaptionElement.className = originalCaption.className;
figcaptionElement.textContent = originalCaption.textContent;
originalCaption.parentNode?.replaceChild(
figcaptionElement,
originalCaption,
);
// Add explicit role and aria-label for better screen reader support
figureElement.setAttribute('role', 'img');
figureElement.setAttribute(
'aria-label',
t(`Image: {{title}}`, { title: figcaptionElement.textContent }),
);
}
// Return the figure element as the new dom
return {
...imageRenderComputed,
dom: figureElement,
};
}
};
const accessibleImage = () => {
imgSelector?.setAttribute('alt', '');
imgSelector?.setAttribute('role', 'presentation');
imgSelector?.setAttribute('aria-hidden', 'true');
imgSelector?.setAttribute('tabindex', '-1');
};
// Set accessibility attributes for the image
const result = withCaption ? accessibleImageWithCaption() : accessibleImage();
// Return the result if accessibleImageWithCaption created a figure, otherwise return original
if (result) {
return result;
}
return {
...imageRenderComputed,
@@ -25,8 +25,12 @@ export const CalloutBlock = createReactBlockSpec(
{
render: ({ block, editor, contentRef }) => {
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
const isEditable = editor.isEditable;
const toggleEmojiPicker = (e: React.MouseEvent) => {
if (!isEditable) {
return;
}
e.preventDefault();
e.stopPropagation();
setOpenEmojiPicker(!openEmojiPicker);
@@ -65,9 +69,13 @@ export const CalloutBlock = createReactBlockSpec(
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
cursor: ${isEditable ? 'pointer' : 'default'};
${isEditable &&
`
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
`}
$align="center"
$height="28px"
@@ -0,0 +1,86 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { insertOrUpdateBlock } from '@blocknote/core';
import {
AddFileButton,
ResizableFileBlockWrapper,
createReactBlockSpec,
} from '@blocknote/react';
import { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
const PDFBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="pdf"] {
width: fit-content;
}
`;
type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor'];
export const PdfBlock = createReactBlockSpec(
{
type: 'pdf',
content: 'none',
propSchema: {
name: { default: '' as const },
url: { default: '' as const },
caption: { default: '' as const },
showPreview: { default: true },
previewWidth: { default: undefined, type: 'number' },
},
isFileBlock: true,
fileBlockAccept: ['application/pdf'],
},
{
render: ({ editor, block, contentRef }) => {
const { t } = useTranslation();
const pdfUrl = block.props.url;
return (
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<PDFBlockStyle />
<ResizableFileBlockWrapper
buttonIcon={<Icon iconName="upload" />}
block={block}
editor={editor as unknown as FileBlockEditor}
buttonText={t('Add PDF')}
>
<Box
className="bn-visual-media"
role="presentation"
as="embed"
$width="100%"
$height="450px"
type="application/pdf"
src={pdfUrl}
contentEditable={false}
draggable={false}
onClick={() => editor.setTextCursorPosition(block)}
/>
</ResizableFileBlockWrapper>
</Box>
);
},
},
);
export const getPdfReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('PDF'),
onItemClick: () => {
insertOrUpdateBlock(editor, { type: 'pdf' });
},
aliases: [t('pdf'), t('document'), t('embed'), t('file')],
group,
icon: <Icon iconName="picture_as_pdf" $size="18px" />,
subtext: t('Embed a PDF file'),
},
];
@@ -0,0 +1,34 @@
import { createReactBlockSpec } from '@blocknote/react';
import { Box, Text } from '@/components';
import Loader from '../../assets/loader.svg';
import Warning from '../../assets/warning.svg';
export const UploadLoaderBlock = createReactBlockSpec(
{
type: 'uploadLoader',
propSchema: {
information: { default: '' as const },
type: {
default: 'loading' as const,
values: ['loading', 'warning'] as const,
},
},
content: 'none',
},
{
render: ({ block }) => {
return (
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
{block.props.type === 'warning' ? (
<Warning />
) : (
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
)}
<Text>{block.props.information}</Text>
</Box>
);
},
},
);
@@ -1,3 +1,5 @@
export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export * from './DividerBlock';
export * from './PdfBlock';
export * from './UploadLoaderBlock';
@@ -6,8 +6,6 @@ import { useMediaUrl } from '@/core/config';
import { sleep } from '@/utils';
import { checkDocMediaStatus, useCreateDocAttachment } from '../api';
import Loader from '../assets/loader.svg?url';
import Warning from '../assets/warning.svg?url';
import { DocsBlockNoteEditor } from '../types';
/**
@@ -33,52 +31,6 @@ const loopCheckDocMediaStatus = async (url: string) => {
}
};
const informationStatus = (src: string, text: string) => {
const loadingContainer = document.createElement('div');
loadingContainer.style.display = 'flex';
loadingContainer.style.alignItems = 'center';
loadingContainer.style.justifyContent = 'left';
loadingContainer.style.padding = '10px';
loadingContainer.style.color = '#666';
loadingContainer.className =
'bn-visual-media bn-audio bn-file-name-with-icon';
// Create an image element for the SVG
const imgElement = document.createElement('img');
imgElement.src = src;
// Create a text span
const textSpan = document.createElement('span');
textSpan.textContent = text;
textSpan.style.marginLeft = '8px';
textSpan.style.verticalAlign = 'middle';
imgElement.style.animation = 'spin 1.5s linear infinite';
// Add the spinner and text to the container
loadingContainer.appendChild(imgElement);
loadingContainer.appendChild(textSpan);
return loadingContainer;
};
const replaceUploadContent = (blockId: string, elementReplace: HTMLElement) => {
const blockEl = document.body.querySelector(
`.bn-block[data-id="${blockId}"]`,
);
blockEl
?.querySelector('.bn-visual-media-wrapper .bn-visual-media')
?.replaceWith(elementReplace);
blockEl
?.querySelector('.bn-file-block-content-wrapper .bn-audio')
?.replaceWith(elementReplace);
blockEl
?.querySelector('.bn-file-block-content-wrapper .bn-file-name-with-icon')
?.replaceWith(elementReplace);
};
export const useUploadFile = (docId: string) => {
const {
mutateAsync: createDocAttachment,
@@ -122,35 +74,55 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
// Delay to let the time to the dom to be rendered
const timoutId = setTimeout(() => {
replaceUploadContent(
blockId,
informationStatus(Loader.src, t('Analyzing file...')),
// Replace the resource block by a loading block
const { insertedBlocks, removedBlocks } = editor.replaceBlocks(
[blockId],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
},
},
],
);
loopCheckDocMediaStatus(url)
.then((response) => {
const block = editor.getBlock(blockId);
if (!block) {
if (insertedBlocks.length === 0 || removedBlocks.length === 0) {
return;
}
block.props = {
...block.props,
const loadingBlockId = insertedBlocks[0].id;
const removedBlock = removedBlocks[0];
removedBlock.props = {
...removedBlock.props,
url: `${mediaUrl}${response.file}`,
};
editor.updateBlock(blockId, block);
// Replace the loading block with the resource block (image, audio, video, pdf ...)
editor.replaceBlocks([loadingBlockId], [removedBlock]);
})
.catch((error) => {
console.error('Error analyzing file:', error);
replaceUploadContent(
blockId,
informationStatus(
Warning.src,
t('The antivirus has detected an anomaly in your file.'),
const loadingBlock = insertedBlocks[0];
if (!loadingBlock) {
return;
}
loadingBlock.props = {
...loadingBlock.props,
type: 'warning',
information: t(
'The antivirus has detected an anomaly in your file.',
),
);
};
editor.updateBlock(loadingBlock.id, loadingBlock);
});
}, 250);
@@ -29,7 +29,12 @@ export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
headings: [],
setHeadings: (editor) => {
const headingBlocks = editor?.document
.filter((block) => block.type === 'heading')
.filter(
(block) =>
block.type === 'heading' &&
block.props.level >= 1 &&
block.props.level <= 3,
)
.map((block) => ({
...block,
contentText: recursiveTextContent(
@@ -16,6 +16,19 @@ export const cssEditor = (readonly: boolean) => css`
font-weight: 400;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: inherit;
height: inherit;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
position: relative;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
}
@@ -91,6 +104,11 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
h1 {
font-size: 1.875rem;
}
@@ -9,3 +9,5 @@ export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';
export * from './uploadLoaderPDF';
export * from './uploadLoaderDocx';
@@ -0,0 +1,14 @@
import { Paragraph, TextRun } from 'docx';
import { DocsExporterDocx } from '../types';
export const blockMappingUploadLoaderDocx: DocsExporterDocx['mappings']['blockMapping']['uploadLoader'] =
(block) => {
return new Paragraph({
children: [
new TextRun(block.props.type === 'loading' ? '⏳' : '⚠️'),
new TextRun(' '),
new TextRun(block.props.information),
],
});
};
@@ -0,0 +1,13 @@
import { Text, View } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
export const blockMappingUploadLoaderPDF: DocsExporterPDF['mappings']['blockMapping']['uploadLoader'] =
(block) => {
return (
<View wrap={false} style={{ flexDirection: 'row', gap: 4 }}>
<Text>{block.props.type === 'loading' ? '⏳' : '⚠️'}</Text>
<Text>{block.props.information}</Text>
</View>
);
};
@@ -10,6 +10,7 @@ import {
useToastProvider,
} from '@openfun/cunningham-react';
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
import { cloneElement, isValidElement, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -76,12 +77,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setIsExporting(true);
const title = (doc.title || untitledDocument)
const filename = (doc.title || untitledDocument)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s/g, '-');
const documentTitle = doc.title || untitledDocument;
const html = templateSelected;
let exportDocument = editor.document;
if (html) {
@@ -93,15 +96,30 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
if (format === DocDownloadFormat.PDF) {
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
emojiSource: {
format: 'png',
builder(code) {
const emoji = jsonemoji.find((e) =>
e.unified.toLocaleLowerCase().includes(code.toLowerCase()),
);
if (emoji) {
return `/assets/fonts/emoji/${emoji.image}`;
}
return '/assets/fonts/emoji/fallback.png';
},
},
});
const rawPdfDocument = (await exporter.toReactPDFDocument(
exportDocument,
)) as React.ReactElement<DocumentProps>;
// Inject language for screen reader support and enable outlines (bookmarks)
// Add language, title and outline properties to improve PDF accessibility and navigation
const pdfDocument = isValidElement(rawPdfDocument)
? cloneElement(rawPdfDocument, {
language: i18next.language,
title: documentTitle,
pageMode: 'useOutlines',
})
: rawPdfDocument;
@@ -112,10 +130,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toBlob(exportDocument);
blobExport = await exporter.toBlob(exportDocument, {
documentOptions: { title: documentTitle },
sectionOptions: {},
});
}
downloadFile(blobExport, `${title}.${format}`);
downloadFile(blobExport, `${filename}.${format}`);
toast(
t('Your {{format}} was downloaded succesfully', {
@@ -144,7 +165,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
color="secondary"
fullWidth
onClick={() => onClose()}
disabled={isExporting}
>
{t('Cancel')}
</Button>
@@ -6,6 +6,7 @@ import {
blockMappingDividerDocx,
blockMappingImageDocx,
blockMappingQuoteDocx,
blockMappingUploadLoaderDocx,
} from './blocks-mapping';
import { inlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping';
import { DocsExporterDocx } from './types';
@@ -16,8 +17,13 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
...docxDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutDocx,
divider: blockMappingDividerDocx,
// We're using the file block mapping for PDF blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
quote: blockMappingQuoteDocx,
image: blockMappingImageDocx,
uploadLoader: blockMappingUploadLoaderDocx,
},
inlineContentMapping: {
...docxDefaultSchemaMappings.inlineContentMapping,
@@ -8,6 +8,7 @@ import {
blockMappingParagraphPDF,
blockMappingQuotePDF,
blockMappingTablePDF,
blockMappingUploadLoaderPDF,
} from './blocks-mapping';
import { inlineContentMappingInterlinkingLinkPDF } from './inline-content-mapping';
import { DocsExporterPDF } from './types';
@@ -23,6 +24,11 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
divider: blockMappingDividerPDF,
quote: blockMappingQuotePDF,
table: blockMappingTablePDF,
// We're using the file block mapping for PDF blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: pdfDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderPDF,
},
inlineContentMapping: {
...pdfDefaultSchemaMappings.inlineContentMapping,
@@ -11,6 +11,7 @@ import {
KEY_DOC,
KEY_LIST_DOC,
useDocStore,
useIsCollaborativeEditable,
useTrans,
useUpdateDoc,
} from '@/docs/doc-management';
@@ -21,7 +22,10 @@ interface DocTitleProps {
}
export const DocTitle = ({ doc }: DocTitleProps) => {
if (!doc.abilities.partial_update) {
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
if (readOnly) {
return <DocTitleText />;
}
@@ -1,5 +1,3 @@
import { useTranslation } from 'react-i18next';
import { Text, TextType } from '@/components';
type DocIconProps = TextType & {
@@ -15,8 +13,6 @@ export const DocIcon = ({
$weight = '400',
...textProps
}: DocIconProps) => {
const { t } = useTranslation();
if (!emoji) {
return <>{defaultIcon}</>;
}
@@ -28,7 +24,7 @@ export const DocIcon = ({
$variation={$variation}
$weight={$weight}
aria-hidden="true"
aria-label={t('Document emoji icon')}
data-testid="doc-emoji-icon"
>
{emoji}
</Text>
@@ -1,50 +1,23 @@
import { Button } from '@openfun/cunningham-react';
import Head from 'next/head';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import img403 from '@/assets/icons/icon-403.png';
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
import { DEFAULT_QUERY_RETRY } from '@/core';
import { KEY_DOC, useDoc } from '@/docs/doc-management';
import { ButtonAccessRequest } from '@/docs/doc-share';
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
width: fit-content;
`;
export function DocLayout() {
const {
query: { id },
} = useRouter();
if (typeof id !== 'string') {
return null;
}
return (
<>
<Head>
<meta name="robots" content="noindex" />
</Head>
<MainLayout>
<DocPage403 id={id} />
</MainLayout>
</>
);
}
interface DocProps {
id: string;
}
const DocPage403 = ({ id }: DocProps) => {
export const DocPage403 = ({ id }: DocProps) => {
const { t } = useTranslation();
const {
data: requests,
@@ -54,39 +27,19 @@ const DocPage403 = ({ id }: DocProps) => {
docId: id,
page: 1,
});
const { replace } = useRouter();
const hasRequested = !!requests?.results.find(
(request) => request.document === id,
);
const { error: docError, isLoading: isLoadingDoc } = useDoc(
{ id },
{
staleTime: 0,
queryKey: [KEY_DOC, { id }],
retry: (failureCount, error) => {
if (error.status == 403) {
return false;
} else {
return failureCount < DEFAULT_QUERY_RETRY;
}
},
},
);
if (!isLoadingDoc && docError?.status !== 403) {
void replace(`/docs/${id}`);
return <Loading />;
}
if (isLoadingDoc || isLoadingRequest) {
if (isLoadingRequest) {
return <Loading />;
}
return (
<>
<Head>
<meta name="robots" content="noindex" />
<title>
{t('Access Denied - Error 403')} - {t('Docs')}
</title>
@@ -106,6 +59,8 @@ const DocPage403 = ({ id }: DocProps) => {
className="c__image-system-filter"
src={img403}
alt={t('Image 403')}
width={300}
height={300}
style={{
maxWidth: '100%',
height: 'auto',
@@ -152,13 +107,3 @@ const DocPage403 = ({ id }: DocProps) => {
</>
);
};
const Page: NextPageWithLayout = () => {
return null;
};
Page.getLayout = function getLayout() {
return <DocLayout />;
};
export default Page;

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